From 3d87756c5e6b378ae0b42040ae11d0bf531c3df6 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 23:59:58 +0100 Subject: [PATCH 01/14] feat: add repository-owned control program flows --- .agents/skills/boatstack-autoplan/SKILL.md | 48 -- .../boatstack-autoplan/agents/openai.yaml | 6 - .agents/skills/boatstack-run/SKILL.md | 48 -- .../skills/boatstack-run/agents/openai.yaml | 6 - .claude/skills/boatstack-autoplan/SKILL.md | 48 -- .claude/skills/boatstack-run/SKILL.md | 48 -- .cursor/commands/boatstack-autoplan.md | 48 -- .cursor/commands/boatstack-run.md | 48 -- .gemini/skills/boatstack-autoplan/SKILL.md | 48 -- .gemini/skills/boatstack-run/SKILL.md | 48 -- .github/tests/test_detached_supervision.py | 16 +- .github/tests/test_repository_contract.py | 36 +- .github/workflows/ci.yml | 23 +- .gitignore | 2 + README.md | 26 +- .../cmd/boatstack-helper/flow_command.go | 305 +++++++++ .../cmd/boatstack-helper/flow_runtime.go | 254 ++++++++ .../cmd/boatstack-helper/flow_runtime_test.go | 189 ++++++ boatstack/cmd/boatstack-helper/main.go | 48 +- boatstack/cmd/boatstack-helper/main_test.go | 22 +- boatstack/controlprogram/artifact.go | 175 +++++ boatstack/controlprogram/canonical.go | 612 ++++++++++++++++++ boatstack/controlprogram/canonical_test.go | 180 ++++++ .../frontend_conformance_test.go | 54 ++ boatstack/controlprogram/ir.go | 153 +++++ boatstack/delivery_controller.go | 9 +- boatstack/distribution/standard.go | 12 +- boatstack/flow/softwaredelivery/bindings.go | 120 ++++ boatstack/flow/softwaredelivery/definition.go | 222 +++++++ .../flow/softwaredelivery/definition_test.go | 118 ++++ boatstack/flow/softwaredelivery/skills.go | 71 ++ .../flow/softwaredelivery/skills_test.go | 42 ++ boatstack/flow/standard/completeness_test.go | 64 +- boatstack/internal/runtime/flow_files.go | 56 ++ .../softwaredelivery/effects/host_skills.go | 28 - .../effects/host_skills_test.go | 61 +- .../softwaredelivery/surfaces/protocol.go | 48 +- .../surfaces/protocol_test.go | 18 + .../softwaredelivery/surfaces/render.go | 11 +- .../softwaredelivery/surfaces/render_test.go | 4 +- .../incident-response.flow.ts | 48 ++ .../incident-response.raw.json | 47 ++ docs/control-program-ir.md | 40 ++ docs/generated-files.md | 16 + docs/getting-started.md | 15 +- package-lock.json | 566 ++++++++++++++++ package.json | 14 + .../boatstack-software-delivery/package.json | 17 + .../boatstack-software-delivery/src/index.ts | 164 +++++ .../boatstack-software-delivery/tsconfig.json | 16 + .../boatstack/bin/boatstack-flow-frontend.mjs | 21 + packages/boatstack/package.json | 21 + packages/boatstack/src/index.ts | 208 ++++++ packages/boatstack/tsconfig.json | 15 + .../2026-08-12-repository-flow-entries.md | 3 + tsconfig.json | 7 + 56 files changed, 4061 insertions(+), 532 deletions(-) delete mode 100644 .agents/skills/boatstack-autoplan/SKILL.md delete mode 100644 .agents/skills/boatstack-autoplan/agents/openai.yaml delete mode 100644 .agents/skills/boatstack-run/SKILL.md delete mode 100644 .agents/skills/boatstack-run/agents/openai.yaml delete mode 100644 .claude/skills/boatstack-autoplan/SKILL.md delete mode 100644 .claude/skills/boatstack-run/SKILL.md delete mode 100644 .cursor/commands/boatstack-autoplan.md delete mode 100644 .cursor/commands/boatstack-run.md delete mode 100644 .gemini/skills/boatstack-autoplan/SKILL.md delete mode 100644 .gemini/skills/boatstack-run/SKILL.md create mode 100644 boatstack/cmd/boatstack-helper/flow_command.go create mode 100644 boatstack/cmd/boatstack-helper/flow_runtime.go create mode 100644 boatstack/cmd/boatstack-helper/flow_runtime_test.go create mode 100644 boatstack/controlprogram/artifact.go create mode 100644 boatstack/controlprogram/canonical.go create mode 100644 boatstack/controlprogram/canonical_test.go create mode 100644 boatstack/controlprogram/frontend_conformance_test.go create mode 100644 boatstack/controlprogram/ir.go create mode 100644 boatstack/flow/softwaredelivery/bindings.go create mode 100644 boatstack/flow/softwaredelivery/definition.go create mode 100644 boatstack/flow/softwaredelivery/definition_test.go create mode 100644 boatstack/flow/softwaredelivery/skills.go create mode 100644 boatstack/flow/softwaredelivery/skills_test.go create mode 100644 boatstack/internal/runtime/flow_files.go create mode 100644 boatstack/testdata/control-programs/incident-response.flow.ts create mode 100644 boatstack/testdata/control-programs/incident-response.raw.json create mode 100644 docs/control-program-ir.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/boatstack-software-delivery/package.json create mode 100644 packages/boatstack-software-delivery/src/index.ts create mode 100644 packages/boatstack-software-delivery/tsconfig.json create mode 100755 packages/boatstack/bin/boatstack-flow-frontend.mjs create mode 100644 packages/boatstack/package.json create mode 100644 packages/boatstack/src/index.ts create mode 100644 packages/boatstack/tsconfig.json create mode 100644 release-notes/2026-08-12-repository-flow-entries.md create mode 100644 tsconfig.json 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..b0dea3e 100644 --- a/README.md +++ b/README.md @@ -65,15 +65,17 @@ 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`. + 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 +133,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 +147,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 +172,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 +234,9 @@ 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. +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..beedbcd --- /dev/null +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -0,0 +1,305 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + 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 + } + artifactPath, err := resolveArtifactPath(options.repository, options.artifact, source) + if err != nil { + return err + } + lockPath, err := exactRepositoryPath(options.repository, options.lock) + if err != nil { + return err + } + frontend, err := resolveFrontend(options.repository, options.frontend) + if err != nil { + return err + } + rawIR, err := boatstackruntime.RunFlowFrontend(ctx, frontend, source) + if 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 + } + skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + 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 + } + 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 + } + retiredSkills, err := retiredGeneratedSkills(options.repository, 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) + for _, path := range paths { + absolute, pathErr := exactRepositoryPath(options.repository, path) + if pathErr != nil { + return pathErr + } + if err := boatstackruntime.AtomicWrite(absolute, skills[path], 0o644); err != nil { + return err + } + } + for _, path := range retiredSkills { + if err := boatstackruntime.RemoveGeneratedFile(path); err != nil { + return fmt.Errorf("remove retired generated skill: %w", err) + } + } + if err := boatstackruntime.AtomicWrite(artifactPath, artifactRaw, 0o644); err != nil { + return err + } + return renderFlowResult("compiled", artifactPath, artifact) +} + +func retiredGeneratedSkills(repository, artifactPath string, next map[string]string) ([]string, error) { + info, err := os.Lstat(artifactPath) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact is not a regular file") + } + raw, err := os.ReadFile(artifactPath) + if err != nil { + return nil, err + } + prior, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact cannot authorize projection replacement: %w", err) + } + retired := make([]string, 0) + for relative, expected := range prior.GeneratedSkills { + if _, retained := next[relative]; retained { + continue + } + path, pathErr := exactRepositoryPath(repository, relative) + if pathErr != nil { + return nil, pathErr + } + fileInfo, statErr := os.Lstat(path) + if statErr != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() { + return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s is missing or not regular", relative) + } + content, readErr := os.ReadFile(path) + if readErr != nil || fileDigest(content) != expected { + return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s was modified", relative) + } + retired = append(retired, path) + } + sort.Strings(retired) + return retired, 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 + } + if _, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, resolver); err != nil { + return err + } + return renderFlowResult("valid", artifactPath, artifact) +} + +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, source string) (string, error) { + if requested != "" { + return exactRepositoryPath(repository, requested) + } + if !strings.HasSuffix(source, ".flow.ts") { + return "", fmt.Errorf("Flow source must end with .flow.ts") + } + return strings.TrimSuffix(source, ".ts") + ".ir.json", 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(repository, requested string) (string, error) { + if requested != "" { + if !filepath.IsAbs(requested) { + return "", fmt.Errorf("--frontend must be exact and absolute") + } + return filepath.Clean(requested), nil + } + name := "boatstack-flow-frontend" + if runtime.GOOS == "windows" { + name += ".cmd" + } + candidate := filepath.Join(repository, "node_modules", ".bin", name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return filepath.Clean(candidate), nil + } + return "", fmt.Errorf("FLOW_FRONTEND_REQUIRED: install @operatorstack/boatstack or pass --frontend") +} + +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..2b6743e --- /dev/null +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -0,0 +1,254 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" +) + +var flowSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +type planInboxConfig struct { + Path string `json:"path"` + Cardinality string `json:"cardinality"` +} + +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) + if err != nil { + return commandOptions{}, err + } + 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 + } + runID := flowRunID(repository, compiled.Fingerprint, options.entryID, deliveryID) + if options.runID != "" && options.runID != runID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and worktree") + } + options.repository = repository + options.runID = runID + if options.objectiveKind == "" { + options.objectiveKind = string(objective) + } + if options.deliveryID == "" { + options.deliveryID = deliveryID + } + if options.objectiveID == "" { + options.objectiveID = "objective-" + options.programID + "-" + options.entryID + "-" + deliveryID + } + if options.objectiveKind != string(objective) || options.deliveryID != deliveryID { + 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 + } + switch options.transitionID { + case "objective.bind": + if _, exists := parameters.Get("objective_kind"); !exists { + options.parameters = append(options.parameters, "objective_kind="+string(objective)) + } + if _, exists := parameters.Get("delivery_id"); !exists { + options.parameters = append(options.parameters, "delivery_id="+deliveryID) + } + case "plan.create", "plan.amend": + if _, exists := parameters.Get("source_path"); !exists { + options.parameters = append(options.parameters, "source_path="+plan) + } + if _, exists := parameters.Get("delivery_id"); !exists { + options.parameters = append(options.parameters, "delivery_id="+deliveryID) + } + } + return options, nil +} + +func resolveBoundPlan(repository string, entry controlprogram.Entry, options commandOptions) (string, string, error) { + if options.runID != "" && flowSegment.MatchString(options.deliveryID) { + managed := filepath.Join(repository, ".boatstack", "plans", options.deliveryID+".source") + if info, err := os.Stat(managed); err == nil && info.Mode().IsRegular() { + return managed, options.deliveryID, nil + } + } + return resolvePlanInput(repository, entry) +} + +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) + if err != nil { + return softwareflow.Definition{}, err + } + return softwareflow.NewDefinition(compiled, resolver) +} + +func resolvePlanInput(repository string, entry controlprogram.Entry) (string, string, error) { + var input *controlprogram.EntryInput + for index := range entry.Inputs { + if entry.Inputs[index].Resolver == "software-delivery.plan-inbox" { + input = &entry.Inputs[index] + break + } + } + if input == nil { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: entry %q has no trusted plan inbox", entry.ID) + } + decoder := json.NewDecoder(bytes.NewReader(input.Config)) + decoder.DisallowUnknownFields() + var config planInboxConfig + if err := decoder.Decode(&config); err != nil { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: %w", err) + } + if err := requireJSONEOF(decoder); err != nil || config.Cardinality != "exactly-one" { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: plan inbox requires exact cardinality") + } + inbox, err := exactRepositoryPath(repository, config.Path) + if err != nil { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: %w", err) + } + resolvedInbox, err := filepath.EvalSymlinks(inbox) + if err != nil && !os.IsNotExist(err) { + return "", "", 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 "", "", fmt.Errorf("FLOW_INPUT_INVALID: plan inbox escapes the repository") + } + inbox = resolvedInbox + } + 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]) + info, err := os.Lstat(selected) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: selected plan is not a regular repository file") + } + resolved, err := filepath.EvalSymlinks(selected) + if err != nil { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: resolve selected plan: %w", err) + } + relative, err := filepath.Rel(inbox, resolved) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: selected plan escapes its inbox") + } + return resolved, deliveryID, 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 flowRunID(repository, fingerprint, entry, delivery string) string { + value := strings.Join([]string{repository, fingerprint, entry, delivery}, "\x00") + digest := sha256.Sum256([]byte(value)) + return "run-" + hex.EncodeToString(digest[:16]) +} + +func requireJSONEOF(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 +} 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..05ae6be --- /dev/null +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" +) + +func flowRepository(t *testing.T) string { + t.Helper() + repository := t.TempDir() + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + truth := true + config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) + document := controlprogram.Document{ + SchemaVersion: controlprogram.SchemaVersion, + Program: controlprogram.Program{ID: "product-delivery", 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}}}}, + } + 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) + } + 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) + } + 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) + return repository +} + +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 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) + } + writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("exact plan")) + writeFixture(t, repository, ".boatstack/plans/inbox/unrelated.md", []byte("other plan")) + writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("approved amendment")) + 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 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 := t.TempDir() + 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) + paths, err := retiredGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + if err != nil { + t.Fatal(err) + } + if len(paths) != 1 || paths[0] != filepath.Join(repository, filepath.FromSlash(retiredPath)) { + t.Fatalf("retired paths = %v", paths) + } + writeFixture(t, repository, retiredPath, []byte("user changed")) + if _, err := retiredGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { + t.Fatalf("modified retired projection was not protected: %v", err) + } +} diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 5a32bd7..525ed33 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,9 @@ type commandOptions struct { objectiveID string objectiveKind string deliveryID string - flowID string + programID string + entryID string + runID string transitionID string correlationID string prescriptionID string @@ -95,6 +98,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,6 +109,10 @@ 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 @@ -114,7 +124,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 +157,7 @@ func run(arguments []string) error { } func usageError() error { - return errors.New("usage: boatstack [flags]") + return errors.New("usage: boatstack [flags]") } func runRPC() error { @@ -251,7 +263,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,7 +324,17 @@ 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 + } + program, err = distribution.ProgramForRepository(ctx, programRequest, definition) + } else { + program, err = distribution.StandardProgramForRepository(ctx, programRequest) + } if err != nil { return boatstack.DeliveryController{}, err } @@ -455,7 +479,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 +491,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 +500,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, 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 +559,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 +579,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..912f7a9 --- /dev/null +++ b/boatstack/controlprogram/artifact.go @@ -0,0 +1,175 @@ +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 +} + +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 := io.ReadAll(io.LimitReader(source, 32<<20)) + 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) (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) + } + } + paths := make([]string, 0, len(artifact.GeneratedSkills)) + for path := range artifact.GeneratedSkills { + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + raw, readErr := readRepositoryFile(repository, path) + if readErr != nil || digest(raw) != artifact.GeneratedSkills[path] { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill %s does not match artifact", path) + } + } + 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") + } + 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..9ff2b88 --- /dev/null +++ b/boatstack/controlprogram/canonical.go @@ -0,0 +1,612 @@ +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 := io.ReadAll(io.LimitReader(source, 16<<20)) + 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 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.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..5d8ddae --- /dev/null +++ b/boatstack/controlprogram/canonical_test.go @@ -0,0 +1,180 @@ +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"}, + 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"}}, + {ID: "incident", Kind: "enum", Values: []string{"open", "mitigated"}}, + }, + Evidence: []controlprogram.Evidence{{ID: "healthcheck", Subject: "service", Kind: "observation"}}, + Operators: []controlprogram.Operator{{ + ID: "restart", Capabilities: []string{"service.restart"}, Authority: []string{"incident-commander"}, + Effects: []string{"service.restart"}, Verifier: "healthcheck", Recovery: "restart", + 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")}}, + Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated", 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) + } +} + +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 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) + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil); 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); 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..3d94080 --- /dev/null +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -0,0 +1,54 @@ +package controlprogram_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "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) + } +} 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..ff6b6ef --- /dev/null +++ b/boatstack/flow/softwaredelivery/definition.go @@ -0,0 +1,222 @@ +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) 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) + } + 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) + } + transition.SourceConditions = append(transition.SourceConditions, guard...) + transition.TargetConditions = append(transition.TargetConditions, target...) + transition.Priority = declaration.Priority + transition.ObjectiveKinds = transition.ObjectiveKinds[:0] + for objective := range objectives { + transition.ObjectiveKinds = append(transition.ObjectiveKinds, objective) + } + 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 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..24537ea --- /dev/null +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -0,0 +1,118 @@ +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 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/skills.go b/boatstack/flow/softwaredelivery/skills.go new file mode 100644 index 0000000..fac23d2 --- /dev/null +++ b/boatstack/flow/softwaredelivery/skills.go @@ -0,0 +1,71 @@ +package softwaredelivery + +import ( + "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 := compiled.Document.Program.ID + "-" + entry.ID + 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 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 + "." + } + return []byte(fmt.Sprintf(`--- +name: %s +description: %s Use only when the user explicitly selects this repository Flow entry. +--- + +# %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..eccd684 --- /dev/null +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -0,0 +1,42 @@ +package softwaredelivery_test + +import ( + "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) + } + } +} diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 323405a..f1c300d 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,66 @@ 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.AtomicWrite": 2, "runtime.RemoveGeneratedFile": 1, + }, + "internal/runtime/flow_files.go": { + "os.MkdirAll": 1, "os.CreateTemp": 1, "os.Remove": 2, "os.Rename": 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 strings.HasSuffix(importPath, "/internal/runtime") && (selector.Sel.Name == "AtomicWrite" || selector.Sel.Name == "RemoveGeneratedFile"): + 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 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 +427,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/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go new file mode 100644 index 0000000..30dbf4a --- /dev/null +++ b/boatstack/internal/runtime/flow_files.go @@ -0,0 +1,56 @@ +package runtime + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +func RunFlowFrontend(ctx context.Context, executable, source string) ([]byte, error) { + if executable == "" || !filepath.IsAbs(executable) || !filepath.IsAbs(source) { + return nil, fmt.Errorf("Flow frontend and source paths must be exact and absolute") + } + command := exec.CommandContext(ctx, executable, 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 +} + +func AtomicWrite(path string, content []byte, mode os.FileMode) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("managed output path must be exact and absolute") + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".boatstack-flow-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if _, err = temporary.Write(content); err == nil { + err = temporary.Chmod(mode) + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(temporaryPath, path) +} + +// RemoveGeneratedFile keeps Flow projection deletion inside the same owned +// filesystem boundary as projection creation. Callers must validate the exact +// path and prior fingerprint before invoking it. +func RemoveGeneratedFile(path string) error { + return os.Remove(path) +} 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/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index c95994e..6576d71 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,8 @@ type Request struct { Repository string `json:"repository"` Host string `json:"host"` CorrelationID string `json:"correlation_id"` + ProgramID string `json:"program_id,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 +65,12 @@ 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) || !flowContextIdentity.MatchString(r.EntryID) || r.FlowID == "") { + return fmt.Errorf("surface Flow entry requires semantic program, entry, and run identity") + } if r.Operation != OperationCatalog { knownHost := false for _, host := range CanonicalHostNames() { @@ -125,9 +138,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 +156,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/control-program-ir.md b/docs/control-program-ir.md new file mode 100644 index 0000000..ee38aec --- /dev/null +++ b/docs/control-program-ir.md @@ -0,0 +1,40 @@ +# 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, then run: + +```sh +boatstack flow compile --repo . +boatstack flow check --repo . +boatstack next --repo . --flow product-delivery --entry run +``` + +Compilation executes the TypeScript frontend and emits raw IR. Boatstack then +strictly validates, canonicalizes, fingerprints, and writes the committed +`.flow.ir.json` artifact and generated Codex and Claude skills. Runtime commands +never execute `flow.ts`. + +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. + +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. 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..61ab5f8 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -15,6 +15,22 @@ 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 | + +`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. + ## 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..aa0041d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "boatstack-workspace", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "boatstack-workspace", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "typescript": "5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@operatorstack/boatstack": { + "resolved": "packages/boatstack", + "link": true + }, + "node_modules/@operatorstack/boatstack-software-delivery": { + "resolved": "packages/boatstack-software-delivery", + "link": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", + "integrity": "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "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": { + "tsx": "4.20.6" + }, + "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..8a6bfc3 --- /dev/null +++ b/packages/boatstack-software-delivery/package.json @@ -0,0 +1,17 @@ +{ + "name": "@operatorstack/boatstack-software-delivery", + "version": "0.0.0-development", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./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..4512a02 --- /dev/null +++ b/packages/boatstack/bin/boatstack-flow-frontend.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import { tsImport } from "tsx/esm/api"; + +const source = process.argv[2]; +if (!source || process.argv.length !== 3) { + console.error("usage: boatstack-flow-frontend "); + process.exit(2); +} + +const absolute = resolve(source); +const loaded = await tsImport(pathToFileURL(absolute).href, import.meta.url); +const exported = + loaded.default?.__esModule && loaded.default.default + ? loaded.default.default + : loaded.default; +if (!exported || typeof exported !== "object") { + throw new Error("Flow module must default-export a Control Program IR object"); +} +process.stdout.write(`${JSON.stringify(exported)}\n`); diff --git a/packages/boatstack/package.json b/packages/boatstack/package.json new file mode 100644 index 0000000..53f8d76 --- /dev/null +++ b/packages/boatstack/package.json @@ -0,0 +1,21 @@ +{ + "name": "@operatorstack/boatstack", + "version": "0.0.0-development", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "bin": { + "boatstack-flow-frontend": "./bin/boatstack-flow-frontend.mjs" + }, + "files": [ + "bin", + "dist" + ], + "dependencies": { + "tsx": "4.20.6" + } +} 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" } + ] +} From ba5e212886cc151896dfa3f3ed5255748247aee7 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 00:03:37 +0100 Subject: [PATCH 02/14] fix: support Node 20 package resolution --- packages/boatstack-software-delivery/package.json | 3 ++- packages/boatstack/package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/boatstack-software-delivery/package.json b/packages/boatstack-software-delivery/package.json index 8a6bfc3..349d9cb 100644 --- a/packages/boatstack-software-delivery/package.json +++ b/packages/boatstack-software-delivery/package.json @@ -5,7 +5,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "default": "./dist/index.js" } }, "files": [ diff --git a/packages/boatstack/package.json b/packages/boatstack/package.json index 53f8d76..9f0c049 100644 --- a/packages/boatstack/package.json +++ b/packages/boatstack/package.json @@ -5,7 +5,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "default": "./dist/index.js" } }, "bin": { From 1373d1366b256ce42838665ec07214adf56416c2 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 00:30:22 +0100 Subject: [PATCH 03/14] fix: close flow execution boundaries --- .../cmd/boatstack-helper/flow_command.go | 17 +- .../cmd/boatstack-helper/flow_runtime.go | 31 +++ .../cmd/boatstack-helper/flow_runtime_test.go | 42 +++- boatstack/cmd/boatstack-helper/main.go | 4 + boatstack/flow/softwaredelivery/definition.go | 45 ++++ .../flow/softwaredelivery/definition_test.go | 18 ++ boatstack/flow/standard/completeness_test.go | 6 +- boatstack/internal/runtime/flow_files.go | 200 +++++++++++++++++- boatstack/internal/runtime/flow_files_test.go | 90 ++++++++ 9 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 boatstack/internal/runtime/flow_files_test.go diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index beedbcd..8a78513 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -117,7 +117,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - retiredSkills, err := retiredGeneratedSkills(options.repository, artifactPath, artifact.GeneratedSkills) + obsoleteSkills, err := obsoleteGeneratedSkills(options.repository, artifactPath, artifact.GeneratedSkills) if err != nil { return err } @@ -126,27 +126,22 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { 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 } - if err := boatstackruntime.AtomicWrite(absolute, skills[path], 0o644); err != nil { - return err - } - } - for _, path := range retiredSkills { - if err := boatstackruntime.RemoveGeneratedFile(path); err != nil { - return fmt.Errorf("remove retired generated skill: %w", err) - } + writes = append(writes, boatstackruntime.ProjectionWrite{Path: absolute, Content: skills[path], Mode: 0o644}) } - if err := boatstackruntime.AtomicWrite(artifactPath, artifactRaw, 0o644); err != nil { + writes = append(writes, boatstackruntime.ProjectionWrite{Path: artifactPath, Content: artifactRaw, Mode: 0o644}) + if err := boatstackruntime.ApplyFlowProjection(options.repository, writes, obsoleteSkills); err != nil { return err } return renderFlowResult("compiled", artifactPath, artifact) } -func retiredGeneratedSkills(repository, artifactPath string, next map[string]string) ([]string, error) { +func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]string) ([]string, error) { info, err := os.Lstat(artifactPath) if os.IsNotExist(err) { return nil, nil diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 2b6743e..d924311 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -16,6 +16,8 @@ import ( "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/surfaces" ) var flowSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -113,6 +115,35 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return options, 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, + 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.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 != "" && flowSegment.MatchString(options.deliveryID) { managed := filepath.Join(repository, ".boatstack", "plans", options.deliveryID+".source") diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 05ae6be..0fa53f5 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -10,6 +10,8 @@ import ( "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/surfaces" ) func flowRepository(t *testing.T) string { @@ -102,6 +104,42 @@ func TestFlowEntryRejectsPlanCardinalityBeforeManagedState(t *testing.T) { } } +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 TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { // control-law: questions-and-restarts-preserve-the-exact-plan-worktree-and-run repository := flowRepository(t) @@ -175,7 +213,7 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { } artifactPath := filepath.Join(repository, ".boatstack", "flows", "program.flow.ir.json") writeFixture(t, repository, ".boatstack/flows/program.flow.ir.json", raw) - paths, err := retiredGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + paths, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) if err != nil { t.Fatal(err) } @@ -183,7 +221,7 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { t.Fatalf("retired paths = %v", paths) } writeFixture(t, repository, retiredPath, []byte("user changed")) - if _, err := retiredGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { + if _, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { t.Fatalf("modified retired projection was not protected: %v", err) } } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 525ed33..ee9f1fb 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -171,6 +171,10 @@ 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 + } kernel, err := standardKernel(context.Background(), request) if err != nil { return err diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index ff6b6ef..c8682d9 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -67,6 +67,9 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim 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 @@ -186,6 +189,48 @@ func canonicalConditions(values []delivery.FacetCondition) []byte { 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{} diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 24537ea..c0593b4 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -95,6 +95,24 @@ func TestRepositoryGuardCanOnlyStrengthenTrustedBinding(t *testing.T) { } } +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 TestCompiledBindingDriftFailsClosed(t *testing.T) { truth := true compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index f1c300d..c6fce1c 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -178,10 +178,10 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { root := sourceRoot(t) expected := map[string]map[string]int{ "cmd/boatstack-helper/flow_command.go": { - "runtime.AtomicWrite": 2, "runtime.RemoveGeneratedFile": 1, + "runtime.ApplyFlowProjection": 1, }, "internal/runtime/flow_files.go": { - "os.MkdirAll": 1, "os.CreateTemp": 1, "os.Remove": 2, "os.Rename": 1, + "os.MkdirAll": 1, "os.CreateTemp": 1, "os.Remove": 5, "os.Rename": 2, }, } for relative, wanted := range expected { @@ -213,7 +213,7 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { switch { case importPath == "os" && writerCallsForInventory(selector.Sel.Name): observed["os."+selector.Sel.Name]++ - case strings.HasSuffix(importPath, "/internal/runtime") && (selector.Sel.Name == "AtomicWrite" || selector.Sel.Name == "RemoveGeneratedFile"): + case strings.HasSuffix(importPath, "/internal/runtime") && selector.Sel.Name == "ApplyFlowProjection": observed["runtime."+selector.Sel.Name]++ } return true diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 30dbf4a..4dd553b 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "path/filepath" + "sort" + "strings" ) func RunFlowFrontend(ctx context.Context, executable, source string) ([]byte, error) { @@ -23,19 +25,180 @@ func RunFlowFrontend(ctx context.Context, executable, source string) ([]byte, er return output, nil } -func AtomicWrite(path string, content []byte, mode os.FileMode) error { +type ProjectionWrite struct { + Path string + Content []byte + Mode os.FileMode +} + +type projectionSnapshot struct { + path string + exists bool + content []byte + mode os.FileMode +} + +type stagedProjection struct { + target string + temporary string +} + +// 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 []string) 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") + } + writes = append([]ProjectionWrite(nil), writes...) + removals = append([]string(nil), removals...) + sort.Slice(writes, func(i, j int) bool { return writes[i].Path < writes[j].Path }) + sort.Strings(removals) + + seen := map[string]bool{} + snapshots := map[string]projectionSnapshot{} + for _, write := range writes { + if write.Mode.Perm() == 0 || seen[write.Path] { + return fmt.Errorf("projection contains an invalid or duplicate write path") + } + seen[write.Path] = true + if err := validateProjectionPath(repository, write.Path); err != nil { + return err + } + snapshot, err := snapshotProjectionPath(write.Path) + if err != nil { + return err + } + snapshots[write.Path] = snapshot + } + for _, path := range removals { + if seen[path] { + return fmt.Errorf("projection cannot write and remove the same path") + } + seen[path] = true + if err := validateProjectionPath(repository, path); err != nil { + return err + } + snapshot, err := snapshotProjectionPath(path) + if err != nil { + return err + } + if !snapshot.exists { + return fmt.Errorf("projection removal target is missing") + } + snapshots[path] = snapshot + } + + staged := make([]stagedProjection, 0, len(writes)) + defer func() { + for _, value := range staged { + _ = os.Remove(value.temporary) + } + }() + for _, write := range writes { + if err := os.MkdirAll(filepath.Dir(write.Path), 0o755); err != nil { + return err + } + if err := validateProjectionPath(repository, write.Path); err != nil { + return err + } + temporary, err := stageProjectionFile(write.Path, write.Content, write.Mode) + if err != nil { + return err + } + staged = append(staged, stagedProjection{target: write.Path, temporary: temporary}) + } + + changed := make([]string, 0, len(writes)+len(removals)) + var commitErr error + for _, value := range staged { + if commitErr = os.Rename(value.temporary, value.target); commitErr != nil { + break + } + changed = append(changed, value.target) + } + if commitErr == nil { + for _, path := range removals { + if commitErr = os.Remove(path); commitErr != nil { + break + } + changed = append(changed, path) + } + } + if commitErr == nil { + return nil + } + if rollbackErr := rollbackProjection(changed, snapshots); rollbackErr != nil { + return fmt.Errorf("commit Flow projection: %v; rollback failed: %w", commitErr, rollbackErr) + } + return fmt.Errorf("commit Flow projection: %w", commitErr) +} + +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") } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + 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 snapshotProjectionPath(path string) (projectionSnapshot, error) { + info, err := os.Lstat(path) + 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 := os.ReadFile(path) + if err != nil { + return projectionSnapshot{}, err + } + return projectionSnapshot{path: path, exists: true, content: content, mode: info.Mode().Perm()}, nil +} + +func stageProjectionFile(path string, content []byte, mode os.FileMode) (string, error) { temporary, err := os.CreateTemp(filepath.Dir(path), ".boatstack-flow-*") if err != nil { - return err + return "", err } temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) if _, err = temporary.Write(content); err == nil { err = temporary.Chmod(mode) } @@ -43,14 +206,29 @@ func AtomicWrite(path string, content []byte, mode os.FileMode) error { err = closeErr } if err != nil { - return err + _ = os.Remove(temporaryPath) + return "", err } - return os.Rename(temporaryPath, path) + return temporaryPath, nil } -// RemoveGeneratedFile keeps Flow projection deletion inside the same owned -// filesystem boundary as projection creation. Callers must validate the exact -// path and prior fingerprint before invoking it. -func RemoveGeneratedFile(path string) error { - return os.Remove(path) +func rollbackProjection(changed []string, snapshots map[string]projectionSnapshot) error { + for index := len(changed) - 1; index >= 0; index-- { + snapshot := snapshots[changed[index]] + if !snapshot.exists { + if err := os.Remove(snapshot.path); err != nil && !os.IsNotExist(err) { + return err + } + continue + } + temporary, err := stageProjectionFile(snapshot.path, snapshot.content, snapshot.mode) + if err != nil { + return err + } + if err := os.Rename(temporary, snapshot.path); err != nil { + _ = os.Remove(temporary) + return err + } + } + return nil } diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go new file mode 100644 index 0000000..06cb072 --- /dev/null +++ b/boatstack/internal/runtime/flow_files_test.go @@ -0,0 +1,90 @@ +package runtime + +import ( + "os" + "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) + } + return repository +} + +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) + 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 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}, + } + err := ApplyFlowProjection(repository, writes, []string{retiredPath}) + 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, []string{retiredPath}); 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) + } +} From 76e4cf6f9deacb5f2999c0a73b8575d72b505fea Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 00:50:01 +0100 Subject: [PATCH 04/14] fix: harden flow authoring boundaries --- README.md | 2 + .../cmd/boatstack-helper/flow_command.go | 26 +- .../cmd/boatstack-helper/flow_runtime.go | 28 +- .../cmd/boatstack-helper/flow_runtime_test.go | 58 ++ .../frontend_conformance_test.go | 62 +++ boatstack/flow/softwaredelivery/definition.go | 8 +- .../flow/softwaredelivery/definition_test.go | 20 + boatstack/flow/standard/completeness_test.go | 2 +- boatstack/internal/runtime/flow_files.go | 45 +- boatstack/internal/runtime/flow_files_test.go | 21 + .../internal/runtime/projection_lock_unix.go | 16 + .../runtime/projection_lock_windows.go | 40 ++ docs/control-program-ir.md | 12 +- docs/generated-files.md | 2 + package-lock.json | 514 +----------------- .../boatstack/bin/boatstack-flow-frontend.mjs | 132 ++++- packages/boatstack/package.json | 2 +- 17 files changed, 437 insertions(+), 553 deletions(-) create mode 100644 boatstack/internal/runtime/projection_lock_unix.go create mode 100644 boatstack/internal/runtime/projection_lock_windows.go diff --git a/README.md b/README.md index b0dea3e..8d17ca5 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,8 @@ The current authoring boundary already includes: 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 index 8a78513..d57a14e 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -84,27 +84,33 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - rawIR, err := boatstackruntime.RunFlowFrontend(ctx, frontend, source) + sourceRaw, err := os.ReadFile(source) if err != nil { return err } - resolver, err := softwareflow.NewResolver(ctx) + lockRaw, err := os.ReadFile(lockPath) if err != nil { return err } - compiled, err := controlprogram.Load(bytes.NewReader(rawIR), resolver) + rawIR, err := boatstackruntime.RunFlowFrontend(ctx, frontend, source, sourceRaw) if err != nil { return err } - skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + 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 } - sourceRaw, err := os.ReadFile(source) + compiled, err := controlprogram.Load(bytes.NewReader(rawIR), resolver) if err != nil { return err } - lockRaw, err := os.ReadFile(lockPath) + skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) if err != nil { return err } @@ -141,6 +147,14 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { return renderFlowResult("compiled", artifactPath, artifact) } +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 obsoleteGeneratedSkills(repository, artifactPath string, next map[string]string) ([]string, error) { info, err := os.Lstat(artifactPath) if os.IsNotExist(err) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index d924311..e692926 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -98,23 +99,34 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } switch options.transitionID { case "objective.bind": - if _, exists := parameters.Get("objective_kind"); !exists { - options.parameters = append(options.parameters, "objective_kind="+string(objective)) + if err := bindResolvedParameter(&options, parameters, "objective_kind", string(objective)); err != nil { + return commandOptions{}, err } - if _, exists := parameters.Get("delivery_id"); !exists { - options.parameters = append(options.parameters, "delivery_id="+deliveryID) + if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { + return commandOptions{}, err } case "plan.create", "plan.amend": - if _, exists := parameters.Get("source_path"); !exists { - options.parameters = append(options.parameters, "source_path="+plan) + if err := bindResolvedParameter(&options, parameters, "source_path", plan); err != nil { + return commandOptions{}, err } - if _, exists := parameters.Get("delivery_id"); !exists { - options.parameters = append(options.parameters, "delivery_id="+deliveryID) + if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { + return commandOptions{}, err } } return options, 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 diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 0fa53f5..86d943d 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -5,12 +5,14 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" "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" ) @@ -140,6 +142,62 @@ func TestRPCFlowEntryPreservesObjectiveEvidenceAndStopContext(t *testing.T) { } } +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 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 TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { // control-law: questions-and-restarts-preserve-the-exact-plan-worktree-and-run repository := flowRepository(t) diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 3d94080..61dc8e1 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -6,6 +6,8 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" + "strings" "testing" "github.com/operatorstack/boatstack/boatstack/controlprogram" @@ -52,3 +54,63 @@ func TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint(t *testing.T) { 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/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index c8682d9..b4fbd73 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -73,9 +73,15 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim 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 { - transition.ObjectiveKinds = append(transition.ObjectiveKinds, objective) + 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) diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index c0593b4..1812d57 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -113,6 +113,26 @@ func TestRepositoryTargetMustBeImpliedByTrustedPostcondition(t *testing.T) { } } +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}) diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index c6fce1c..bb3de3b 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -181,7 +181,7 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { "runtime.ApplyFlowProjection": 1, }, "internal/runtime/flow_files.go": { - "os.MkdirAll": 1, "os.CreateTemp": 1, "os.Remove": 5, "os.Rename": 2, + "os.MkdirAll": 2, "os.CreateTemp": 1, "os.OpenFile": 1, "os.Remove": 5, "os.Rename": 2, }, } for relative, wanted := range expected { diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 4dd553b..7c74e89 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -1,7 +1,9 @@ package runtime import ( + "bytes" "context" + "crypto/sha256" "fmt" "os" "os/exec" @@ -10,11 +12,12 @@ import ( "strings" ) -func RunFlowFrontend(ctx context.Context, executable, source string) ([]byte, error) { - if executable == "" || !filepath.IsAbs(executable) || !filepath.IsAbs(source) { +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, source) + 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 { @@ -54,6 +57,11 @@ func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals [ if err != nil || resolvedRepository != repository { return fmt.Errorf("projection repository must be a resolved directory") } + lock, err := acquireProjectionLock(repository) + if err != nil { + return err + } + defer lock.release() writes = append([]ProjectionWrite(nil), writes...) removals = append([]string(nil), removals...) sort.Slice(writes, func(i, j int) bool { return writes[i].Path < writes[j].Path }) @@ -138,6 +146,37 @@ func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals [ return fmt.Errorf("commit Flow projection: %w", commitErr) } +type projectionLock struct { + file *os.File +} + +func acquireProjectionLock(repository string) (*projectionLock, error) { + cache, err := os.UserCacheDir() + if err != nil { + return nil, err + } + root := filepath.Join(cache, "boatstack", "flow-projection-locks") + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + identity := sha256.Sum256([]byte(repository)) + path := filepath.Join(root, fmt.Sprintf("%x.lock", identity)) + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + 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 (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") diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index 06cb072..456da42 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -88,3 +88,24 @@ func TestFlowProjectionStagesAllOutputsBeforeReplacement(t *testing.T) { 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) + 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); err != nil { + t.Fatal(err) + } +} 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/docs/control-program-ir.md b/docs/control-program-ir.md index ee38aec..613e3f4 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -22,16 +22,18 @@ boatstack flow check --repo . boatstack next --repo . --flow product-delivery --entry run ``` -Compilation executes the TypeScript frontend and emits raw IR. Boatstack then -strictly validates, canonicalizes, fingerprints, and writes the committed -`.flow.ir.json` artifact and generated Codex and Claude skills. Runtime commands -never execute `flow.ts`. +Compilation sends the exact source bytes to a restricted TypeScript frontend. +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 the committed `.flow.ir.json` artifact and generated skills as one +serialized update. Runtime commands never execute `flow.ts`. 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. +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 diff --git a/docs/generated-files.md b/docs/generated-files.md index 61ab5f8..840d822 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -30,6 +30,8 @@ committed runtime inputs: `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 while the frontend is running. ## Machine-local controller state diff --git a/package-lock.json b/package-lock.json index aa0041d..f4ec294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,422 +12,6 @@ "typescript": "5.9.3" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@operatorstack/boatstack": { "resolved": "packages/boatstack", "link": true @@ -436,106 +20,10 @@ "resolved": "packages/boatstack-software-delivery", "link": true }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", - "integrity": "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==", - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -549,7 +37,7 @@ "name": "@operatorstack/boatstack", "version": "0.0.0-development", "dependencies": { - "tsx": "4.20.6" + "typescript": "5.9.3" }, "bin": { "boatstack-flow-frontend": "bin/boatstack-flow-frontend.mjs" diff --git a/packages/boatstack/bin/boatstack-flow-frontend.mjs b/packages/boatstack/bin/boatstack-flow-frontend.mjs index 4512a02..22143ef 100755 --- a/packages/boatstack/bin/boatstack-flow-frontend.mjs +++ b/packages/boatstack/bin/boatstack-flow-frontend.mjs @@ -1,21 +1,123 @@ #!/usr/bin/env node -import { pathToFileURL } from "node:url"; +import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { tsImport } from "tsx/esm/api"; +import process from "node:process"; +import ts from "typescript"; -const source = process.argv[2]; -if (!source || process.argv.length !== 3) { - console.error("usage: boatstack-flow-frontend "); - process.exit(2); +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 "); } -const absolute = resolve(source); -const loaded = await tsImport(pathToFileURL(absolute).href, import.meta.url); -const exported = - loaded.default?.__esModule && loaded.default.default - ? loaded.default.default - : loaded.default; -if (!exported || typeof exported !== "object") { - throw new Error("Flow module must default-export a Control Program IR object"); +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"); } -process.stdout.write(`${JSON.stringify(exported)}\n`); + +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 index 9f0c049..5edef89 100644 --- a/packages/boatstack/package.json +++ b/packages/boatstack/package.json @@ -17,6 +17,6 @@ "dist" ], "dependencies": { - "tsx": "4.20.6" + "typescript": "5.9.3" } } From c7deee1745692f4339c1e2618413c732a933f3fb Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 01:09:07 +0100 Subject: [PATCH 05/14] fix: make flow projection authorization durable --- README.md | 4 +- .../cmd/boatstack-helper/flow_command.go | 96 ++++++++----- .../cmd/boatstack-helper/flow_runtime_test.go | 113 ++++++++++++--- boatstack/flow/standard/completeness_test.go | 2 +- boatstack/internal/runtime/flow_files.go | 136 +++++++++++++++--- boatstack/internal/runtime/flow_files_test.go | 127 +++++++++++++++- docs/control-program-ir.md | 14 +- docs/generated-files.md | 5 +- 8 files changed, 408 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 8d17ca5..a85a3bc 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ $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`. +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) diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index d57a14e..288e25e 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "path/filepath" - "runtime" "sort" "strings" @@ -72,9 +71,8 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - artifactPath, err := resolveArtifactPath(options.repository, options.artifact, 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 { @@ -110,6 +108,10 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if 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 @@ -123,7 +125,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - obsoleteSkills, err := obsoleteGeneratedSkills(options.repository, artifactPath, artifact.GeneratedSkills) + obsoleteSkills, artifactExpectation, err := obsoleteGeneratedSkills(options.repository, artifactPath, artifact.GeneratedSkills) if err != nil { return err } @@ -140,8 +142,13 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { } writes = append(writes, boatstackruntime.ProjectionWrite{Path: absolute, Content: skills[path], Mode: 0o644}) } - writes = append(writes, boatstackruntime.ProjectionWrite{Path: artifactPath, Content: artifactRaw, Mode: 0o644}) - if err := boatstackruntime.ApplyFlowProjection(options.repository, writes, obsoleteSkills); err != nil { + writes = append(writes, boatstackruntime.ProjectionWrite{Path: artifactPath, Content: artifactRaw, Mode: 0o644, PublishLast: true}) + expectations := []boatstackruntime.ProjectionExpectation{ + {Path: source, Exists: true, ExpectedSHA256: fileDigest(sourceRaw)}, + {Path: lockPath, Exists: true, ExpectedSHA256: fileDigest(lockRaw)}, + artifactExpectation, + } + if err := boatstackruntime.ApplyFlowProjection(options.repository, writes, obsoleteSkills, expectations); err != nil { return err } return renderFlowResult("compiled", artifactPath, artifact) @@ -155,46 +162,53 @@ func requireUnchangedCompileInput(path string, expected []byte) error { return nil } -func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]string) ([]string, error) { +func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]string) ([]boatstackruntime.ProjectionRemoval, boatstackruntime.ProjectionExpectation, error) { + expectation := boatstackruntime.ProjectionExpectation{Path: artifactPath} info, err := os.Lstat(artifactPath) if os.IsNotExist(err) { - return nil, nil + return nil, expectation, nil } if err != nil { - return nil, err + return nil, expectation, err } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact is not a regular file") + return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact is not a regular file") } raw, err := os.ReadFile(artifactPath) if err != nil { - return nil, err + return nil, expectation, err } + expectation.Exists = true + expectation.ExpectedSHA256 = fileDigest(raw) prior, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) if err != nil { - return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact cannot authorize projection replacement: %w", err) + return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact cannot authorize projection replacement: %w", err) } - retired := make([]string, 0) + retired := make([]boatstackruntime.ProjectionRemoval, 0) for relative, expected := range prior.GeneratedSkills { if _, retained := next[relative]; retained { continue } path, pathErr := exactRepositoryPath(repository, relative) if pathErr != nil { - return nil, pathErr + return nil, expectation, pathErr } fileInfo, statErr := os.Lstat(path) + if os.IsNotExist(statErr) { + retired = append(retired, boatstackruntime.ProjectionRemoval{Path: path, ExpectedSHA256: expected, AllowMissing: true}) + continue + } if statErr != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() { - return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s is missing or not regular", relative) + return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s is not regular", relative) } content, readErr := os.ReadFile(path) if readErr != nil || fileDigest(content) != expected { - return nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s was modified", relative) + return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s was modified", relative) } - retired = append(retired, path) + retired = append(retired, boatstackruntime.ProjectionRemoval{Path: path, ExpectedSHA256: expected, AllowMissing: true}) } - sort.Strings(retired) - return retired, nil + sort.Slice(retired, func(i, j int) bool { return retired[i].Path < retired[j].Path }) + return retired, expectation, nil } func fileDigest(value []byte) string { @@ -239,14 +253,22 @@ func resolveFlowSource(repository, requested string) (string, error) { return filepath.Clean(matches[0]), nil } -func resolveArtifactPath(repository, requested, source string) (string, error) { - if requested != "" { - return exactRepositoryPath(repository, requested) +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 !strings.HasSuffix(source, ".flow.ts") { - return "", fmt.Errorf("Flow source must end with .flow.ts") + 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 strings.TrimSuffix(source, ".ts") + ".ir.json", nil + return actual, nil } func resolveCheckArtifact(repository, requested string) (string, error) { @@ -263,22 +285,18 @@ func resolveCheckArtifact(repository, requested string) (string, error) { return filepath.Clean(matches[0]), nil } -func resolveFrontend(repository, requested string) (string, error) { - if requested != "" { - if !filepath.IsAbs(requested) { - return "", fmt.Errorf("--frontend must be exact and absolute") - } - return filepath.Clean(requested), nil +func resolveFrontend(_ string, requested string) (string, error) { + if requested == "" { + return "", fmt.Errorf("FLOW_FRONTEND_REQUIRED: pass an explicitly authorized absolute --frontend path") } - name := "boatstack-flow-frontend" - if runtime.GOOS == "windows" { - name += ".cmd" + if !filepath.IsAbs(requested) || filepath.Clean(requested) != requested { + return "", fmt.Errorf("--frontend must be exact and absolute") } - candidate := filepath.Join(repository, "node_modules", ".bin", name) - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return filepath.Clean(candidate), nil + info, err := os.Stat(requested) + if err != nil || info.IsDir() { + return "", fmt.Errorf("FLOW_FRONTEND_REQUIRED: explicit frontend is unavailable") } - return "", fmt.Errorf("FLOW_FRONTEND_REQUIRED: install @operatorstack/boatstack or pass --frontend") + return requested, nil } func exactRepositoryPath(repository, relative string) (string, error) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 86d943d..06c20f7 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -23,23 +23,7 @@ func flowRepository(t *testing.T) string { if err != nil { t.Fatal(err) } - truth := true - config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) - document := controlprogram.Document{ - SchemaVersion: controlprogram.SchemaVersion, - Program: controlprogram.Program{ID: "product-delivery", 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}}}}, - } + document := productDeliveryDocument("product-delivery") compiled, err := controlprogram.Compile(document, resolver) if err != nil { t.Fatal(err) @@ -66,6 +50,26 @@ func flowRepository(t *testing.T) string { return repository } +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)) @@ -198,6 +202,68 @@ func TestFlowCompileRejectsSourceChangedDuringFrontend(t *testing.T) { } } +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, "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 TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { // control-law: questions-and-restarts-preserve-the-exact-plan-worktree-and-run repository := flowRepository(t) @@ -271,15 +337,22 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { } artifactPath := filepath.Join(repository, ".boatstack", "flows", "program.flow.ir.json") writeFixture(t, repository, ".boatstack/flows/program.flow.ir.json", raw) - paths, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + paths, artifactExpectation, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) if err != nil { t.Fatal(err) } - if len(paths) != 1 || paths[0] != filepath.Join(repository, filepath.FromSlash(retiredPath)) { + if len(paths) != 1 || paths[0].Path != filepath.Join(repository, filepath.FromSlash(retiredPath)) || !artifactExpectation.Exists { t.Fatalf("retired paths = %v", paths) } writeFixture(t, repository, retiredPath, []byte("user changed")) - if _, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { + if _, _, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { t.Fatalf("modified retired projection was not protected: %v", err) } + if err := os.Remove(filepath.Join(repository, filepath.FromSlash(retiredPath))); err != nil { + t.Fatal(err) + } + paths, _, err = obsoleteGeneratedSkills(repository, 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/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index bb3de3b..333e49b 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -181,7 +181,7 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { "runtime.ApplyFlowProjection": 1, }, "internal/runtime/flow_files.go": { - "os.MkdirAll": 2, "os.CreateTemp": 1, "os.OpenFile": 1, "os.Remove": 5, "os.Rename": 2, + "os.MkdirAll": 2, "os.CreateTemp": 1, "os.OpenFile": 1, "os.Remove": 5, "os.Rename": 3, }, } for relative, wanted := range expected { diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 7c74e89..c8d41fa 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -29,9 +29,22 @@ func RunFlowFrontend(ctx context.Context, executable, sourceName string, source } type ProjectionWrite struct { - Path string - Content []byte - Mode os.FileMode + Path string + Content []byte + Mode os.FileMode + PublishLast bool +} + +type ProjectionRemoval struct { + Path string + ExpectedSHA256 string + AllowMissing bool +} + +type ProjectionExpectation struct { + Path string + Exists bool + ExpectedSHA256 string } type projectionSnapshot struct { @@ -46,10 +59,19 @@ type stagedProjection struct { temporary string } +type projectionHooks struct { + afterValidation func() + 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 []string) error { +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 { if !filepath.IsAbs(repository) || filepath.Clean(repository) != repository { return fmt.Errorf("projection repository must be exact and absolute") } @@ -63,16 +85,27 @@ func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals [ } defer lock.release() writes = append([]ProjectionWrite(nil), writes...) - removals = append([]string(nil), removals...) - sort.Slice(writes, func(i, j int) bool { return writes[i].Path < writes[j].Path }) - sort.Strings(removals) + 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 @@ -83,22 +116,43 @@ func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals [ } snapshots[write.Path] = snapshot } - for _, path := range removals { - if seen[path] { + 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[path] = true - if err := validateProjectionPath(repository, path); err != nil { + seen[removal.Path] = true + if err := validateProjectionPath(repository, removal.Path); err != nil { return err } - snapshot, err := snapshotProjectionPath(path) + snapshot, err := snapshotProjectionPath(removal.Path) if err != nil { return err } - if !snapshot.exists { + if !snapshot.exists && !removal.AllowMissing { return fmt.Errorf("projection removal target is missing") } - snapshots[path] = snapshot + 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(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)) @@ -123,18 +177,60 @@ func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals [ changed := make([]string, 0, len(writes)+len(removals)) var commitErr error - for _, value := range staged { + for index, value := range staged { + if writes[index].PublishLast { + continue + } if commitErr = os.Rename(value.temporary, value.target); commitErr != nil { break } changed = append(changed, value.target) } if commitErr == nil { - for _, path := range removals { - if commitErr = os.Remove(path); commitErr != nil { + for _, removal := range removals { + current, currentErr := snapshotProjectionPath(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 } - changed = append(changed, path) + if commitErr = os.Remove(removal.Path); commitErr != nil { + break + } + changed = append(changed, removal.Path) + } + } + if commitErr == nil && hooks.afterRemovals != nil { + hooks.afterRemovals() + } + if commitErr == nil && publishLast == 1 { + for _, expectation := range expectations { + current, currentErr := snapshotProjectionPath(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 + } + if commitErr = os.Rename(value.temporary, value.target); commitErr == nil { + changed = append(changed, value.target) + } + break } } if commitErr == nil { @@ -146,6 +242,10 @@ func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals [ return fmt.Errorf("commit Flow projection: %w", commitErr) } +func projectionDigest(value []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(value)) +} + type projectionLock struct { file *os.File } diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index 456da42..326683b 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -2,6 +2,7 @@ package runtime import ( "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -25,7 +26,7 @@ func TestFlowProjectionRejectsRepositoryParentSymlink(t *testing.T) { 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) + 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) } @@ -59,7 +60,8 @@ func TestFlowProjectionStagesAllOutputsBeforeReplacement(t *testing.T) { {Path: newSkillPath, Content: []byte("new skill"), Mode: 0o644}, {Path: artifactPath, Content: []byte("new artifact"), Mode: 0o644}, } - err := ApplyFlowProjection(repository, writes, []string{retiredPath}) + 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) } @@ -75,7 +77,7 @@ func TestFlowProjectionStagesAllOutputsBeforeReplacement(t *testing.T) { if _, statErr := os.Stat(newSkillPath); !os.IsNotExist(statErr) { t.Fatalf("new skill became visible before artifact staging: %v", statErr) } - if err := ApplyFlowProjection(repository, writes, []string{retiredPath}); err != nil { + 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"} { @@ -97,7 +99,7 @@ func TestFlowProjectionRefusesConcurrentCompiler(t *testing.T) { 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) + 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) } @@ -105,7 +107,122 @@ func TestFlowProjectionRefusesConcurrentCompiler(t *testing.T) { t.Fatalf("refused compiler changed projection: %v", statErr) } lock.release() - if err := ApplyFlowProjection(repository, []ProjectionWrite{{Path: target, Content: []byte("skill"), Mode: 0o644}}, nil); err != nil { + if err := ApplyFlowProjection(repository, []ProjectionWrite{{Path: target, Content: []byte("skill"), Mode: 0o644}}, nil, nil); 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 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 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, 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, 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/docs/control-program-ir.md b/docs/control-program-ir.md index 613e3f4..c4551a6 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -14,20 +14,26 @@ Software terms such as plans, tests, Git, and pull requests belong to ## Compile and check -Install the TypeScript frontend in the repository, then run: +Install the TypeScript frontend in the repository, resolve its absolute path, +then run: ```sh -boatstack flow compile --repo . +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 the committed `.flow.ir.json` artifact and generated skills as one -serialized update. Runtime commands never execute `flow.ts`. +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 diff --git a/docs/generated-files.md b/docs/generated-files.md index 840d822..17739cd 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -31,7 +31,10 @@ committed runtime inputs: 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 while the frontend is running. +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. ## Machine-local controller state From 64bbaf6b6b187bc2911ac41810f0e0f92132901f Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 01:23:26 +0100 Subject: [PATCH 06/14] fix: preserve flow identities and filesystem scope --- .../cmd/boatstack-helper/flow_runtime.go | 12 +- .../cmd/boatstack-helper/flow_runtime_test.go | 11 ++ boatstack/flow/softwaredelivery/definition.go | 3 + .../flow/softwaredelivery/definition_test.go | 18 +++ boatstack/flow/softwaredelivery/skills.go | 9 +- .../flow/softwaredelivery/skills_test.go | 32 +++++ boatstack/flow/standard/completeness_test.go | 21 ++- boatstack/internal/runtime/flow_files.go | 125 +++++++++++++----- boatstack/internal/runtime/flow_files_test.go | 32 +++++ docs/control-program-ir.md | 3 +- docs/generated-files.md | 7 +- 11 files changed, 227 insertions(+), 46 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index e692926..c06e53b 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -191,16 +191,10 @@ func loadFlowDefinition(ctx context.Context, repository, programID string) (soft } func resolvePlanInput(repository string, entry controlprogram.Entry) (string, string, error) { - var input *controlprogram.EntryInput - for index := range entry.Inputs { - if entry.Inputs[index].Resolver == "software-delivery.plan-inbox" { - input = &entry.Inputs[index] - break - } - } - if input == nil { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: entry %q has no trusted plan inbox", entry.ID) + if len(entry.Inputs) != 1 || !entry.Inputs[0].Required || entry.Inputs[0].Resolver != "software-delivery.plan-inbox" { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: entry %q requires exactly one required trusted plan inbox", entry.ID) } + input := &entry.Inputs[0] decoder := json.NewDecoder(bytes.NewReader(input.Config)) decoder.DisallowUnknownFields() var config planInboxConfig diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 06c20f7..052def1 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -110,6 +110,17 @@ func TestFlowEntryRejectsPlanCardinalityBeforeManagedState(t *testing.T) { } } +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 required trusted plan inbox") { + 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"} { diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index b4fbd73..bb41a4f 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -58,6 +58,9 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim 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 { diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 1812d57..55a2eef 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -113,6 +113,24 @@ func TestRepositoryTargetMustBeImpliedByTrustedPostcondition(t *testing.T) { } } +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}) diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index fac23d2..cd8b372 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -11,7 +11,10 @@ import ( func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) { result := map[string][]byte{} for _, entry := range compiled.Document.Entries { - slug := compiled.Document.Program.ID + "-" + entry.ID + 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 { @@ -29,6 +32,10 @@ func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[strin return result, nil } +func flowSkillSlug(programID, entryID string) string { + return programID + "-" + strings.ReplaceAll(entryID, "-", "--") +} + func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, slug, host string) []byte { description := entry.Description if description == "" { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index eccd684..b1ef1dd 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -40,3 +40,35 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } } } + +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) + } + } +} diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 333e49b..d2e5003 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -181,7 +181,9 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { "runtime.ApplyFlowProjection": 1, }, "internal/runtime/flow_files.go": { - "os.MkdirAll": 2, "os.CreateTemp": 1, "os.OpenFile": 1, "os.Remove": 5, "os.Rename": 3, + "os.MkdirAll": 1, "os.OpenFile": 1, + "os.Root.MkdirAll": 1, "os.Root.OpenFile": 1, "os.Root.Remove": 5, "os.Root.Rename": 3, + "os.File.Write": 1, "os.File.Chmod": 1, }, } for relative, wanted := range expected { @@ -213,6 +215,10 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { 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" && fileWriterCallsForInventory(selector.Sel.Name): + observed["os.File."+selector.Sel.Name]++ case strings.HasSuffix(importPath, "/internal/runtime") && selector.Sel.Name == "ApplyFlowProjection": observed["runtime."+selector.Sel.Name]++ } @@ -224,6 +230,19 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { } } +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": diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index c8d41fa..83f3c78 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -3,6 +3,7 @@ package runtime import ( "bytes" "context" + "crypto/rand" "crypto/sha256" "fmt" "os" @@ -61,6 +62,7 @@ type stagedProjection struct { type projectionHooks struct { afterValidation func() + beforeStage func(string) afterRemovals func() } @@ -79,6 +81,11 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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 @@ -110,7 +117,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ if err := validateProjectionPath(repository, write.Path); err != nil { return err } - snapshot, err := snapshotProjectionPath(write.Path) + snapshot, err := snapshotProjectionPath(root, repository, write.Path) if err != nil { return err } @@ -127,7 +134,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ if err := validateProjectionPath(repository, removal.Path); err != nil { return err } - snapshot, err := snapshotProjectionPath(removal.Path) + snapshot, err := snapshotProjectionPath(root, repository, removal.Path) if err != nil { return err } @@ -143,7 +150,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ if err := validateProjectionPath(repository, expectation.Path); err != nil { return err } - snapshot, err := snapshotProjectionPath(expectation.Path) + snapshot, err := snapshotProjectionPath(root, repository, expectation.Path) if err != nil { return err } @@ -158,17 +165,24 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ staged := make([]stagedProjection, 0, len(writes)) defer func() { for _, value := range staged { - _ = os.Remove(value.temporary) + _ = root.Remove(value.temporary) } }() for _, write := range writes { - if err := os.MkdirAll(filepath.Dir(write.Path), 0o755); err != nil { + 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 } - temporary, err := stageProjectionFile(write.Path, write.Content, write.Mode) + if hooks.beforeStage != nil { + hooks.beforeStage(write.Path) + } + temporary, err := stageProjectionFile(root, repository, write.Path, write.Content, write.Mode) if err != nil { return err } @@ -181,14 +195,19 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ if writes[index].PublishLast { continue } - if commitErr = os.Rename(value.temporary, value.target); commitErr != nil { + 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) } if commitErr == nil { for _, removal := range removals { - current, currentErr := snapshotProjectionPath(removal.Path) + current, currentErr := snapshotProjectionPath(root, repository, removal.Path) if currentErr != nil { commitErr = currentErr break @@ -200,7 +219,12 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ commitErr = fmt.Errorf("FLOW_PROJECTION_INPUT_CHANGED: removal target %s changed before removal", removal.Path) break } - if commitErr = os.Remove(removal.Path); commitErr != nil { + 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) @@ -211,7 +235,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ } if commitErr == nil && publishLast == 1 { for _, expectation := range expectations { - current, currentErr := snapshotProjectionPath(expectation.Path) + current, currentErr := snapshotProjectionPath(root, repository, expectation.Path) if currentErr != nil { commitErr = currentErr break @@ -227,7 +251,12 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ if !writes[index].PublishLast { continue } - if commitErr = os.Rename(value.temporary, value.target); commitErr == nil { + 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) } break @@ -236,7 +265,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ if commitErr == nil { return nil } - if rollbackErr := rollbackProjection(changed, snapshots); rollbackErr != nil { + if rollbackErr := rollbackProjection(root, repository, changed, snapshots); rollbackErr != nil { return fmt.Errorf("commit Flow projection: %v; rollback failed: %w", commitErr, rollbackErr) } return fmt.Errorf("commit Flow projection: %w", commitErr) @@ -314,8 +343,20 @@ func validateProjectionPath(repository, path string) error { return nil } -func snapshotProjectionPath(path string) (projectionSnapshot, error) { - info, err := os.Lstat(path) +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 } @@ -325,47 +366,65 @@ func snapshotProjectionPath(path string) (projectionSnapshot, error) { if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return projectionSnapshot{}, fmt.Errorf("managed output is not a regular file") } - content, err := os.ReadFile(path) + 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(path string, content []byte, mode os.FileMode) (string, error) { - temporary, err := os.CreateTemp(filepath.Dir(path), ".boatstack-flow-*") +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 } - temporaryPath := temporary.Name() - if _, err = temporary.Write(content); err == nil { - err = temporary.Chmod(mode) - } - if closeErr := temporary.Close(); err == nil { - err = closeErr - } - if err != nil { - _ = os.Remove(temporaryPath) - 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 temporaryPath, nil + return "", fmt.Errorf("cannot allocate a staged projection file") } -func rollbackProjection(changed []string, snapshots map[string]projectionSnapshot) error { +func rollbackProjection(root *os.Root, repository string, changed []string, snapshots map[string]projectionSnapshot) error { for index := len(changed) - 1; index >= 0; index-- { snapshot := snapshots[changed[index]] + relative, err := projectionRelativePath(repository, snapshot.path) + if err != nil { + return err + } if !snapshot.exists { - if err := os.Remove(snapshot.path); err != nil && !os.IsNotExist(err) { + if err := root.Remove(relative); err != nil && !os.IsNotExist(err) { return err } continue } - temporary, err := stageProjectionFile(snapshot.path, snapshot.content, snapshot.mode) + temporary, err := stageProjectionFile(root, repository, snapshot.path, snapshot.content, snapshot.mode) if err != nil { return err } - if err := os.Rename(temporary, snapshot.path); err != nil { - _ = os.Remove(temporary) + if err := root.Rename(temporary, relative); err != nil { + _ = root.Remove(temporary) return err } } diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index 326683b..d73b4e8 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -35,6 +35,38 @@ func TestFlowProjectionRejectsRepositoryParentSymlink(t *testing.T) { } } +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" { diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index c4551a6..b7ad0a4 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -44,5 +44,6 @@ 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. Durable software state continues to pass through the existing +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 17739cd..1491822 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -27,6 +27,10 @@ committed runtime inputs: | `.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. @@ -34,7 +38,8 @@ 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. +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 From 1c77b8a9bccc2a9c5aa5f125f5fed2fa64118855 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 01:40:26 +0100 Subject: [PATCH 07/14] fix: preserve exact flow context --- .../cmd/boatstack-helper/flow_runtime.go | 52 ++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 136 ++++++++++++++++-- boatstack/cmd/boatstack-helper/main.go | 6 +- boatstack/controlprogram/canonical.go | 6 + boatstack/controlprogram/canonical_test.go | 16 ++- boatstack/flow/softwaredelivery/definition.go | 2 + boatstack/flow/softwaredelivery/skills.go | 8 +- .../flow/softwaredelivery/skills_test.go | 12 ++ .../softwaredelivery/surfaces/protocol.go | 6 +- 9 files changed, 222 insertions(+), 22 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index c06e53b..827b5f1 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -63,6 +63,9 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, 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 @@ -80,6 +83,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and worktree") } options.repository = repository + options.flowProgramFingerprint = compiled.Fingerprint options.runID = runID if options.objectiveKind == "" { options.objectiveKind = string(objective) @@ -97,6 +101,15 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err != nil { return commandOptions{}, err } + for name, expected := range map[string]string{ + "objective_kind": string(objective), + "delivery_id": deliveryID, + "source_path": plan, + } { + 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 { @@ -116,6 +129,13 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, 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 { @@ -137,7 +157,8 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R } bound, err := bindFlowEntry(ctx, commandOptions{ repository: request.Repository, host: request.Host, programID: request.ProgramID, entryID: request.EntryID, - runID: request.FlowID, objectiveID: request.Objective.ID, objectiveKind: string(request.Objective.Kind), deliveryID: request.Objective.DeliveryID, + 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 { @@ -148,6 +169,7 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R 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) @@ -157,13 +179,29 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R } func resolveBoundPlan(repository string, entry controlprogram.Entry, options commandOptions) (string, string, error) { - if options.runID != "" && flowSegment.MatchString(options.deliveryID) { - managed := filepath.Join(repository, ".boatstack", "plans", options.deliveryID+".source") - if info, err := os.Stat(managed); err == nil && info.Mode().IsRegular() { - return managed, options.deliveryID, nil - } + 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") + info, err := os.Lstat(managed) + if err != nil { + return "", "", fmt.Errorf("FLOW_INPUT_REQUIRED: active run plan is unavailable: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: active run plan must be a regular non-symlink file") + } + resolved, err := filepath.EvalSymlinks(managed) + if err != nil { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: resolve active run plan: %w", err) + } + relative, err := filepath.Rel(repository, resolved) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: active run plan escapes the repository") } - return resolvePlanInput(repository, entry) + return resolved, options.deliveryID, nil } func loadFlowDefinition(ctx context.Context, repository, programID string) (softwareflow.Definition, error) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 052def1..b2cf0a9 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "os" @@ -19,11 +20,22 @@ import ( func flowRepository(t *testing.T) string { t.Helper() repository := t.TempDir() + 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 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) } - document := productDeliveryDocument("product-delivery") compiled, err := controlprogram.Compile(document, resolver) if err != nil { t.Fatal(err) @@ -32,11 +44,6 @@ func flowRepository(t *testing.T) string { if err != nil { t.Fatal(err) } - 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) - } for path, content := range skills { writeFixture(t, repository, path, content) } @@ -46,8 +53,7 @@ func flowRepository(t *testing.T) string { if err != nil { t.Fatal(err) } - writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ir.json", artifactRaw) - return repository + writeFixture(t, repository, ".boatstack/flows/"+document.Program.ID+".flow.ir.json", artifactRaw) } func productDeliveryDocument(programID string) controlprogram.Document { @@ -187,6 +193,21 @@ func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { } } +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") @@ -275,6 +296,52 @@ func TestFlowCompileNamesDefaultArtifactFromProgramID(t *testing.T) { } } +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, "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 TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { // control-law: questions-and-restarts-preserve-the-exact-plan-worktree-and-run repository := flowRepository(t) @@ -308,6 +375,59 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } } +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) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index ee9f1fb..6dfa32e 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -44,6 +44,7 @@ type commandOptions struct { objectiveKind string deliveryID string programID string + flowProgramFingerprint string entryID string runID string transitionID string @@ -335,6 +336,9 @@ func standardKernel(ctx context.Context, request surfaces.Request) (boatstack.De 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) @@ -504,7 +508,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, - ProgramID: options.programID, EntryID: options.entryID, 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, diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index 9ff2b88..fd3f9ed 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -462,6 +462,12 @@ func sortPredicates(values []Predicate) { } 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 = "" diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 5d8ddae..fc0c7b5 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -16,26 +16,28 @@ func incidentProgram() controlprogram.Document { 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"}}, + {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"}}, + 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")}}, - Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated", Inputs: []controlprogram.EntryInput{{ID: "incident", Type: "json", Required: true, Resolver: "incident.input", Config: json.RawMessage(`{"b":2,"a":1}`)}}}}, + 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}`)}}}}, } } @@ -65,6 +67,12 @@ func TestDomainNeutralIncidentProgramCompiles(t *testing.T) { 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) { diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index bb41a4f..f7279d5 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -28,6 +28,8 @@ func NewDefinition(compiled controlprogram.Compiled, resolver Resolver) (Definit 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 { diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index cd8b372..4230feb 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -1,6 +1,7 @@ package softwaredelivery import ( + "encoding/hex" "fmt" "path/filepath" "strings" @@ -33,7 +34,12 @@ func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[strin } func flowSkillSlug(programID, entryID string) string { - return programID + "-" + strings.ReplaceAll(entryID, "-", "--") + 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 { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index b1ef1dd..729c5f2 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -71,4 +71,16 @@ func TestGeneratedSkillIdentityIsInjectiveAcrossProgramEntryPairs(t *testing.T) 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/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 6576d71..e10067f 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -46,6 +46,7 @@ type Request struct { 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"` @@ -68,9 +69,12 @@ func (r Request) Validate(now time.Time) error { if (r.ProgramID == "") != (r.EntryID == "") { return fmt.Errorf("surface request requires both program and entry identity") } - if r.ProgramID != "" && (!flowContextIdentity.MatchString(r.ProgramID) || !flowContextIdentity.MatchString(r.EntryID) || r.FlowID == "") { + 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() { From 438e97a51bd465f78ea17a5497f524396b060bc5 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 01:54:57 +0100 Subject: [PATCH 08/14] fix: keep flow projections consumer-ready --- .../cmd/boatstack-helper/flow_command.go | 20 +++- .../cmd/boatstack-helper/flow_runtime.go | 100 ++++++++++-------- .../cmd/boatstack-helper/flow_runtime_test.go | 87 +++++++++++++++ boatstack/internal/runtime/flow_files.go | 48 ++++++++- boatstack/internal/runtime/flow_files_test.go | 24 +++++ 5 files changed, 234 insertions(+), 45 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 288e25e..c43c92c 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -108,6 +108,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } + if err := validateSoftwareFlow(ctx, compiled, resolver); err != nil { + return err + } artifactPath, err := resolveArtifactPath(options.repository, options.artifact, compiled.Document.Program.ID) if err != nil { return err @@ -233,12 +236,27 @@ func checkFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - if _, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, resolver); err != nil { + compiled, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, resolver) + if err != nil { + return err + } + if err := validateSoftwareFlow(ctx, compiled, resolver); err != nil { return err } return renderFlowResult("valid", artifactPath, artifact) } +func validateSoftwareFlow(ctx context.Context, compiled controlprogram.Compiled, resolver softwareflow.Resolver) error { + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err != nil { + return fmt.Errorf("FLOW_RUNTIME_INVALID: %w", err) + } + if _, err := definition.RuntimeManifest(ctx); 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) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 827b5f1..0bd0d1b 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -186,22 +186,38 @@ func resolveBoundPlan(repository string, entry controlprogram.Entry, options com return "", "", fmt.Errorf("FLOW_CONTEXT_MISMATCH: active run requires its delivery identity") } managed := filepath.Join(repository, ".boatstack", "plans", options.deliveryID+".source") - info, err := os.Lstat(managed) + 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 "", "", fmt.Errorf("FLOW_INPUT_REQUIRED: active run plan is unavailable: %w", err) + return "", "", err + } + selected := filepath.Join(inbox, options.deliveryID+".md") + resolved, err := resolveRegularRepositoryFile(repository, selected, "active run inbox plan") + return resolved, options.deliveryID, err +} + +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: active run plan must be a regular non-symlink file") + return "", fmt.Errorf("FLOW_INPUT_INVALID: %s must be a regular non-symlink file", label) } - resolved, err := filepath.EvalSymlinks(managed) + resolved, err := filepath.EvalSymlinks(path) if err != nil { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: resolve active run plan: %w", err) + 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: active run plan escapes the repository") + return "", fmt.Errorf("FLOW_INPUT_INVALID: %s escapes the repository", label) } - return resolved, options.deliveryID, nil + return resolved, nil } func loadFlowDefinition(ctx context.Context, repository, programID string) (softwareflow.Definition, error) { @@ -229,33 +245,9 @@ func loadFlowDefinition(ctx context.Context, repository, programID string) (soft } func resolvePlanInput(repository string, entry controlprogram.Entry) (string, string, error) { - if len(entry.Inputs) != 1 || !entry.Inputs[0].Required || entry.Inputs[0].Resolver != "software-delivery.plan-inbox" { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: entry %q requires exactly one required trusted plan inbox", entry.ID) - } - input := &entry.Inputs[0] - decoder := json.NewDecoder(bytes.NewReader(input.Config)) - decoder.DisallowUnknownFields() - var config planInboxConfig - if err := decoder.Decode(&config); err != nil { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: %w", err) - } - if err := requireJSONEOF(decoder); err != nil || config.Cardinality != "exactly-one" { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: plan inbox requires exact cardinality") - } - inbox, err := exactRepositoryPath(repository, config.Path) + inbox, config, err := resolvePlanInbox(repository, entry) if err != nil { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: %w", err) - } - resolvedInbox, err := filepath.EvalSymlinks(inbox) - if err != nil && !os.IsNotExist(err) { - return "", "", 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 "", "", fmt.Errorf("FLOW_INPUT_INVALID: plan inbox escapes the repository") - } - inbox = resolvedInbox + return "", "", err } entries, err := os.ReadDir(inbox) if err != nil { @@ -287,19 +279,43 @@ func resolvePlanInput(repository string, entry controlprogram.Entry) (string, st } deliveryID := strings.TrimSuffix(candidates[0], filepath.Ext(candidates[0])) selected := filepath.Join(inbox, candidates[0]) - info, err := os.Lstat(selected) - if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: selected plan is not a regular repository file") + resolved, err := resolveRegularRepositoryFile(repository, selected, "selected plan") + if err != nil { + return "", "", err + } + return resolved, deliveryID, nil +} + +func resolvePlanInbox(repository string, entry controlprogram.Entry) (string, planInboxConfig, error) { + if len(entry.Inputs) != 1 || !entry.Inputs[0].Required || entry.Inputs[0].Resolver != "software-delivery.plan-inbox" { + return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: entry %q requires exactly one required trusted plan inbox", entry.ID) + } + input := &entry.Inputs[0] + decoder := json.NewDecoder(bytes.NewReader(input.Config)) + decoder.DisallowUnknownFields() + var config planInboxConfig + if err := decoder.Decode(&config); err != nil { + return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) } - resolved, err := filepath.EvalSymlinks(selected) + if err := requireJSONEOF(decoder); err != nil || config.Cardinality != "exactly-one" { + return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: plan inbox requires exact cardinality") + } + inbox, err := exactRepositoryPath(repository, config.Path) if err != nil { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: resolve selected plan: %w", err) + return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) } - relative, err := filepath.Rel(inbox, resolved) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return "", "", fmt.Errorf("FLOW_INPUT_INVALID: selected plan escapes its inbox") + resolvedInbox, err := filepath.EvalSymlinks(inbox) + if err != nil && !os.IsNotExist(err) { + return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: resolve plan inbox: %w", err) } - return resolved, deliveryID, nil + if err == nil { + relative, relativeErr := filepath.Rel(repository, resolvedInbox) + if relativeErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", planInboxConfig{}, 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) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index b2cf0a9..2a2ef66 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -342,6 +342,74 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { } } +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 TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { // control-law: questions-and-restarts-preserve-the-exact-plan-worktree-and-run repository := flowRepository(t) @@ -353,6 +421,25 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { 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")) writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("approved amendment")) diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 83f3c78..ca7cce0 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -190,6 +190,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ } changed := make([]string, 0, len(writes)+len(removals)) + committed := map[string]projectionSnapshot{} var commitErr error for index, value := range staged { if writes[index].PublishLast { @@ -204,6 +205,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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 { @@ -228,11 +230,41 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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) @@ -258,6 +290,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ } 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 } @@ -265,7 +298,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ if commitErr == nil { return nil } - if rollbackErr := rollbackProjection(root, repository, changed, snapshots); rollbackErr != 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) @@ -406,9 +439,16 @@ func stageProjectionFile(root *os.Root, repository, path string, content []byte, return "", fmt.Errorf("cannot allocate a staged projection file") } -func rollbackProjection(root *os.Root, repository string, changed []string, snapshots map[string]projectionSnapshot) error { +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 @@ -430,3 +470,7 @@ func rollbackProjection(root *os.Root, repository string, changed []string, snap } return nil } + +func sameProjectionState(left, right projectionSnapshot) bool { + return left.exists == right.exists && (!left.exists || (left.mode == right.mode && bytes.Equal(left.content, right.content))) +} diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index d73b4e8..a996830 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -201,6 +201,30 @@ func TestFlowProjectionRevalidatesCompileInputsUnderLock(t *testing.T) { } } +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" { From 95b10229b78da365219e35f4e7f3ab2de874bc53 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 02:04:27 +0100 Subject: [PATCH 09/14] fix: compare projection modes portably --- boatstack/internal/runtime/flow_files.go | 12 +++++++++++- boatstack/internal/runtime/flow_files_test.go | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index ca7cce0..663f7e9 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" ) @@ -472,5 +473,14 @@ func rollbackProjection(root *os.Root, repository string, changed []string, snap } func sameProjectionState(left, right projectionSnapshot) bool { - return left.exists == right.exists && (!left.exists || (left.mode == right.mode && bytes.Equal(left.content, right.content))) + 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 index a996830..3a2c965 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -18,6 +18,23 @@ func resolvedTemporaryRepository(t *testing.T) string { 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) From 79117e2cd9e303c2ea00841dac562ed5aebdc7be Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 02:21:15 +0100 Subject: [PATCH 10/14] fix: bind flow producer boundaries --- .../cmd/boatstack-helper/flow_command.go | 35 +++-- .../cmd/boatstack-helper/flow_runtime.go | 48 ++---- .../cmd/boatstack-helper/flow_runtime_test.go | 140 +++++++++++++++++- boatstack/flow/softwaredelivery/inputs.go | 62 ++++++++ .../flow/softwaredelivery/inputs_test.go | 48 ++++++ boatstack/flow/softwaredelivery/skills.go | 3 +- .../flow/softwaredelivery/skills_test.go | 34 +++++ boatstack/flow/standard/completeness_test.go | 2 +- boatstack/internal/runtime/flow_files.go | 110 ++++++++++++-- boatstack/internal/runtime/flow_files_test.go | 123 ++++++++++++++- 10 files changed, 535 insertions(+), 70 deletions(-) create mode 100644 boatstack/flow/softwaredelivery/inputs.go create mode 100644 boatstack/flow/softwaredelivery/inputs_test.go diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index c43c92c..dede514 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -128,7 +128,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - obsoleteSkills, artifactExpectation, err := obsoleteGeneratedSkills(options.repository, artifactPath, artifact.GeneratedSkills) + obsoleteSkills, artifactExpectation, priorSkills, err := obsoleteGeneratedSkills(options.repository, artifactPath, artifact.GeneratedSkills) if err != nil { return err } @@ -143,9 +143,13 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if pathErr != nil { return pathErr } - writes = append(writes, boatstackruntime.ProjectionWrite{Path: absolute, Content: skills[path], Mode: 0o644}) + 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, PublishLast: true}) + writes = append(writes, boatstackruntime.ProjectionWrite{ + Path: artifactPath, Content: artifactRaw, Mode: 0o644, ExpectedPreviousSHA256: artifactExpectation.ExpectedSHA256, PublishLast: true, + }) expectations := []boatstackruntime.ProjectionExpectation{ {Path: source, Exists: true, ExpectedSHA256: fileDigest(sourceRaw)}, {Path: lockPath, Exists: true, ExpectedSHA256: fileDigest(lockRaw)}, @@ -165,27 +169,27 @@ func requireUnchangedCompileInput(path string, expected []byte) error { return nil } -func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]string) ([]boatstackruntime.ProjectionRemoval, boatstackruntime.ProjectionExpectation, error) { +func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]string) ([]boatstackruntime.ProjectionRemoval, boatstackruntime.ProjectionExpectation, map[string]string, error) { expectation := boatstackruntime.ProjectionExpectation{Path: artifactPath} info, err := os.Lstat(artifactPath) if os.IsNotExist(err) { - return nil, expectation, nil + return nil, expectation, map[string]string{}, nil } if err != nil { - return nil, expectation, err + return nil, expectation, nil, err } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact is not a regular file") + return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact is not a regular file") } raw, err := os.ReadFile(artifactPath) if err != nil { - return nil, expectation, err + return nil, expectation, nil, err } expectation.Exists = true expectation.ExpectedSHA256 = fileDigest(raw) prior, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) if err != nil { - return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact cannot authorize projection replacement: %w", err) + return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact cannot authorize projection replacement: %w", err) } retired := make([]boatstackruntime.ProjectionRemoval, 0) for relative, expected := range prior.GeneratedSkills { @@ -194,7 +198,7 @@ func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]st } path, pathErr := exactRepositoryPath(repository, relative) if pathErr != nil { - return nil, expectation, pathErr + return nil, expectation, nil, pathErr } fileInfo, statErr := os.Lstat(path) if os.IsNotExist(statErr) { @@ -202,16 +206,16 @@ func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]st continue } if statErr != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() { - return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s is not regular", relative) + return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s is not regular", relative) } content, readErr := os.ReadFile(path) if readErr != nil || fileDigest(content) != expected { - return nil, expectation, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s was modified", relative) + return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s was modified", relative) } retired = append(retired, boatstackruntime.ProjectionRemoval{Path: path, ExpectedSHA256: expected, AllowMissing: true}) } sort.Slice(retired, func(i, j int) bool { return retired[i].Path < retired[j].Path }) - return retired, expectation, nil + return retired, expectation, prior.GeneratedSkills, nil } func fileDigest(value []byte) string { @@ -247,6 +251,11 @@ func checkFlow(ctx context.Context, options flowCommandOptions) error { } func validateSoftwareFlow(ctx context.Context, 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) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 0bd0d1b..8722646 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -5,9 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" - "io" "os" "path/filepath" "regexp" @@ -23,11 +21,6 @@ import ( var flowSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) -type planInboxConfig struct { - Path string `json:"path"` - Cardinality string `json:"cardinality"` -} - func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, error) { if options.programID == "" && options.entryID == "" { return options, nil @@ -91,10 +84,11 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if options.deliveryID == "" { options.deliveryID = deliveryID } + expectedObjectiveID := "objective-" + options.programID + "-" + options.entryID + "-" + deliveryID if options.objectiveID == "" { - options.objectiveID = "objective-" + options.programID + "-" + options.entryID + "-" + deliveryID + options.objectiveID = expectedObjectiveID } - if options.objectiveKind != string(objective) || options.deliveryID != deliveryID { + 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) @@ -286,32 +280,23 @@ func resolvePlanInput(repository string, entry controlprogram.Entry) (string, st return resolved, deliveryID, nil } -func resolvePlanInbox(repository string, entry controlprogram.Entry) (string, planInboxConfig, error) { - if len(entry.Inputs) != 1 || !entry.Inputs[0].Required || entry.Inputs[0].Resolver != "software-delivery.plan-inbox" { - return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: entry %q requires exactly one required trusted plan inbox", entry.ID) - } - input := &entry.Inputs[0] - decoder := json.NewDecoder(bytes.NewReader(input.Config)) - decoder.DisallowUnknownFields() - var config planInboxConfig - if err := decoder.Decode(&config); err != nil { - return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) - } - if err := requireJSONEOF(decoder); err != nil || config.Cardinality != "exactly-one" { - return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: plan inbox requires exact cardinality") +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, config.Path) + inbox, err := exactRepositoryPath(repository, filepath.FromSlash(config.Path)) if err != nil { - return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) + return "", softwareflow.PlanInbox{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) } resolvedInbox, err := filepath.EvalSymlinks(inbox) if err != nil && !os.IsNotExist(err) { - return "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: resolve plan inbox: %w", 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 "", planInboxConfig{}, fmt.Errorf("FLOW_INPUT_INVALID: plan inbox escapes the repository") + return "", softwareflow.PlanInbox{}, fmt.Errorf("FLOW_INPUT_INVALID: plan inbox escapes the repository") } inbox = resolvedInbox } @@ -332,14 +317,3 @@ func flowRunID(repository, fingerprint, entry, delivery string) string { digest := sha256.Sum256([]byte(value)) return "run-" + hex.EncodeToString(digest[:16]) } - -func requireJSONEOF(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 -} diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 2a2ef66..babe248 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -122,7 +122,7 @@ func TestFlowEntryRejectsAdditionalRequiredInputs(t *testing.T) { {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 required trusted plan inbox") { + if _, _, err := resolvePlanInput(t.TempDir(), entry); err == nil || !strings.Contains(err.Error(), "exactly one plan input") { t.Fatalf("multiple required inputs result = %v", err) } } @@ -276,6 +276,7 @@ func TestFlowCompileNamesDefaultArtifactFromProgramID(t *testing.T) { 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") @@ -312,6 +313,7 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { 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") @@ -342,6 +344,44 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { } } +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 TestFlowCompileAndCheckRejectRuntimeInvalidSoftwareFlow(t *testing.T) { // control-law: compiled-and-checked-artifacts-are-admissible-by-the-production-adapter if runtime.GOOS == "windows" { @@ -410,6 +450,74 @@ func TestFlowCompileAndCheckRejectRuntimeInvalidSoftwareFlow(t *testing.T) { } } +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) @@ -462,6 +570,27 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } } +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) @@ -555,21 +684,24 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { } artifactPath := filepath.Join(repository, ".boatstack", "flows", "program.flow.ir.json") writeFixture(t, repository, ".boatstack/flows/program.flow.ir.json", raw) - paths, artifactExpectation, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + paths, artifactExpectation, priorSkills, err := obsoleteGeneratedSkills(repository, 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)) || !artifactExpectation.Exists { 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")) - if _, _, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { + if _, _, _, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { t.Fatalf("modified retired projection was not protected: %v", err) } if err := os.Remove(filepath.Join(repository, filepath.FromSlash(retiredPath))); err != nil { t.Fatal(err) } - paths, _, err = obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + paths, _, _, err = obsoleteGeneratedSkills(repository, 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/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 index 4230feb..10f6627 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -47,9 +47,10 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s 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: %s Use only when the user explicitly selects this repository Flow entry. +description: %q --- # %s diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 729c5f2..e4e265c 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -1,6 +1,7 @@ package softwaredelivery_test import ( + "encoding/json" "strings" "testing" @@ -41,6 +42,39 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } } +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"}, diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index d2e5003..96d5191 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -181,7 +181,7 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { "runtime.ApplyFlowProjection": 1, }, "internal/runtime/flow_files.go": { - "os.MkdirAll": 1, "os.OpenFile": 1, + "os.OpenFile": 1, "os.Root.MkdirAll": 1, "os.Root.OpenFile": 1, "os.Root.Remove": 5, "os.Root.Rename": 3, "os.File.Write": 1, "os.File.Chmod": 1, }, diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 663f7e9..1a53ae7 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -31,10 +31,11 @@ func RunFlowFrontend(ctx context.Context, executable, sourceName string, source } type ProjectionWrite struct { - Path string - Content []byte - Mode os.FileMode - PublishLast bool + Path string + Content []byte + Mode os.FileMode + ExpectedPreviousSHA256 string + PublishLast bool } type ProjectionRemoval struct { @@ -122,6 +123,9 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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 the prior artifact", write.Path) + } snapshots[write.Path] = snapshot } if publishLast > 1 { @@ -197,6 +201,20 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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 @@ -284,6 +302,20 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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 @@ -309,25 +341,44 @@ 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) { - cache, err := os.UserCacheDir() + gitDirectory, err := projectionGitDirectory(repository) if err != nil { return nil, err } - root := filepath.Join(cache, "boatstack", "flow-projection-locks") - if err := os.MkdirAll(root, 0o700); 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 } - identity := sha256.Sum256([]byte(repository)) - path := filepath.Join(root, fmt.Sprintf("%x.lock", identity)) - file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + 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) @@ -335,6 +386,43 @@ func acquireProjectionLock(repository string) (*projectionLock, error) { 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() diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index 3a2c965..3615a6a 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -1,6 +1,7 @@ package runtime import ( + "bufio" "os" "os/exec" "path/filepath" @@ -15,6 +16,9 @@ func resolvedTemporaryRepository(t *testing.T) string { if err != nil { t.Fatal(err) } + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o755); err != nil { + t.Fatal(err) + } return repository } @@ -107,7 +111,7 @@ func TestFlowProjectionStagesAllOutputsBeforeReplacement(t *testing.T) { } writes := []ProjectionWrite{ {Path: newSkillPath, Content: []byte("new skill"), Mode: 0o644}, - {Path: artifactPath, Content: []byte("new artifact"), 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) @@ -161,6 +165,55 @@ func TestFlowProjectionRefusesConcurrentCompiler(t *testing.T) { } } +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) @@ -192,6 +245,70 @@ func TestFlowProjectionRevalidatesAuthorizationUnderLock(t *testing.T) { } } +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) @@ -251,7 +368,7 @@ func TestFlowProjectionRecoversAfterCrashBeforeArtifactPublication(t *testing.T) 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, PublishLast: true}, + {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) }}) @@ -286,7 +403,7 @@ func TestFlowProjectionRecoversAfterCrashBeforeArtifactPublication(t *testing.T) writes := []ProjectionWrite{ {Path: newSkillPath, Content: []byte("new skill"), Mode: 0o644}, - {Path: artifactPath, Content: []byte("new artifact"), Mode: 0o644, PublishLast: true}, + {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 { From a4ea8bbf87beb70620854bb801e50a163ecb9924 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 02:36:05 +0100 Subject: [PATCH 11/14] fix: preserve flow plan filename on resume --- .../cmd/boatstack-helper/flow_runtime.go | 28 ++++++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 50 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 8722646..8078180 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -190,11 +190,35 @@ func resolveBoundPlan(repository string, entry controlprogram.Entry, options com if err != nil { return "", "", err } - selected := filepath.Join(inbox, options.deliveryID+".md") - resolved, err := resolveRegularRepositoryFile(repository, selected, "active run inbox plan") + 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 { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index babe248..9e834b4 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -570,6 +570,56 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } } +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) From f39faec6d51085439f23645076e6bc6e124adcdb Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 02:55:31 +0100 Subject: [PATCH 12/14] fix: close flow projection trust boundaries --- .../cmd/boatstack-helper/flow_command.go | 75 +++--- .../cmd/boatstack-helper/flow_runtime_test.go | 173 +++++++++++++- boatstack/cmd/boatstack-helper/main.go | 17 ++ boatstack/flow/standard/completeness_test.go | 14 +- boatstack/internal/runtime/flow_files.go | 32 ++- boatstack/internal/runtime/flow_ownership.go | 217 ++++++++++++++++++ 6 files changed, 472 insertions(+), 56 deletions(-) create mode 100644 boatstack/internal/runtime/flow_ownership.go diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index dede514..e6a15ca 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -13,7 +13,10 @@ import ( "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" ) @@ -108,7 +111,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - if err := validateSoftwareFlow(ctx, compiled, resolver); err != nil { + if err := validateSoftwareFlow(ctx, options.repository, compiled, resolver); err != nil { return err } artifactPath, err := resolveArtifactPath(options.repository, options.artifact, compiled.Document.Program.ID) @@ -128,7 +131,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - obsoleteSkills, artifactExpectation, priorSkills, err := obsoleteGeneratedSkills(options.repository, artifactPath, artifact.GeneratedSkills) + removals, artifactPrevious, priorSkills, ownership, err := ownedProjectionChanges(options.repository, filepath.ToSlash(sourceRelative), artifactPath, artifact.GeneratedSkills) if err != nil { return err } @@ -148,14 +151,15 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { }) } writes = append(writes, boatstackruntime.ProjectionWrite{ - Path: artifactPath, Content: artifactRaw, Mode: 0o644, ExpectedPreviousSHA256: artifactExpectation.ExpectedSHA256, PublishLast: true, + Path: artifactPath, Content: artifactRaw, Mode: 0o644, ExpectedPreviousSHA256: artifactPrevious, PublishLast: true, }) expectations := []boatstackruntime.ProjectionExpectation{ {Path: source, Exists: true, ExpectedSHA256: fileDigest(sourceRaw)}, {Path: lockPath, Exists: true, ExpectedSHA256: fileDigest(lockRaw)}, - artifactExpectation, } - if err := boatstackruntime.ApplyFlowProjection(options.repository, writes, obsoleteSkills, expectations); err != nil { + 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) @@ -169,53 +173,36 @@ func requireUnchangedCompileInput(path string, expected []byte) error { return nil } -func obsoleteGeneratedSkills(repository, artifactPath string, next map[string]string) ([]boatstackruntime.ProjectionRemoval, boatstackruntime.ProjectionExpectation, map[string]string, error) { - expectation := boatstackruntime.ProjectionExpectation{Path: artifactPath} - info, err := os.Lstat(artifactPath) - if os.IsNotExist(err) { - return nil, expectation, map[string]string{}, 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 } - if err != nil { - return nil, expectation, nil, err - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact is not a regular file") - } - raw, err := os.ReadFile(artifactPath) - if err != nil { - return nil, expectation, nil, err - } - expectation.Exists = true - expectation.ExpectedSHA256 = fileDigest(raw) - prior, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) - if err != nil { - return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: existing artifact cannot authorize projection replacement: %w", err) - } - retired := make([]boatstackruntime.ProjectionRemoval, 0) + 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, expectation, nil, pathErr - } - fileInfo, statErr := os.Lstat(path) - if os.IsNotExist(statErr) { - retired = append(retired, boatstackruntime.ProjectionRemoval{Path: path, ExpectedSHA256: expected, AllowMissing: true}) - continue - } - if statErr != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() { - return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s is not regular", relative) - } - content, readErr := os.ReadFile(path) - if readErr != nil || fileDigest(content) != expected { - return nil, expectation, nil, fmt.Errorf("CONTROL_PROGRAM_STALE: retired generated skill %s was modified", relative) + 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, expectation, prior.GeneratedSkills, nil + return retired, artifactPrevious, prior.GeneratedSkills, ownership, nil } func fileDigest(value []byte) string { @@ -244,13 +231,13 @@ func checkFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - if err := validateSoftwareFlow(ctx, compiled, resolver); err != nil { + if err := validateSoftwareFlow(ctx, options.repository, compiled, resolver); err != nil { return err } return renderFlowResult("valid", artifactPath, artifact) } -func validateSoftwareFlow(ctx context.Context, compiled controlprogram.Compiled, resolver softwareflow.Resolver) error { +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) @@ -260,7 +247,7 @@ func validateSoftwareFlow(ctx context.Context, compiled controlprogram.Compiled, if err != nil { return fmt.Errorf("FLOW_RUNTIME_INVALID: %w", err) } - if _, err := definition.RuntimeManifest(ctx); err != nil { + 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 diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 9e834b4..13d9e2d 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -12,6 +12,7 @@ import ( "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" @@ -382,6 +383,148 @@ func TestFlowCompileRefusesUnmanagedGeneratedSkill(t *testing.T) { } } +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" { @@ -714,7 +857,11 @@ func TestFlowEntryRejectsPlanInboxSymlinkEscape(t *testing.T) { func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { // control-law: removing-an-entry-cannot-leave-a-stale-authority-bearing-skill - repository := t.TempDir() + 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" @@ -734,24 +881,38 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { } artifactPath := filepath.Join(repository, ".boatstack", "flows", "program.flow.ir.json") writeFixture(t, repository, ".boatstack/flows/program.flow.ir.json", raw) - paths, artifactExpectation, priorSkills, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + 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)) || !artifactExpectation.Exists { + 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")) - if _, _, _, err := obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}); err == nil || !strings.Contains(err.Error(), "was modified") { - t.Fatalf("modified retired projection was not protected: %v", err) + 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 = obsoleteGeneratedSkills(repository, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + 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 6dfa32e..713dc6b 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -118,6 +118,11 @@ func run(arguments []string) error { 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 @@ -176,6 +181,11 @@ func runRPC() error { 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 @@ -349,6 +359,13 @@ func standardKernel(ctx context.Context, request surfaces.Request) (boatstack.De 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() diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 96d5191..bb46b9a 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -178,12 +178,16 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { root := sourceRoot(t) expected := map[string]map[string]int{ "cmd/boatstack-helper/flow_command.go": { - "runtime.ApplyFlowProjection": 1, + "runtime.ApplyOwnedFlowProjection": 1, }, "internal/runtime/flow_files.go": { - "os.OpenFile": 1, + "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": 1, + "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 { @@ -217,9 +221,9 @@ func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { observed["os."+selector.Sel.Name]++ case owner.Name == "root" && rootWriterCallsForInventory(selector.Sel.Name): observed["os.Root."+selector.Sel.Name]++ - case owner.Name == "temporary" && fileWriterCallsForInventory(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": + case strings.HasSuffix(importPath, "/internal/runtime") && (selector.Sel.Name == "ApplyFlowProjection" || selector.Sel.Name == "ApplyOwnedFlowProjection"): observed["runtime."+selector.Sel.Name]++ } return true diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 1a53ae7..8717de8 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -76,6 +76,10 @@ func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals [ } 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") } @@ -93,6 +97,14 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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...) @@ -124,7 +136,7 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ return err } if !projectionWriteAuthorized(snapshot, write) { - return fmt.Errorf("FLOW_PROJECTION_WRITE_UNAUTHORIZED: %s is not absent, exact crash residue, or authorized by the prior artifact", write.Path) + 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 } @@ -168,10 +180,14 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ } 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) @@ -193,6 +209,12 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ } 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{} @@ -328,6 +350,14 @@ func applyFlowProjection(repository string, writes []ProjectionWrite, removals [ 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 } 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") +} From d13197eb7180198a5d083f2fd0bde4d2ef732d0e Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 03:10:28 +0100 Subject: [PATCH 13/14] fix: bind flow inputs exactly --- .../cmd/boatstack-helper/flow_command.go | 18 +++++ .../cmd/boatstack-helper/flow_runtime.go | 11 ++- .../cmd/boatstack-helper/flow_runtime_test.go | 68 ++++++++++++++++++- boatstack/controlprogram/artifact.go | 2 +- boatstack/controlprogram/canonical.go | 13 +++- boatstack/controlprogram/canonical_test.go | 33 +++++++++ 6 files changed, 139 insertions(+), 6 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index e6a15ca..88d3ddb 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -153,6 +153,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { 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)}, @@ -165,6 +168,21 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { 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) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 8078180..ead3095 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -71,7 +71,12 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err != nil { return commandOptions{}, err } - runID := flowRunID(repository, compiled.Fingerprint, options.entryID, deliveryID) + planRaw, err := os.ReadFile(plan) + if err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", err) + } + planDigest := sha256.Sum256(planRaw) + runID := flowRunID(repository, compiled.Fingerprint, options.entryID, deliveryID, hex.EncodeToString(planDigest[:])) if options.runID != "" && options.runID != runID { return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and worktree") } @@ -336,8 +341,8 @@ func findEntry(entries []controlprogram.Entry, id string) (controlprogram.Entry, return controlprogram.Entry{}, false } -func flowRunID(repository, fingerprint, entry, delivery string) string { - value := strings.Join([]string{repository, fingerprint, entry, delivery}, "\x00") +func flowRunID(repository, fingerprint, entry, delivery, planFingerprint string) string { + value := strings.Join([]string{repository, 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 index 13d9e2d..431974f 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -345,6 +345,51 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { } } +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" { @@ -693,7 +738,6 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("exact plan")) writeFixture(t, repository, ".boatstack/plans/inbox/unrelated.md", []byte("other plan")) - writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("approved amendment")) 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", @@ -713,6 +757,28 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } } +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) diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 912f7a9..97b10c8 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -61,7 +61,7 @@ func NewArtifact(compiled Compiled, input ArtifactInput) (Artifact, []byte, erro } func LoadArtifact(source io.Reader) (Artifact, error) { - raw, err := io.ReadAll(io.LimitReader(source, 32<<20)) + raw, err := readLimited(source, 32<<20, "CONTROL_PROGRAM_ARTIFACT_INVALID: input exceeds 32 MiB") if err != nil { return Artifact{}, err } diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index fd3f9ed..5ffb243 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -21,7 +21,7 @@ type Compiled struct { } func Load(source io.Reader, resolver BindingResolver) (Compiled, error) { - raw, err := io.ReadAll(io.LimitReader(source, 16<<20)) + raw, err := readLimited(source, 16<<20, "CONTROL_PROGRAM_INVALID: input exceeds 16 MiB") if err != nil { return Compiled{}, err } @@ -40,6 +40,17 @@ func Load(source io.Reader, resolver BindingResolver) (Compiled, error) { 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") diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index fc0c7b5..373f623 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -119,6 +119,39 @@ func TestStrictLoaderRejectsUnknownAndDuplicateFields(t *testing.T) { } } +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"} }, From 7e9ca9c2bdce3c58ea0c1d0037d744c9e8d961fa Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 03:31:01 +0100 Subject: [PATCH 14/14] fix: bind flow execution invariants --- .../cmd/boatstack-helper/flow_command.go | 2 +- .../cmd/boatstack-helper/flow_runtime.go | 77 ++++++++++++++++--- .../cmd/boatstack-helper/flow_runtime_test.go | 63 +++++++++++++++ boatstack/controlprogram/artifact.go | 42 +++++++--- boatstack/controlprogram/canonical_test.go | 24 +++++- boatstack/flow/standard/transitions.json | 10 +++ .../softwaredelivery/effects/artifacts.go | 4 + .../effects/plan_fingerprint_test.go | 45 +++++++++++ .../boatstack-v2-transition-catalog.md | 60 +++++++-------- 9 files changed, 273 insertions(+), 54 deletions(-) create mode 100644 boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 88d3ddb..fb63ff0 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -245,7 +245,7 @@ func checkFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - compiled, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, resolver) + compiled, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) if err != nil { return err } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index ead3095..f5bdeff 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -52,7 +52,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err != nil { return commandOptions{}, err } - compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver) + compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) if err != nil { return commandOptions{}, err } @@ -76,9 +76,14 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", err) } planDigest := sha256.Sum256(planRaw) - runID := flowRunID(repository, compiled.Fingerprint, options.entryID, deliveryID, hex.EncodeToString(planDigest[:])) + 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 worktree") + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") } options.repository = repository options.flowProgramFingerprint = compiled.Fingerprint @@ -101,9 +106,10 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return commandOptions{}, err } for name, expected := range map[string]string{ - "objective_kind": string(objective), - "delivery_id": deliveryID, - "source_path": plan, + "objective_kind": string(objective), + "delivery_id": deliveryID, + "source_path": plan, + "source_fingerprint": planFingerprint, } { if err := validateResolvedParameter(parameters, name, expected); err != nil { return commandOptions{}, err @@ -124,6 +130,9 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, 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 } @@ -260,7 +269,7 @@ func loadFlowDefinition(ctx context.Context, repository, programID string) (soft if err != nil { return softwareflow.Definition{}, err } - compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver) + compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) if err != nil { return softwareflow.Definition{}, err } @@ -341,8 +350,58 @@ func findEntry(entries []controlprogram.Entry, id string) (controlprogram.Entry, return controlprogram.Entry{}, false } -func flowRunID(repository, fingerprint, entry, delivery, planFingerprint string) string { - value := strings.Join([]string{repository, fingerprint, entry, delivery, planFingerprint}, "\x00") +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 index 431974f..7078927 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -21,6 +21,9 @@ import ( 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") @@ -31,6 +34,26 @@ func flowRepository(t *testing.T) string { 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()) @@ -164,6 +187,46 @@ func TestRPCFlowEntryPreservesObjectiveEvidenceAndStopContext(t *testing.T) { } } +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"} { diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 97b10c8..1a6e13d 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -36,6 +36,11 @@ type ArtifactInput struct { 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") @@ -102,7 +107,7 @@ func safeGeneratedSkillPath(value string) bool { 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) (Compiled, error) { +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") } @@ -124,17 +129,6 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: %s does not match artifact", check.label) } } - paths := make([]string, 0, len(artifact.GeneratedSkills)) - for path := range artifact.GeneratedSkills { - paths = append(paths, path) - } - sort.Strings(paths) - for _, path := range paths { - raw, readErr := readRepositoryFile(repository, path) - if readErr != nil || digest(raw) != artifact.GeneratedSkills[path] { - return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill %s does not match artifact", path) - } - } compiled, err := Compile(artifact.Program, resolver) if err != nil { return Compiled{}, err @@ -142,6 +136,30 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, 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 } diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 373f623..273a17c 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -194,13 +194,33 @@ func TestArtifactBindsSourceLockSkillsAndCompiler(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil); err != nil { + 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); err == nil || !strings.Contains(err.Error(), "source") { + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, generate); err == nil || !strings.Contains(err.Error(), "source") { t.Fatalf("stale source result = %v", err) } } 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/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/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/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`.