From 150a855abd5310e17603bc72fa219e9fd273de68 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 15:05:18 +0100 Subject: [PATCH 1/7] Add governed foreground work to repository flows --- .github/tests/test_detached_supervision.py | 13 +- .../boatstack-helper/delegation_command.go | 28 +- .../cmd/boatstack-helper/flow_command.go | 46 +- .../cmd/boatstack-helper/flow_runtime.go | 212 ++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 157 +++++ boatstack/cmd/boatstack-helper/main.go | 51 +- boatstack/cmd/boatstack-helper/main_test.go | 42 ++ .../cmd/boatstack-helper/work_command.go | 64 +++ boatstack/controlprogram/artifact.go | 57 +- boatstack/controlprogram/canonical.go | 157 ++++- boatstack/controlprogram/canonical_test.go | 100 ++++ .../frontend_conformance_test.go | 40 +- boatstack/controlprogram/ir.go | 59 +- boatstack/core/system_test.go | 19 + boatstack/core/transitions.json | 3 +- boatstack/delivery/control.go | 3 + boatstack/delivery_controller.go | 66 ++- boatstack/flow/softwaredelivery/bindings.go | 7 + boatstack/flow/softwaredelivery/definition.go | 46 ++ .../flow/softwaredelivery/planning_package.go | 114 ++++ boatstack/flow/softwaredelivery/skills.go | 40 +- .../flow/softwaredelivery/skills_test.go | 26 +- boatstack/internal/runtime/flow_files.go | 31 + boatstack/internal/runtime/flow_files_test.go | 34 ++ .../catalog/native_state_handler.go | 25 +- .../softwaredelivery/catalog/transition.go | 84 +++ .../softwaredelivery/durable/state.go | 109 ++-- .../softwaredelivery/durable/state_facet.go | 2 +- .../softwaredelivery/effects/artifacts.go | 206 ++++++- .../effects/command_boundary.go | 99 +++- .../effects/command_boundary_test.go | 107 +++- .../effects/integration_test.go | 10 +- .../effects/planning_package_test.go | 124 ++++ .../softwaredelivery/effects/receipts.go | 52 +- .../softwaredelivery/effects/receipts_test.go | 39 ++ .../softwaredelivery/effects/runtime_store.go | 51 ++ .../effects/runtime_store_test.go | 29 + .../softwaredelivery/effects/state_reducer.go | 23 +- .../softwaredelivery/engine/engine.go | 36 +- .../softwaredelivery/engine/engine_test.go | 92 +++ .../foregroundwork/manager.go | 534 ++++++++++++++++++ .../foregroundwork/manager_test.go | 233 ++++++++ .../internal/softwaredelivery/model/state.go | 33 +- .../softwaredelivery/plant/observer.go | 151 ++++- .../softwaredelivery/plant/observer_test.go | 79 +++ .../internal/softwaredelivery/ports/ports.go | 8 + .../softwaredelivery/protocol/admission.go | 43 +- .../softwaredelivery/protocol/authority.go | 13 + .../protocol/authority_test.go | 41 ++ .../softwaredelivery/protocol/prescription.go | 26 +- .../softwaredelivery/protocol/receipt.go | 9 +- .../softwaredelivery/protocol/work.go | 116 ++++ .../softwaredelivery/surfaces/protocol.go | 59 +- .../surfaces/protocol_test.go | 50 ++ .../softwaredelivery/surfaces/render.go | 3 + .../softwaredelivery/surfaces/render_test.go | 3 +- .../control-programs/assets/diagnose.md | 4 + .../assets/diagnosis.schema.json | 10 + .../assets/planning-list.schema.json | 12 + .../assets/planning-package.md | 8 + .../incident-response-work.flow.ts | 82 +++ .../incident-response.raw.json | 2 +- .../product-delivery-planning-package.flow.ts | 111 ++++ .../boatstack-transition-catalog.md | 66 +-- docs/control-program-ir.md | 46 +- docs/getting-started.md | 16 +- .../authority-and-delegation.md | 8 + docs/product-delivery/writing-a-flow.md | 52 +- .../boatstack-software-delivery/src/index.ts | 20 + packages/boatstack/src/index.ts | 82 ++- release-notes/2026-08-15-foreground-work.md | 17 + 71 files changed, 4190 insertions(+), 250 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/work_command.go create mode 100644 boatstack/flow/softwaredelivery/planning_package.go create mode 100644 boatstack/internal/softwaredelivery/effects/planning_package_test.go create mode 100644 boatstack/internal/softwaredelivery/effects/runtime_store.go create mode 100644 boatstack/internal/softwaredelivery/effects/runtime_store_test.go create mode 100644 boatstack/internal/softwaredelivery/foregroundwork/manager.go create mode 100644 boatstack/internal/softwaredelivery/foregroundwork/manager_test.go create mode 100644 boatstack/internal/softwaredelivery/protocol/authority_test.go create mode 100644 boatstack/internal/softwaredelivery/protocol/work.go create mode 100644 boatstack/testdata/control-programs/assets/diagnose.md create mode 100644 boatstack/testdata/control-programs/assets/diagnosis.schema.json create mode 100644 boatstack/testdata/control-programs/assets/planning-list.schema.json create mode 100644 boatstack/testdata/control-programs/assets/planning-package.md create mode 100644 boatstack/testdata/control-programs/incident-response-work.flow.ts create mode 100644 boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts create mode 100644 release-notes/2026-08-15-foreground-work.md diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index b21fc30..97a4356 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -435,7 +435,7 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali self.assertEqual(bound["decision"]["kind"], "PRESCRIBED") self.assertEqual(bound["decision"]["transition"]["id"], "plan.create") - def test_repository_authority_rematerialization_fails_closed_without_verified_config(self) -> None: + def test_repository_authority_rematerialization_waits_for_verified_config(self) -> None: # control-law: repository-authority-requires-exact-verified-fingerprint root = Path(self.work.name) / "unverified" root.mkdir() @@ -446,19 +446,18 @@ def test_repository_authority_rematerialization_fails_closed_without_verified_co self._git(root, "add", "README.md") self._git(root, "commit", "-m", "fixture") before = self.porcelain(root) - result = self.run_helper( + result = self.helper_json( "next", "--repo", root, "--objective-id", "unverified-authority", "--target-id", "open-or-updated-pr", "--delivery", "unverified-authority", "--run-id", "flow-unverified-authority", "--human", "contract", "--repository-authority", - cwd=root, expected=1, - ) - self.assertIn( - "repository authority requires current verified configuration evidence", - result.stderr, + cwd=root, ) + self.assertEqual(result["decision"]["kind"], "CANDIDATE") + self.assertEqual(result["decision"]["transition"]["id"], "installation.initialize") + self.assertNotIn('"repository-policy"', json.dumps(result)) self.assertEqual(self.porcelain(root), before) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 01a1fb6..aec6bdd 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -19,6 +19,10 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) +var resolveGitHubProviderAuthority = func(ctx context.Context, repository, previewFingerprint string, now time.Time) (protocol.AuthorityReceipt, error) { + return effects.NewNativeBoundary().ResolveGitHubProviderAuthority(ctx, repository, previewFingerprint, now) +} + func runFlowAuthorize(arguments []string) error { flags := flag.NewFlagSet("flow authorize", flag.ContinueOnError) flags.SetOutput(os.Stderr) @@ -258,7 +262,13 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return resolved, err } - rebound, changed, rebindErr := bindContinuationCandidate(ctx, bound, resolved) + rebound, changed, rebindErr := bindTrustedProviderCandidate(ctx, bound, resolved) + if rebindErr != nil { + return surfaces.Response{}, rebindErr + } + if !changed { + rebound, changed, rebindErr = bindContinuationCandidate(ctx, bound, resolved) + } if rebindErr != nil { return surfaces.Response{}, rebindErr } @@ -335,6 +345,21 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return applied, nil } +func bindTrustedProviderCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) { + if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || + (response.Decision.Kind != supervisor.DecisionFrontier && response.Decision.Kind != supervisor.DecisionCandidate) || + len(response.Decision.Candidates) != 1 || response.Decision.Candidates[0] != "publication.execute" { + return bound, false, nil + } + rebound := bound + rebound.transitionID = "publication.execute" + rebound, err := bindFlowEntry(ctx, rebound) + if err != nil { + return commandOptions{}, false, err + } + return rebound, true, nil +} + func bindContinuationCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) { if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || response.Decision.Kind != supervisor.DecisionCandidate || response.Decision.Transition == nil || len(response.Decision.Candidates) != 1 { return bound, false, nil @@ -381,5 +406,6 @@ func advanceContinuation(options *commandOptions, response surfaces.Response) er options.requiredCapabilities = nil options.effectiveCapabilities = nil options.idempotencyKey = "" + options.trustedAuthorityReceipts = nil return nil } diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 6f2ddeb..e3ab874 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -21,7 +21,7 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" ) -const flowCompilerVersion = "control-program.compiler.1" +const flowCompilerVersion = "control-program.compiler.2" type flowCommandOptions struct { repository string @@ -33,7 +33,7 @@ type flowCommandOptions struct { func runFlowCommand(arguments []string) error { if len(arguments) == 0 { - return fmt.Errorf("usage: boatstack flow [flags]") + return fmt.Errorf("usage: boatstack flow [flags]") } action := arguments[0] if action == "authorize" { @@ -45,6 +45,9 @@ func runFlowCommand(arguments []string) error { if action == "run" { return runFlowContinuation(arguments[1:]) } + if action == "work" { + return runFlowWork(arguments[1:]) + } flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError) flags.SetOutput(os.Stderr) options := flowCommandOptions{} @@ -116,7 +119,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - compiled, err := controlprogram.Load(bytes.NewReader(rawIR), resolver) + compiled, err := controlprogram.LoadWithAssets(bytes.NewReader(rawIR), resolver, controlprogram.RepositoryAssetResolver{Repository: options.repository}) if err != nil { return err } @@ -162,13 +165,29 @@ 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)}, } + compileInputs := []string{source, lockPath} + assetPaths := make([]string, 0, len(artifact.Assets)) + for relative := range artifact.Assets { + assetPaths = append(assetPaths, relative) + } + sort.Strings(assetPaths) + for _, relative := range assetPaths { + absolute, pathErr := exactRepositoryPath(options.repository, relative) + if pathErr != nil { + return pathErr + } + compileInputs = append(compileInputs, absolute) + expectations = append(expectations, boatstackruntime.ProjectionExpectation{ + Path: absolute, Exists: true, ExpectedSHA256: artifact.Assets[relative], + }) + } + if err := rejectProjectionInputOverlap(compileInputs, writes, removals); err != nil { + return err + } 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 { @@ -177,16 +196,19 @@ 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) +func rejectProjectionInputOverlap(inputs []string, writes []boatstackruntime.ProjectionWrite, removals []boatstackruntime.ProjectionRemoval) error { + bound := make(map[string]bool, len(inputs)) + for _, input := range inputs { + bound[filepath.Clean(input)] = true + } for _, write := range writes { - if filepath.Clean(write.Path) == lockPath { - return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a projection output") + if bound[filepath.Clean(write.Path)] { + return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: compile input %s is a projection output", write.Path) } } for _, removal := range removals { - if filepath.Clean(removal.Path) == lockPath { - return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a retired projection output") + if bound[filepath.Clean(removal.Path)] { + return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: compile input %s is a retired projection output", removal.Path) } } return nil diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index bbc9f7e..ca00d11 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -14,6 +14,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/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" @@ -80,6 +81,12 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err != nil { return commandOptions{}, err } + options.workInputs = map[string]string{} + for _, input := range entry.Inputs { + if plan != "" { + options.workInputs[input.ID] = plan + } + } planFingerprint := "" if plan != "" { planRaw, readErr := os.ReadFile(plan) @@ -88,15 +95,10 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } planDigest := sha256.Sum256(planRaw) planFingerprint = hex.EncodeToString(planDigest[:]) - repositoryIdentity, identityErr := flowRepositoryIdentity(repository) - if identityErr != nil { - return commandOptions{}, identityErr - } - runID := flowRunID(repositoryIdentity, compiled.Fingerprint, options.entryID, deliveryID, planFingerprint) - if options.runID != "" && options.runID != runID { - return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + options, err = bindSelectedPlanRun(options, repository, compiled.Fingerprint, deliveryID, planFingerprint) + if err != nil { + return commandOptions{}, err } - options.runID = runID } else if options.runID == "" { return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active abandonment has no committed run identity") } @@ -152,7 +154,8 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } if record, loadErr := delegation.Load(recordPath); loadErr == nil { bound := record.Request - if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { + inputDrift := !options.activeFlowBound && strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") + if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request") } delegationRequest = bound @@ -184,6 +187,17 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } } switch options.transitionID { + case "installation.initialize": + configPath := filepath.Join(repository, ".boatstack", "project.json") + if err := bindResolvedParameter(&options, parameters, "config_path", configPath); err != nil { + return commandOptions{}, err + } + if err := populateProjectConfigFingerprint(&options); err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind verified project configuration: %w", err) + } + if err := populateRuntimeParameters(&options); err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind exact runtime identity: %w", err) + } case "objective.bind": if err := bindResolvedParameter(&options, parameters, "target_id", string(objective.TargetID)); err != nil { return commandOptions{}, err @@ -191,7 +205,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { return commandOptions{}, err } - case "plan.create", "plan.amend": + case "plan.create", "plan.amend", softwareflow.PlanningPackageAdmit: if err := bindResolvedParameter(&options, parameters, "source_path", plan); err != nil { return commandOptions{}, err } @@ -201,14 +215,145 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err := bindResolvedParameter(&options, parameters, "source_fingerprint", planFingerprint); err != nil { return commandOptions{}, err } + case softwareflow.PlanningPackageApprove: + fingerprint, fingerprintErr := softwareflow.PlanningPackageFingerprint(repository, deliveryID) + if fingerprintErr != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read planning package fingerprint: %w", fingerprintErr) + } + if err := bindResolvedParameter(&options, parameters, "package_fingerprint", fingerprint); err != nil { + return commandOptions{}, err + } + case "publication.preview": + if err := bindPublicationPreviewParameters(ctx, repository, deliveryID, options.host, &options, parameters); err != nil { + return commandOptions{}, err + } + case "workspace.cut": + baseRef, exists := parameters.Get("base_ref") + if !exists { + return commandOptions{}, fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace.cut requires an exact base_ref") + } + if err := verifyWorkspaceControlBundleAtRevision(ctx, repository, artifactPath, artifact, baseRef); err != nil { + return commandOptions{}, err + } } return options, nil } -func bindActiveFlowContext(ctx context.Context, repository string, options commandOptions, entryObjective softwareflow.EntryObjective) (commandOptions, error) { - if options.runID != "" && entryObjective.TrustedClass != model.ObjectiveAbandoned { +func bindPublicationPreviewParameters(ctx context.Context, repository, deliveryID, host string, options *commandOptions, parameters protocol.Parameters) error { + canonicalRepository, err := filepath.EvalSymlinks(repository) + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: resolve publication repository: %w", err) + } + repository = canonicalRepository + configRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: read publication configuration: %w", err) + } + config, err := protocol.DecodeProjectConfig(configRaw) + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: decode publication configuration: %w", err) + } + resolver, err := plant.NewResolver("") + if err != nil { + return err + } + if host == "" { + host = "cli" + } + invocation, err := resolver.ResolveInvocation(ctx, repository, host, "flow-publication-preview") + if err != nil { + return err + } + if !strings.HasPrefix(invocation.Ref, "refs/heads/") { + return fmt.Errorf("FLOW_INPUT_REQUIRED: publication requires an attached branch") + } + bodyPath, err := resolveRegularRepositoryFile(repository, filepath.Join(repository, ".boatstack", "evidence", deliveryID+"-pr-body.md"), "publication body") + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: bind publication body: %w", err) + } + for name, value := range map[string]string{ + "base_ref": config.Project.DefaultBranch, + "head_ref": strings.TrimPrefix(invocation.Ref, "refs/heads/"), + "body_path": bodyPath, + } { + if err := bindResolvedParameter(options, parameters, name, value); err != nil { + return err + } + } + return nil +} + +func verifyWorkspaceControlBundleAtRevision(ctx context.Context, repository, artifactPath string, artifact controlprogram.Artifact, revision string) error { + if err := protocol.ValidateGitReference(revision); err != nil { + return fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: invalid workspace base_ref: %w", err) + } + artifactRelative, err := filepath.Rel(repository, artifactPath) + if err != nil { + return fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: resolve compiled artifact path: %w", err) + } + paths := map[string]struct{}{ + filepath.ToSlash(artifactRelative): {}, + artifact.SourcePath: {}, + artifact.DependencyLockPath: {}, + } + for path := range artifact.Assets { + paths[path] = struct{}{} + } + for path := range artifact.GeneratedSkills { + paths[path] = struct{}{} + } + ordered := make([]string, 0, len(paths)) + for path := range paths { + ordered = append(ordered, path) + } + sort.Strings(ordered) + if err := boatstackruntime.VerifyFlowProjectionAtRevision(ctx, repository, revision, ordered); err != nil { + return fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace base %q does not contain the active control bundle; commit or regenerate the Flow bundle before workspace.cut: %w", revision, err) + } + return nil +} + +func populateProjectConfigFingerprint(options *commandOptions) error { + parameters, err := parseParameters(options.parameters) + if err != nil { + return err + } + if _, exists := parameters.Get("config_sha256"); exists { + return nil + } + path, exists := parameters.Get("config_path") + if !exists { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + _, fingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + return err + } + options.parameters = append(options.parameters, "config_sha256="+fingerprint) + return nil +} + +func bindSelectedPlanRun(options commandOptions, repository, programFingerprint, deliveryID, planFingerprint string) (commandOptions, error) { + if options.activeFlowBound { return options, nil } + repositoryIdentity, err := flowRepositoryIdentity(repository) + if err != nil { + return commandOptions{}, err + } + runID := flowRunID(repositoryIdentity, programFingerprint, options.entryID, deliveryID, planFingerprint) + if options.runID != "" && options.runID != runID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + } + options.runID = runID + return options, nil +} + +func bindActiveFlowContext(ctx context.Context, repository string, options commandOptions, entryObjective softwareflow.EntryObjective) (commandOptions, error) { resolver, err := plant.NewResolver("") if err != nil { return commandOptions{}, err @@ -258,10 +403,11 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active objective has no committed run identity") } if active.TargetID == entryObjective.TargetID && strings.HasPrefix(active.ID, prefix) { - options.runID, options.deliveryID = receipt.FlowID, active.DeliveryID - options.objectiveID, options.targetID, options.trustedObjectiveClass = active.ID, string(active.TargetID), string(active.TrustedObjectiveClass()) - options.activeFlowBound = true - return options, nil + bound, bindErr := bindCommittedActiveRun(options, active, receipt) + if bindErr != nil { + return commandOptions{}, bindErr + } + return bindStateOwnedTransitionParameters(bound, state) } if entryObjective.TrustedClass == model.ObjectiveAbandoned { repositoryIdentity, identityErr := flowRepositoryIdentity(repository) @@ -279,6 +425,39 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_CONFLICT: delivery %q is active under objective %q; abandon it before selecting another inbox plan", active.DeliveryID, active.ID) } +func bindStateOwnedTransitionParameters(options commandOptions, state durable.State) (commandOptions, error) { + parameters, err := parseParameters(options.parameters) + if err != nil { + return commandOptions{}, err + } + var name, value string + switch options.transitionID { + case "workspace.activate", "workspace.sync", "workspace.publish": + name, value = "branch", state.WorkspaceBranch + case "publication.execute": + name, value = "preview_fingerprint", state.PreviewFingerprint + case "publication.observe": + name, value = "publication_id", state.PublicationID + } + if name == "" || value == "" { + return options, nil + } + if err := bindResolvedParameter(&options, parameters, name, value); err != nil { + return commandOptions{}, err + } + return options, nil +} + +func bindCommittedActiveRun(options commandOptions, active model.Objective, receipt protocol.TransitionReceipt) (commandOptions, error) { + if options.runID != "" && options.runID != receipt.FlowID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the committed active delivery") + } + options.runID, options.deliveryID = receipt.FlowID, active.DeliveryID + options.objectiveID, options.targetID, options.trustedObjectiveClass = active.ID, string(active.TargetID), string(active.TrustedObjectiveClass()) + options.activeFlowBound = true + 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) @@ -329,6 +508,7 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R request.DelegationBindingFingerprint = bound.delegationBindingFingerprint request.DelegationRequestFingerprint = bound.delegationRequestFingerprint request.DelegatedAuthorities = delegationClasses(bound.delegationAuthorities) + request.WorkInputs = bound.workInputs return request, nil } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 85c2f4d..63b0bd8 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -19,6 +19,7 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" @@ -398,6 +399,49 @@ func TestFlowRunIdentitySurvivesWorkspaceTransfer(t *testing.T) { } } +func TestWorkspaceCutRejectsControlBundleThatIsNotInBaseRevision(t *testing.T) { + // control-law: workspace-cut-preserves-the-exact-active-control-bundle + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + raw, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatal(err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if err := verifyWorkspaceControlBundleAtRevision(context.Background(), repository, artifactPath, artifact, "HEAD"); err != nil { + t.Fatalf("committed control bundle rejected: %v", err) + } + + var skillPath string + for path := range artifact.GeneratedSkills { + skillPath = path + break + } + if skillPath == "" { + t.Fatal("fixture generated no skills") + } + if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(skillPath)), []byte("regenerated but uncommitted\n"), 0o600); err != nil { + t.Fatal(err) + } + err = verifyWorkspaceControlBundleAtRevision(context.Background(), repository, artifactPath, artifact, "HEAD") + if err == nil || !strings.Contains(err.Error(), "WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED") || !strings.Contains(err.Error(), skillPath) { + t.Fatalf("uncommitted generated skill result = %v", err) + } +} + func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { // control-law: entry-resolved-inputs-cannot-be-replaced-by-callers for _, surface := range []string{"cli", "rpc"} { @@ -1173,6 +1217,34 @@ func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing } } + projectConfig := []byte(`{"schema_version":2,"project":{"name":"fresh-flow","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex"]}`) + writeFixture(t, repository, ".boatstack/project.json", projectConfig) + installationInitialize := catalog.Transition{ID: "installation.initialize", Parameters: []catalog.ParameterSpec{ + {Name: "config_path", Required: true}, {Name: "config_sha256", Required: true}, {Name: "runtime_version", Required: true}, {Name: "runtime_sha256", Required: true}, {Name: "source_revision", Required: true}, + }} + installationBound, installationChanged, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{ + Kind: supervisor.DecisionCandidate, Transition: &installationInitialize, Candidates: []catalog.TransitionID{"installation.initialize"}, + }}) + if err != nil || !installationChanged || installationBound.transitionID != "installation.initialize" { + t.Fatalf("installation candidate rebind = options=%#v changed=%t err=%v", installationBound, installationChanged, err) + } + installationParameters, err := parseParameters(installationBound.parameters) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"config_path", "config_sha256", "runtime_version", "runtime_sha256", "source_revision"} { + if value, ok := installationParameters.Get(name); !ok || value == "" { + t.Fatalf("installation parameter %q = %q, %t", name, value, ok) + } + } + _, expectedConfigFingerprint, err := protocol.ProjectConfigFingerprint(projectConfig) + if err != nil { + t.Fatal(err) + } + if actual, _ := installationParameters.Get("config_sha256"); actual != expectedConfigFingerprint { + t.Fatalf("installation config fingerprint = %q, want semantic fingerprint %q", actual, expectedConfigFingerprint) + } + for name, decision := range map[string]supervisor.Decision{ "ambiguous": { Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, @@ -1211,6 +1283,91 @@ func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing } } +func TestPublicationPreviewParametersAreRepositoryResolved(t *testing.T) { + repository := t.TempDir() + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "checkout", "-q", "-b", "feature/publication") + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli"]}`)) + bodyPath := filepath.Join(repository, ".boatstack", "evidence", "delivery-pr-body.md") + writeFixture(t, repository, ".boatstack/evidence/delivery-pr-body.md", []byte("# Pull request\n")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + options := commandOptions{repository: repository, transitionID: "publication.preview", host: "cli"} + if err := bindPublicationPreviewParameters(context.Background(), repository, "delivery", "cli", &options, nil); err != nil { + t.Fatal(err) + } + parameters, err := parseParameters(options.parameters) + if err != nil { + t.Fatal(err) + } + canonicalBodyPath, err := filepath.EvalSymlinks(bodyPath) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"base_ref": "main", "head_ref": "feature/publication", "body_path": canonicalBodyPath} { + if got, ok := parameters.Get(name); !ok || got != want { + t.Fatalf("publication parameter %s = %q, present=%t, want %q", name, got, ok, want) + } + } +} + +func TestStateOwnedTransitionParametersDoNotRequireHumanAnswers(t *testing.T) { + state := durable.State{ + WorkspaceBranch: "feat/exact-branch", + PreviewFingerprint: strings.Repeat("a", 64), + PublicationID: "123", + } + for transition, expected := range map[string]string{ + "workspace.activate": "branch=feat/exact-branch", + "workspace.sync": "branch=feat/exact-branch", + "workspace.publish": "branch=feat/exact-branch", + "publication.execute": "preview_fingerprint=" + strings.Repeat("a", 64), + "publication.observe": "publication_id=123", + } { + t.Run(transition, func(t *testing.T) { + bound, err := bindStateOwnedTransitionParameters(commandOptions{transitionID: transition}, state) + if err != nil || len(bound.parameters) != 1 || bound.parameters[0] != expected { + t.Fatalf("state-owned binding = %#v, %v", bound.parameters, err) + } + }) + } + + _, err := bindStateOwnedTransitionParameters(commandOptions{ + transitionID: "workspace.activate", parameters: []string{"branch=feat/other"}, + }, state) + if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + t.Fatalf("caller override was not rejected: %v", err) + } +} + +func TestCommittedActiveRunRehydratesExactDeliveryWhenRunIDIsSupplied(t *testing.T) { + // control-law: a resumed run resolves inputs from its committed delivery before selecting work + active := model.Objective{ + ID: "objective-product-delivery-run-delivery-one", TargetID: "published-pr", + TrustedClass: model.ObjectiveOpenPR, DeliveryID: "delivery-one", + } + receipt := protocol.TransitionReceipt{FlowID: "run-committed"} + bound, err := bindCommittedActiveRun(commandOptions{runID: "run-committed"}, active, receipt) + if err != nil { + t.Fatal(err) + } + if !bound.activeFlowBound || bound.deliveryID != "delivery-one" || bound.objectiveID != active.ID || bound.targetID != "published-pr" || bound.trustedObjectiveClass != string(model.ObjectiveOpenPR) { + t.Fatalf("active run was not rehydrated: %#v", bound) + } + if _, err := bindCommittedActiveRun(commandOptions{runID: "run-other"}, active, receipt); err == nil || !strings.Contains(err.Error(), "FLOW_RUN_MISMATCH") { + t.Fatalf("conflicting run identity result = %v", err) + } +} + +func TestCommittedActiveRunIdentitySurvivesApprovedPlanTransformation(t *testing.T) { + // control-law: trusted plan transformation cannot rename the already-committed run + active := commandOptions{entryID: "run", runID: "run-committed", activeFlowBound: true} + bound, err := bindSelectedPlanRun(active, filepath.Join(t.TempDir(), "not-a-repository"), strings.Repeat("a", 64), "delivery", strings.Repeat("b", 64)) + if err != nil || bound.runID != "run-committed" { + t.Fatalf("active run transformation result = %#v err=%v", bound, err) + } +} + func TestRepositoryNamedAbandonmentEntryUsesCompiledObjective(t *testing.T) { entry := controlprogram.Entry{ID: "cancel", Target: "safely-abandoned"} plan, delivery, err := resolveBoundPlan(t.TempDir(), entry, softwareflow.EntryObjective{ diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 798acf8..ed89e7e 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -68,6 +68,7 @@ type commandOptions struct { acceptProgramChange bool parameters stringList authorityReceipts stringList + trustedAuthorityReceipts []protocol.AuthorityReceipt follow bool host string command string @@ -76,6 +77,14 @@ type commandOptions struct { delegationAuthorities stringList delegationDescription string delegationRequest delegation.Request + workInputs map[string]string + workID string + workQuestionPrompt string + workQuestionSchemaPath string + workQuestionID string + workAnswerPath string + workBlockReason string + workResultFingerprint string } func main() { @@ -353,6 +362,13 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.BoolVar(&options.follow, "follow", false, "follow passive process events (events with jsonl only)") flags.StringVar(&options.host, "host", options.host, "cli, sdk, cursor, codex, claude, gemini, or mcp") flags.StringVar(&options.command, "command", "", "raw command to classify at the guard boundary") + flags.StringVar(&options.workID, "work-id", "", "foreground work contract identity") + flags.StringVar(&options.workQuestionPrompt, "prompt", "", "bounded foreground work question") + flags.StringVar(&options.workQuestionSchemaPath, "question-schema", "", "JSON Schema path for a foreground work answer") + flags.StringVar(&options.workQuestionID, "question-id", "", "exact foreground work question identity") + flags.StringVar(&options.workAnswerPath, "answer", "", "JSON answer path") + flags.StringVar(&options.workBlockReason, "reason", "", "foreground work blocker") + flags.StringVar(&options.workResultFingerprint, "work-result-fingerprint", "", "exact foreground work result from resolution") if err := flags.Parse(arguments); err != nil { return commandOptions{}, err } @@ -556,6 +572,16 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface if err != nil { return surfaces.Request{}, err } + if options.transitionID == "publication.execute" { + previewFingerprint, ok := parameters.Get("preview_fingerprint") + if ok && previewFingerprint != "" { + receipt, resolveErr := resolveGitHubProviderAuthority(context.Background(), options.repository, previewFingerprint, now) + if resolveErr != nil { + return surfaces.Request{}, resolveErr + } + options.trustedAuthorityReceipts = append(options.trustedAuthorityReceipts, receipt) + } + } authority, err := loadAuthority(options, correlation, objective, parameters, now) if err != nil { return surfaces.Request{}, err @@ -583,11 +609,16 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface ExpectedInstanceID: options.expectedInstanceID, ExpectedStateRevision: options.expectedStateRevision, ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint, ExpectedObjectiveBindingFingerprint: options.expectedObjectiveBindingFingerprint, AuthorityFingerprint: options.authorityFingerprint, - }, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities}, + }, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities, WorkResultFingerprint: options.workResultFingerprint}, RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command, DelegationBindingFingerprint: options.delegationBindingFingerprint, DelegationRequestFingerprint: options.delegationRequestFingerprint, DelegatedAuthorities: delegationClasses(options.delegationAuthorities), + WorkInputs: options.workInputs, + WorkID: options.workID, + WorkQuestionPrompt: options.workQuestionPrompt, + WorkQuestionID: options.workQuestionID, + WorkBlockReason: options.workBlockReason, }, nil } @@ -664,6 +695,15 @@ func loadAuthority(options commandOptions, correlation string, objective model.O if err := decoder.Decode(&trailing); err != io.EOF { return protocol.AuthorityBundle{}, fmt.Errorf("authority receipt contains trailing JSON") } + if receipt.Class == catalog.AuthorityProvider { + return protocol.AuthorityBundle{}, fmt.Errorf("PROVIDER_AUTHORITY_UNTRUSTED: external-provider authority must be derived by the trusted provider boundary") + } + bundle.Receipts = append(bundle.Receipts, receipt) + } + for _, receipt := range options.trustedAuthorityReceipts { + if receipt.Class != catalog.AuthorityProvider { + return protocol.AuthorityBundle{}, fmt.Errorf("trusted authority channel accepts only external-provider receipts") + } bundle.Receipts = append(bundle.Receipts, receipt) } if options.humanActor != "" { @@ -760,6 +800,15 @@ func renderResponse(response surfaces.Response, format string) error { response.Prescription.ExpectedStateRevision, response.Prescription.ExpectedProgramFingerprint, response.Prescription.ExpectedSnapshotFingerprint, correlation) } + if response.Work != nil { + fmt.Printf("work=%s status=%s revision=%d staging=%s\n", response.Work.Request.Contract.ID, response.Work.Status, response.Work.Revision, response.Work.Request.StagingRoot) + if response.Work.Question != nil { + fmt.Printf("question=%s %s\n", response.Work.Question.ID, response.Work.Question.Prompt) + } + if response.Work.BlockReason != "" { + fmt.Println("blocker:", response.Work.BlockReason) + } + } if response.Receipt != nil { fmt.Println("receipt:", response.Receipt.ID) } diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index 19b27aa..0617d6b 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "os" "path/filepath" "strings" @@ -91,6 +92,47 @@ func TestTransitionReceiptCannotBeLoadedAsAuthority(t *testing.T) { } } +func TestCallerCannotSupplyExternalProviderAuthority(t *testing.T) { + now := time.Unix(100, 0).UTC() + path := filepath.Join(t.TempDir(), "provider.json") + receipt := `{"id":"provider-forged","class":"external-provider","subject":"github:owner/repository","fingerprint":"` + strings.Repeat("a", 64) + `","issued_at":"` + now.Format(time.RFC3339) + `","expires_at":"` + now.Add(time.Minute).Format(time.RFC3339) + `"}` + if err := os.WriteFile(path, []byte(receipt), 0o600); err != nil { + t.Fatal(err) + } + _, err := loadAuthority(commandOptions{authorityReceipts: stringList{path}}, "correlation", model.Objective{}, nil, now) + if err == nil || !strings.Contains(err.Error(), "PROVIDER_AUTHORITY_UNTRUSTED") { + t.Fatalf("caller-supplied provider authority error = %v", err) + } +} + +func TestPublicationRequestDerivesStableTrustedProviderAuthority(t *testing.T) { + prior := resolveGitHubProviderAuthority + resolveGitHubProviderAuthority = func(_ context.Context, _ string, fingerprint string, now time.Time) (protocol.AuthorityReceipt, error) { + return protocol.AuthorityReceipt{ + ID: "provider-stable", Class: catalog.AuthorityProvider, Subject: "github:owner/repository", Fingerprint: fingerprint, + IssuedAt: now, ExpiresAt: now.Add(2 * time.Minute), + }, nil + } + t.Cleanup(func() { resolveGitHubProviderAuthority = prior }) + options := commandOptions{ + repository: ".", transitionID: "publication.execute", + parameters: stringList{"preview_fingerprint=" + strings.Repeat("a", 64)}, + } + one, err := buildRequest(surfaces.OperationResolve, options) + if err != nil { + t.Fatal(err) + } + two, err := buildRequest(surfaces.OperationApply, options) + if err != nil { + t.Fatal(err) + } + oneFingerprint, _ := one.Authority.Fingerprint() + twoFingerprint, _ := two.Authority.Fingerprint() + if len(one.Authority.Receipts) != 1 || one.Authority.Receipts[0].Class != catalog.AuthorityProvider || oneFingerprint != twoFingerprint { + t.Fatalf("provider authority was not stable: one=%#v two=%#v", one.Authority, two.Authority) + } +} + func TestHumanPublicationConfirmationBindsExactPreviewFingerprint(t *testing.T) { // control-law: publication-authority-confirms-exact-preview-bytes now := time.Now().UTC() diff --git a/boatstack/cmd/boatstack-helper/work_command.go b/boatstack/cmd/boatstack-helper/work_command.go new file mode 100644 index 0000000..e565460 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/work_command.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +func runFlowWork(arguments []string) error { + if len(arguments) == 0 { + return fmt.Errorf("usage: boatstack flow work [flags]") + } + action := arguments[0] + operations := map[string]surfaces.Operation{ + "show": surfaces.OperationWorkShow, "input-required": surfaces.OperationWorkInputRequired, + "answer": surfaces.OperationWorkAnswer, "complete": surfaces.OperationWorkComplete, "block": surfaces.OperationWorkBlock, + } + operation, ok := operations[action] + if !ok { + return fmt.Errorf("unknown foreground work action %q", action) + } + options, err := parseOptions("flow work "+action, arguments[1:], "", nil) + if err != nil { + return err + } + if options.programID == "" || options.entryID == "" || options.runID == "" || options.workID == "" { + return fmt.Errorf("flow work %s requires --flow, --entry, --run-id, and --work-id", action) + } + bound, err := bindFlowEntry(context.Background(), options) + if err != nil { + return err + } + request, err := buildRequest(operation, bound) + if err != nil { + return err + } + if options.workQuestionSchemaPath != "" { + request.WorkQuestionSchema, err = os.ReadFile(options.workQuestionSchemaPath) + if err != nil { + return err + } + } + if options.workAnswerPath != "" { + request.WorkAnswer, err = os.ReadFile(options.workAnswerPath) + if err != nil { + return err + } + if !json.Valid(request.WorkAnswer) { + return fmt.Errorf("foreground work answer file is not JSON") + } + } + kernel, err := standardKernel(context.Background(), request) + if err != nil { + return err + } + response, handleErr := kernel.Handle(context.Background(), request) + if renderErr := renderResponse(response, options.format); renderErr != nil { + return renderErr + } + return handleErr +} diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 49cd4e6..a9ac233 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -15,7 +15,7 @@ import ( const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 1 + ArtifactSchemaRevision = 2 ) type Artifact struct { @@ -28,6 +28,7 @@ type Artifact struct { DependencyLockSHA256 string `json:"dependency_lock_sha256"` ProgramFingerprint string `json:"program_fingerprint"` GeneratedSkills map[string]string `json:"generated_skills"` + Assets map[string]string `json:"assets"` Program Document `json:"program"` } @@ -60,7 +61,7 @@ func NewArtifact(compiled Compiled, input ArtifactInput) (Artifact, []byte, erro Schema: ArtifactSchemaName, SchemaRevision: ArtifactSchemaRevision, 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, + ProgramFingerprint: compiled.Fingerprint, GeneratedSkills: skills, Assets: workAssetBindings(compiled.Document), Program: compiled.Document, } encoded, err := json.MarshalIndent(artifact, "", " ") if err != nil { @@ -86,7 +87,7 @@ func LoadArtifact(source io.Reader) (Artifact, error) { if err := requireEOF(decoder); err != nil { return Artifact{}, err } - if artifact.Schema != ArtifactSchemaName || artifact.SchemaRevision != ArtifactSchemaRevision || artifact.CompilerVersion == "" || !safeRelative(artifact.SourcePath) || !safeRelative(artifact.DependencyLockPath) || len(artifact.ProgramFingerprint) != 64 || artifact.GeneratedSkills == nil { + if artifact.Schema != ArtifactSchemaName || artifact.SchemaRevision != ArtifactSchemaRevision || artifact.CompilerVersion == "" || !safeRelative(artifact.SourcePath) || !safeRelative(artifact.DependencyLockPath) || len(artifact.ProgramFingerprint) != 64 || artifact.GeneratedSkills == nil || artifact.Assets == nil { return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: artifact envelope is incomplete") } for path, fingerprint := range artifact.GeneratedSkills { @@ -94,6 +95,11 @@ func LoadArtifact(source io.Reader) (Artifact, error) { return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid generated skill binding") } } + for path, fingerprint := range artifact.Assets { + if !safeRelative(path) || len(fingerprint) != 64 { + return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid asset binding") + } + } return artifact, nil } @@ -136,6 +142,12 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: %s does not match artifact", check.label) } } + for path, expected := range artifact.Assets { + raw, readErr := readRepositoryFile(repository, path) + if readErr != nil || digest(raw) != expected { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: work asset %s does not match artifact", path) + } + } compiled, err := Compile(artifact.Program, resolver) if err != nil { return Compiled{}, err @@ -170,6 +182,45 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, return compiled, nil } +func workAssetBindings(document Document) map[string]string { + result := map[string]string{} + for _, contract := range document.Work { + result[contract.Instructions.Path] = contract.Instructions.SHA256 + for _, output := range contract.Outputs { + if output.Schema != nil { + result[output.Schema.Path] = output.Schema.SHA256 + } + } + } + return result +} + +// RepositoryAssetResolver resolves exact bounded regular files below one +// canonical repository root. +type RepositoryAssetResolver struct{ Repository string } + +func (r RepositoryAssetResolver) ResolveAsset(path string, maxBytes int64) ([]byte, error) { + if !safeRelative(path) || maxBytes <= 0 { + return nil, fmt.Errorf("asset path or bound is invalid") + } + root, err := filepath.Abs(r.Repository) + if err != nil { + return nil, err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + raw, err := readRepositoryFile(root, path) + if err != nil { + return nil, err + } + if int64(len(raw)) > maxBytes { + return nil, fmt.Errorf("asset exceeds %d bytes", maxBytes) + } + return raw, 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, `\\`) { diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index dd1c064..dde875f 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -9,6 +9,10 @@ import ( "io" "regexp" "sort" + "strings" + "unicode/utf8" + + "github.com/santhosh-tekuri/jsonschema/v6" ) var semanticID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`) @@ -20,7 +24,25 @@ type Compiled struct { Fingerprint string } +const ( + maxInstructionBytes = 256 << 10 + maxSchemaBytes = 1 << 20 + defaultOutputBytes = 1 << 20 + maxOutputBytes = 16 << 20 +) + +// AssetResolver reads one exact repository-owned asset through a trusted, +// symlink-safe boundary. The restricted TypeScript frontend never reads it. +type AssetResolver interface { + ResolveAsset(path string, maxBytes int64) ([]byte, error) +} + func Load(source io.Reader, resolver BindingResolver) (Compiled, error) { + return LoadWithAssets(source, resolver, nil) +} + +// LoadWithAssets strictly decodes raw IR and resolves its declared work assets. +func LoadWithAssets(source io.Reader, resolver BindingResolver, assets AssetResolver) (Compiled, error) { raw, err := readLimited(source, 16<<20, "CONTROL_PROGRAM_INVALID: input exceeds 16 MiB") if err != nil { return Compiled{}, err @@ -37,7 +59,7 @@ func Load(source io.Reader, resolver BindingResolver) (Compiled, error) { if err := requireEOF(decoder); err != nil { return Compiled{}, err } - return Compile(document, resolver) + return compile(document, resolver, assets) } func readLimited(source io.Reader, limit int64, oversized string) ([]byte, error) { @@ -52,6 +74,10 @@ func readLimited(source io.Reader, limit int64, oversized string) ([]byte, error } func Compile(document Document, resolver BindingResolver) (Compiled, error) { + return compile(document, resolver, nil) +} + +func compile(document Document, resolver BindingResolver, assets AssetResolver) (Compiled, error) { if document.Schema != SchemaName || document.SchemaRevision != SchemaRevision { return Compiled{}, invalid("schema", "unsupported schema or revision") } @@ -111,7 +137,11 @@ func Compile(document Document, resolver BindingResolver) (Compiled, error) { if err := normalizeTargetsAndEntries(&document, facets, resolver); err != nil { return Compiled{}, err } - if err := normalizeTransitions(&document, facets, operators); err != nil { + work, err := normalizeWork(&document, assets) + if err != nil { + return Compiled{}, err + } + if err := normalizeTransitions(&document, facets, operators, work); err != nil { return Compiled{}, err } @@ -130,6 +160,111 @@ func Compile(document Document, resolver BindingResolver) (Compiled, error) { return Compiled{Document: document, Canonical: pretty, Fingerprint: fingerprint}, nil } +func normalizeWork(document *Document, assets AssetResolver) (map[string]WorkContract, error) { + entryInputs := map[string]bool{} + for _, entry := range document.Entries { + for _, input := range entry.Inputs { + entryInputs[input.ID] = true + } + } + seen := map[string]WorkContract{} + for i := range document.Work { + contract := &document.Work[i] + if !validID(contract.ID) || seen[contract.ID].ID != "" { + return nil, invalid(fmt.Sprintf("work[%d].id", i), "invalid or duplicate work id") + } + if err := resolveWorkAsset(&contract.Instructions, assets, maxInstructionBytes, "work."+contract.ID+".instructions"); err != nil { + return nil, err + } + if strings.TrimSpace(contract.Instructions.Content) == "" { + return nil, invalid("work."+contract.ID+".instructions", "instruction asset must not be empty") + } + inputIDs := map[string]bool{} + for j := range contract.Inputs { + input := &contract.Inputs[j] + if !validID(input.ID) || !validID(input.EntryInput) || inputIDs[input.ID] || !entryInputs[input.EntryInput] { + return nil, invalid(fmt.Sprintf("work.%s.inputs[%d]", contract.ID, j), "invalid or duplicate work input") + } + inputIDs[input.ID] = true + } + sort.Slice(contract.Inputs, func(i, j int) bool { return contract.Inputs[i].ID < contract.Inputs[j].ID }) + outputIDs, outputPaths := map[string]bool{}, map[string]bool{} + for j := range contract.Outputs { + output := &contract.Outputs[j] + if !validID(output.ID) || !safeRelative(output.Path) || outputIDs[output.ID] || outputPaths[output.Path] { + return nil, invalid(fmt.Sprintf("work.%s.outputs[%d]", contract.ID, j), "invalid or duplicate output id/path") + } + if output.MediaType != "text/markdown" && output.MediaType != "text/plain" && output.MediaType != "application/json" { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".media_type", "must be text/markdown, text/plain, or application/json") + } + if output.MaxBytes == 0 { + output.MaxBytes = defaultOutputBytes + } + if output.MaxBytes < 1 || output.MaxBytes > maxOutputBytes { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".max_bytes", "must be between 1 and 16 MiB") + } + if output.Schema != nil { + if output.MediaType != "application/json" { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", "schemas require application/json") + } + if err := resolveWorkAsset(output.Schema, assets, maxSchemaBytes, "work."+contract.ID+".outputs."+output.ID+".schema"); err != nil { + return nil, err + } + var schemaValue any + decoder := json.NewDecoder(strings.NewReader(output.Schema.Content)) + decoder.UseNumber() + if err := decoder.Decode(&schemaValue); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", err.Error()) + } + if err := requireEOF(decoder); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", "schema contains trailing JSON") + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource(output.Schema.Path, schemaValue); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", err.Error()) + } + if _, err := compiler.Compile(output.Schema.Path); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", err.Error()) + } + } + outputIDs[output.ID], outputPaths[output.Path] = true, true + } + if len(contract.Outputs) == 0 { + return nil, invalid("work."+contract.ID+".outputs", "at least one output is required") + } + sort.Slice(contract.Outputs, func(i, j int) bool { return contract.Outputs[i].ID < contract.Outputs[j].ID }) + seen[contract.ID] = *contract + } + sort.Slice(document.Work, func(i, j int) bool { return document.Work[i].ID < document.Work[j].ID }) + return seen, nil +} + +func resolveWorkAsset(asset *WorkAsset, resolver AssetResolver, limit int64, field string) error { + if !safeRelative(asset.Path) { + return invalid(field+".path", "must be a canonical repository-relative path") + } + compiled := asset.SHA256 != "" || asset.Content != "" + if compiled { + if asset.SHA256 == "" || len(asset.SHA256) != 64 || digest([]byte(asset.Content)) != asset.SHA256 || int64(len(asset.Content)) > limit { + return invalid(field, "compiled asset bytes or fingerprint are invalid") + } + return nil + } + if resolver == nil { + return invalid(field, "unresolved asset requires a trusted repository resolver") + } + raw, err := resolver.ResolveAsset(asset.Path, limit) + if err != nil { + return invalid(field, err.Error()) + } + if !utf8.Valid(raw) { + return invalid(field, "asset must be UTF-8") + } + asset.Content, asset.SHA256 = string(raw), digest(raw) + return nil +} + func normalizeEvidence(document *Document, facets map[string]Facet) error { seen := map[string]bool{} for i := range document.Evidence { @@ -263,8 +398,9 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi return seen, nil } -func normalizeTransitions(document *Document, facets map[string]Facet, operators map[string]Operator) error { +func normalizeTransitions(document *Document, facets map[string]Facet, operators map[string]Operator, work map[string]WorkContract) error { seen := map[string]bool{} + workRefs := map[string]bool{} for i := range document.Transitions { value := &document.Transitions[i] var err error @@ -272,6 +408,12 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators return invalid(fmt.Sprintf("transitions[%d]", i), "invalid transition or operator reference") } seen[value.ID] = true + if value.Work != "" { + if work[value.Work].ID == "" { + return invalid("transitions."+value.ID+".work", "unknown work reference "+value.Work) + } + workRefs[value.Work] = true + } if err := normalizePredicate(&value.Guard, facets); err != nil { return invalid("transitions."+value.ID+".guard", err.Error()) } @@ -286,6 +428,11 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators return invalid("transitions."+value.ID+".requires.authorities", "undeclared "+missing) } } + for id := range work { + if !workRefs[id] { + return invalid("work."+id, "work contract is not referenced by a transition") + } + } if len(seen) == 0 { return invalid("transitions", "at least one transition is required") } @@ -520,6 +667,7 @@ func sortPredicates(values []Predicate) { func stripDescriptions(value Document) Document { value.Facets = append([]Facet(nil), value.Facets...) value.Evidence = append([]Evidence(nil), value.Evidence...) + value.Work = append([]WorkContract(nil), value.Work...) value.Operators = append([]Operator(nil), value.Operators...) value.Transitions = append([]Transition(nil), value.Transitions...) value.Targets = append([]Target(nil), value.Targets...) @@ -531,6 +679,9 @@ func stripDescriptions(value Document) Document { for i := range value.Evidence { value.Evidence[i].Description = "" } + for i := range value.Work { + value.Work[i].Description = "" + } for i := range value.Operators { value.Operators[i].Description = "" } diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index e02a690..a415c17 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -2,6 +2,8 @@ package controlprogram_test import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "path/filepath" @@ -92,6 +94,68 @@ func TestDomainNeutralIncidentProgramCompiles(t *testing.T) { } } +func incidentWorkProgram() controlprogram.Document { + document := incidentProgram() + instructions := "Inspect the incident and produce the declared diagnosis." + digest := sha256.Sum256([]byte(instructions)) + document.Work = []controlprogram.WorkContract{{ + ID: "diagnose", Instructions: controlprogram.WorkAsset{Path: "instructions.md", SHA256: hex.EncodeToString(digest[:]), Content: instructions}, + Inputs: []controlprogram.WorkInput{{ID: "incident", EntryInput: "incident"}}, + Outputs: []controlprogram.WorkOutput{{ID: "diagnosis", Path: "diagnosis.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}, + Description: "presentation only", + }} + document.Transitions[0].Work = "diagnose" + return document +} + +func TestForegroundWorkIsDomainNeutralAndFingerprintBound(t *testing.T) { + // control-law: exact foreground-work semantics contribute to canonical program identity + base, err := controlprogram.Compile(incidentWorkProgram(), nil) + if err != nil { + t.Fatal(err) + } + if len(base.Document.Work) != 1 || base.Document.Transitions[0].Work != "diagnose" { + t.Fatalf("compiled work = %#v", base.Document.Work) + } + description := incidentWorkProgram() + description.Work[0].Description = "different prose" + equivalent, err := controlprogram.Compile(description, nil) + if err != nil || equivalent.Fingerprint != base.Fingerprint { + t.Fatalf("work description changed executable identity: %v %s != %s", err, equivalent.Fingerprint, base.Fingerprint) + } + changed := incidentWorkProgram() + changed.Work[0].Instructions.Content += " Verify recovery." + digest := sha256.Sum256([]byte(changed.Work[0].Instructions.Content)) + changed.Work[0].Instructions.SHA256 = hex.EncodeToString(digest[:]) + semantic, err := controlprogram.Compile(changed, nil) + if err != nil { + t.Fatal(err) + } + if semantic.Fingerprint == base.Fingerprint { + t.Fatal("instruction asset change preserved executable identity") + } +} + +func TestForegroundWorkRejectsUnboundAssetsInputsAndTransitions(t *testing.T) { + // control-law: every foreground-work dependency is declared and exactly referenced + for name, mutate := range map[string]func(*controlprogram.Document){ + "unresolved-asset": func(value *controlprogram.Document) { + value.Work[0].Instructions.Content, value.Work[0].Instructions.SHA256 = "", "" + }, + "unknown-entry-input": func(value *controlprogram.Document) { value.Work[0].Inputs[0].EntryInput = "missing" }, + "unreferenced-work": func(value *controlprogram.Document) { value.Transitions[0].Work = "" }, + "unknown-work": func(value *controlprogram.Document) { value.Transitions[0].Work = "missing" }, + } { + t.Run(name, func(t *testing.T) { + document := incidentWorkProgram() + mutate(&document) + if _, err := controlprogram.Compile(document, nil); err == nil { + t.Fatalf("%s was accepted", name) + } + }) + } +} + func TestCanonicalFingerprintIgnoresOrderingAndDescriptions(t *testing.T) { // control-law: canonical-program-identity-binds-executable-semantics-only base, err := controlprogram.Compile(incidentProgram(), nil) @@ -339,3 +403,39 @@ func TestArtifactRejectsGeneratedPathsOutsideHostSkillRoots(t *testing.T) { t.Fatal("artifact accepted an arbitrary generated deletion path") } } + +func TestArtifactBindsExactForegroundWorkAssets(t *testing.T) { + // control-law: runtime admits only the instruction and schema assets compiled into the artifact + repository := t.TempDir() + document := incidentWorkProgram() + files := map[string][]byte{ + "flow.ts": []byte("source"), + "package-lock.json": []byte("lock"), + "instructions.md": []byte(document.Work[0].Instructions.Content), + } + for path, raw := range files { + if err := os.WriteFile(filepath.Join(repository, path), raw, 0o600); err != nil { + t.Fatal(err) + } + } + compiled, err := controlprogram.Compile(document, nil) + if err != nil { + t.Fatal(err) + } + artifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: files["flow.ts"], + DependencyLockPath: "package-lock.json", DependencyLock: files["package-lock.json"], GeneratedSkills: map[string][]byte{}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, nil); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "instructions.md"), []byte("different instructions"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, nil); err == nil || !strings.Contains(err.Error(), "work asset") { + t.Fatalf("changed work asset result = %v", err) + } +} diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 6d9af69..c4cc884 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -11,7 +11,10 @@ import ( "strings" "testing" + 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" ) @@ -83,6 +86,7 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { {"product-delivery-a.flow.ts", 1, 1}, {"product-delivery-b.flow.ts", 2, 2}, {"product-delivery-c.flow.ts", 1, 6}, + {"product-delivery-planning-package.flow.ts", 1, 21}, } for _, test := range cases { t.Run(test.fixture, func(t *testing.T) { @@ -91,7 +95,7 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { if commandErr != nil { t.Fatalf("compile repository Flow: %v\n%s", commandErr, frontendRaw) } - compiled, compileErr := controlprogram.Load(bytes.NewReader(frontendRaw), resolver) + compiled, compileErr := controlprogram.LoadWithAssets(bytes.NewReader(frontendRaw), resolver, controlprogram.RepositoryAssetResolver{Repository: filepath.Dir(moduleRoot)}) if compileErr != nil { t.Fatal(compileErr) } @@ -106,10 +110,44 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { if len(compiled.Document.Entries) != test.entries || len(manifest.Transitions) != test.transitions { t.Fatalf("entries=%d transitions=%d", len(compiled.Document.Entries), len(manifest.Transitions)) } + if test.fixture == "product-delivery-planning-package.flow.ts" { + if _, compileErr := delivery.Compile(context.Background(), delivery.CompileRequest{KernelVersion: boatstack.Version, Core: core.System(), Runtime: definition, Settings: map[string]string{"fixture": test.fixture}}); compileErr != nil { + t.Fatalf("compile repository Flow runtime: %v", compileErr) + } + } }) } } +func TestDomainNeutralFrontendDeclaresForegroundWorkWithoutSoftwareDelivery(t *testing.T) { + // control-law: foreground work is a domain-neutral requirement rather than a delivery effect + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate foreground work 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 { + t.Skip("Flow frontend dependencies are not installed") + } + source := filepath.Join(moduleRoot, "testdata", "control-programs", "incident-response-work.flow.ts") + raw, err := exec.Command(frontend, source).CombinedOutput() + if err != nil { + t.Fatalf("compile foreground work fixture: %v\n%s", err, raw) + } + compiled, err := controlprogram.LoadWithAssets(bytes.NewReader(raw), nil, controlprogram.RepositoryAssetResolver{Repository: repositoryRoot}) + if err != nil { + t.Fatal(err) + } + if len(compiled.Document.Work) != 1 || compiled.Document.Transitions[0].Work != "diagnose" || compiled.Document.Work[0].Instructions.Content == "" || compiled.Document.Work[0].Outputs[0].Schema.Content == "" { + t.Fatalf("compiled foreground work = %#v", compiled.Document.Work) + } +} + func TestTypeScriptFrontendRejectsRepositoryCodeWithoutExecutingIt(t *testing.T) { // control-law: authoring-frontends-parse-repository-declarations-without-module-execution _, file, _, ok := runtime.Caller(0) diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 68b5b11..86cc7ab 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,21 +7,22 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 2 + SchemaRevision = 3 ) type Document struct { - Schema string `json:"schema"` - SchemaRevision int `json:"schema_revision"` - 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"` + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + Program Program `json:"program"` + Declarations Declarations `json:"declarations"` + Facets []Facet `json:"facets"` + Evidence []Evidence `json:"evidence,omitempty"` + Work []WorkContract `json:"work,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 { @@ -52,6 +53,39 @@ type Evidence struct { Description string `json:"description,omitempty"` } +// WorkAsset is a repository asset declaration in raw IR and an exact +// content-bound asset in compiled IR. The trusted compiler supplies SHA256 and +// Content; repository Flow source may supply only Path. +type WorkAsset struct { + Path string `json:"path"` + SHA256 string `json:"sha256,omitempty"` + Content string `json:"content,omitempty"` +} + +type WorkInput struct { + ID string `json:"id"` + EntryInput string `json:"entry_input"` +} + +type WorkOutput struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + Required bool `json:"required"` + MaxBytes int64 `json:"max_bytes,omitempty"` + Schema *WorkAsset `json:"schema,omitempty"` +} + +// WorkContract declares bounded foreground work. It contains no executable +// code, capabilities, authority, effects, or native handlers. +type WorkContract struct { + ID string `json:"id"` + Instructions WorkAsset `json:"instructions"` + Inputs []WorkInput `json:"inputs"` + Outputs []WorkOutput `json:"outputs"` + 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"` @@ -122,6 +156,7 @@ type Transition struct { Target Predicate `json:"target"` Priority int `json:"priority"` Requires TransitionRequirements `json:"requires,omitempty"` + Work string `json:"work,omitempty"` Description string `json:"description,omitempty"` } diff --git a/boatstack/core/system_test.go b/boatstack/core/system_test.go index a6c3f65..f9d894b 100644 --- a/boatstack/core/system_test.go +++ b/boatstack/core/system_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/delivery" ) func TestManifestOwnsOnlyOperationalCapabilities(t *testing.T) { @@ -25,6 +26,24 @@ func TestManifestOwnsOnlyOperationalCapabilities(t *testing.T) { } } +func TestInstallationInitializationAcceptsExplicitlyDelegatedAutonomy(t *testing.T) { + // control-law: exact run delegation may bootstrap verified local state + manifest, err := core.System().CoreManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, transition := range manifest.Transitions { + if transition.ID != "installation.initialize" { + continue + } + if len(transition.Authority) != 2 || transition.Authority[0] != delivery.AuthorityHuman || transition.Authority[1] != delivery.AuthorityAutonomy { + t.Fatalf("installation.initialize authority alternatives = %v", transition.Authority) + } + return + } + t.Fatal("installation.initialize is absent") +} + func hasPrefix(value string, prefixes ...string) bool { for _, prefix := range prefixes { if strings.HasPrefix(value, prefix) { diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index 4679de5..6d59897 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -2285,7 +2285,8 @@ "correlation-id" ], "authority": [ - "human" + "human", + "autonomy" ], "required_evidence": [ "invocation-context", diff --git a/boatstack/delivery/control.go b/boatstack/delivery/control.go index 9797d3e..8cccd0a 100644 --- a/boatstack/delivery/control.go +++ b/boatstack/delivery/control.go @@ -40,6 +40,9 @@ type StateEffectKind = catalog.StateEffectKind type StateAssignment = catalog.StateAssignment type StatePrecondition = catalog.StatePrecondition type StateValueReference = catalog.StateValueReference +type WorkContract = catalog.WorkContract +type WorkInput = catalog.WorkInput +type WorkOutput = catalog.WorkOutput type StateFacet = model.StateFacet type TargetID = model.TargetID type ProtocolPhase = model.ProtocolPhase diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index b225932..c0c117d 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -13,6 +13,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/engine" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" @@ -37,6 +38,7 @@ type DeliveryController struct { observer ports.Observer engine engine.Engine clock effects.Clock + work foregroundwork.Manager } // TargetSatisfied reports whether the exact compiled program marks the @@ -65,6 +67,10 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro if err != nil { return DeliveryController{}, err } + workManager, err := foregroundwork.NewManager(resolver, locker, clock, effects.NewRuntimeStore()) + if err != nil { + return DeliveryController{}, err + } journal, err := effects.NewJournal(resolver, clock) if err != nil { return DeliveryController{}, err @@ -84,7 +90,7 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro if err != nil { return DeliveryController{}, err } - return DeliveryController{program: program, registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock}, nil + return DeliveryController{program: program, registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock, work: workManager}, nil } func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) { @@ -120,7 +126,20 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request switch request.Operation { case surfaces.OperationResolve, surfaces.OperationExplain: explain := request.Operation == surfaces.OperationExplain - resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain}) + resolveRequest := engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain} + resolution, resolveErr := k.engine.Resolve(ctx, resolveRequest) + if !explain && resolveErr == nil && resolution.Decision.Kind == supervisor.DecisionCandidate && resolution.Decision.Transition != nil && resolution.Decision.Transition.Work != nil { + record, workErr := k.work.Ensure(ctx, invocation, request.FlowID, request.ProgramID, request.EntryID, resolution.Objective, resolution.Snapshot, *resolution.Decision.Transition, request.WorkInputs) + if workErr != nil { + response.Error = workErr.Error() + return response, workErr + } + response.Work = &record + if record.Status == foregroundwork.StatusCompleted && record.Result != nil { + resolveRequest.Work = record.Result + resolution, resolveErr = k.engine.Resolve(ctx, resolveRequest) + } + } response.Objective, response.Decision, response.Trace = resolution.Objective, &resolution.Decision, resolution.Trace if !explain && resolution.Prescription.ID != "" { response.Prescription = &resolution.Prescription @@ -129,10 +148,10 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request if !explain && resolution.Snapshot.Fingerprint != "" { response.Snapshot = &resolution.Snapshot } - if !explain { + if !explain && response.Work == nil { response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, resolution.Decision) } - if !explain && response.Question == nil && request.FlowID != "" && len(resolution.Decision.Candidates) == 1 { + if !explain && response.Work == nil && 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) @@ -149,8 +168,22 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request } return response, nil case surfaces.OperationApply, surfaces.OperationRecover: + var work *protocol.WorkEvidence + if transition, ok := k.registry.Lookup(request.TransitionID); ok && transition.Work != nil { + record, workErr := k.work.Show(ctx, invocation, request.FlowID, transition.Work.ID) + if workErr != nil { + response.Error = workErr.Error() + return response, workErr + } + if record.Status != foregroundwork.StatusCompleted || record.Result == nil { + err := fmt.Errorf("transition %q requires completed foreground work %q", transition.ID, transition.Work.ID) + response.Error = err.Error() + return response, err + } + work, response.Work = record.Result, &record + } result, applyErr := k.engine.Apply(ctx, engine.ApplyRequest{ - ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID}, + ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID, Work: work}, FlowID: request.FlowID, Prescription: request.Prescription, Parameters: request.Parameters, IdempotencyKey: request.IdempotencyKey, AdmissionLifetime: 2 * time.Minute, }) response.Prescription = &request.Prescription @@ -176,6 +209,27 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request return response, applyErr } return response, nil + case surfaces.OperationWorkShow, surfaces.OperationWorkInputRequired, surfaces.OperationWorkAnswer, surfaces.OperationWorkComplete, surfaces.OperationWorkBlock: + var record foregroundwork.Record + var workErr error + switch request.Operation { + case surfaces.OperationWorkShow: + record, workErr = k.work.Show(ctx, invocation, request.FlowID, request.WorkID) + case surfaces.OperationWorkInputRequired: + record, workErr = k.work.InputRequired(ctx, invocation, request.FlowID, request.WorkID, request.WorkQuestionPrompt, request.WorkQuestionSchema) + case surfaces.OperationWorkAnswer: + record, workErr = k.work.Answer(ctx, invocation, request.FlowID, request.WorkID, request.WorkQuestionID, request.WorkAnswer) + case surfaces.OperationWorkComplete: + record, workErr = k.work.Complete(ctx, invocation, request.FlowID, request.WorkID) + case surfaces.OperationWorkBlock: + record, workErr = k.work.Block(ctx, invocation, request.FlowID, request.WorkID, request.WorkBlockReason) + } + if workErr != nil { + response.Error = workErr.Error() + return response, workErr + } + response.Work = &record + return response, nil case surfaces.OperationDoctor: summary := k.program.Summary() extensionIDs := make([]string, 0, len(summary.Extensions)) @@ -288,7 +342,7 @@ func (k DeliveryController) deriveRepositoryAuthority(ctx context.Context, invoc if err != nil { return protocol.AuthorityBundle{}, err } - return protocol.DeriveRepositoryAuthority(snapshot, bundle, k.clock.Now()) + return protocol.DeriveRepositoryAuthorityWhenAvailable(snapshot, bundle, k.clock.Now()) } func readEvents(path string) ([]map[string]any, error) { diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 17f8fd4..db0ff01 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -33,6 +33,13 @@ func NewResolver(ctx context.Context) (Resolver, error) { for _, transition := range manifest.Transitions { transitions[string(transition.ID)] = transition } + planning, err := planningPackageTransitions(transitions) + if err != nil { + return Resolver{}, err + } + for _, transition := range planning { + transitions[string(transition.ID)] = transition + } return Resolver{transitions: transitions}, nil } diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index d7bfd88..b8f4650 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -10,6 +10,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/delivery" "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + general "github.com/operatorstack/boatstack/boatstack/kernel" ) // Definition is a trusted adapter. Repository IR selects bindings and adds @@ -46,6 +47,10 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim for _, operator := range d.compiled.Document.Operators { operatorByID[operator.ID] = operator } + workByID := map[string]controlprogram.WorkContract{} + for _, work := range d.compiled.Document.Work { + workByID[work.ID] = work + } objectives := map[model.TargetID]EntryObjective{} contracts := map[model.TargetID]delivery.ObjectiveContract{} for _, entry := range d.compiled.Document.Entries { @@ -86,6 +91,17 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim transition.TargetConditions = append(transition.TargetConditions, target...) transition.Priority = declaration.Priority transition.ExecutionContext = operator.ExecutionContext + if declaration.Work != "" { + work, exists := workByID[declaration.Work] + if !exists { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q references unknown foreground work %q", declaration.ID, declaration.Work) + } + transition.Work, err = runtimeWorkContract(work) + if err != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) + } + transition.OwnedResources = append(transition.OwnedResources, "foreground-work-"+transition.Work.ID) + } for _, authority := range declaration.Requires.Authorities { transition.AuthorityAll = append(transition.AuthorityAll, delivery.AuthorityClass(authority)) } @@ -135,6 +151,36 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim return base, nil } +func runtimeWorkContract(declaration controlprogram.WorkContract) (*delivery.WorkContract, error) { + work := &delivery.WorkContract{ + ID: declaration.ID, InstructionPath: declaration.Instructions.Path, + InstructionSHA256: declaration.Instructions.SHA256, InstructionContent: declaration.Instructions.Content, + } + for _, input := range declaration.Inputs { + work.Inputs = append(work.Inputs, delivery.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) + } + for _, output := range declaration.Outputs { + runtimeOutput := delivery.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} + if output.Schema != nil { + runtimeOutput.SchemaPath, runtimeOutput.SchemaSHA256, runtimeOutput.SchemaContent = output.Schema.Path, output.Schema.SHA256, output.Schema.Content + } + work.Outputs = append(work.Outputs, runtimeOutput) + } + fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []delivery.WorkInput `json:"inputs,omitempty"` + Outputs []delivery.WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}) + if err != nil { + return nil, err + } + work.Fingerprint = fingerprint + return work, nil +} + func uniqueAuthorities(values []delivery.AuthorityClass) []delivery.AuthorityClass { seen := map[delivery.AuthorityClass]bool{} result := make([]delivery.AuthorityClass, 0, len(values)) diff --git a/boatstack/flow/softwaredelivery/planning_package.go b/boatstack/flow/softwaredelivery/planning_package.go new file mode 100644 index 0000000..accc4dc --- /dev/null +++ b/boatstack/flow/softwaredelivery/planning_package.go @@ -0,0 +1,114 @@ +package softwaredelivery + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +var planningPackageSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +const ( + PlanningPackageAdmit = "planning.package.admit" + PlanningPackageApprove = "planning.package.approve" + PlanningPackagePromote = "planning.package.promote" +) + +// planningPackageTransitions derives optional trusted mechanisms from the +// standard delivery primitives. Repositories decide whether these transitions +// belong to their Flow and attach their own foreground-work contract to admit. +func planningPackageTransitions(transitions map[string]delivery.Transition) ([]delivery.Transition, error) { + clone := func(id string) (delivery.Transition, error) { + transition, ok := transitions[id] + if !ok { + return delivery.Transition{}, fmt.Errorf("trusted planning-package base %q is unavailable", id) + } + return transition, nil + } + admit, err := clone("plan.create") + if err != nil { + return nil, err + } + admit.ID, admit.Effect = PlanningPackageAdmit, PlanningPackageAdmit + admit.LocalEffects = []delivery.EffectID{PlanningPackageAdmit} + admit.Prescription.Operation = PlanningPackageAdmit + admit.Prescription.ExpectedPostcondition = "a schema-valid planning package is admitted" + admit.SourcePredicate, admit.AdmissionPredicate, admit.TargetPredicate = "planning-package-admit-source", "exact-work-and-transition-admission", "planning-package-valid" + admit.Verifier = "verifier:fresh-observation:" + PlanningPackageAdmit + admit.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-admit"} + admit.TargetConditions = replacePlanCondition(admit.TargetConditions, model.PlanValid) + admit.OwnedResources = []string{"planning-package"} + admit.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageAdmit + + approve, err := clone("plan.approve") + if err != nil { + return nil, err + } + approve.ID, approve.Effect = PlanningPackageApprove, PlanningPackageApprove + approve.LocalEffects = []delivery.EffectID{PlanningPackageApprove} + approve.Parameters = []delivery.ParameterSpec{{Name: "package_fingerprint", Required: true}} + approve.Prescription.Operation = PlanningPackageApprove + approve.Prescription.ExpectedPostcondition = "the exact planning package is approved" + approve.SourcePredicate, approve.AdmissionPredicate, approve.TargetPredicate = "planning-package-valid", "exact-package-approval", "planning-package-approved" + approve.Verifier = "verifier:fresh-observation:" + PlanningPackageApprove + approve.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-approve"} + approve.SourceConditions = replacePlanCondition(approve.SourceConditions, model.PlanValid) + approve.TargetConditions = replacePlanCondition(approve.TargetConditions, model.PlanPackageApproved) + approve.OwnedResources = []string{"planning-package-approval"} + approve.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageApprove + + promote, err := clone("plan.activate") + if err != nil { + return nil, err + } + promote.ID, promote.Effect = PlanningPackagePromote, PlanningPackagePromote + promote.LocalEffects = []delivery.EffectID{PlanningPackagePromote} + promote.Prescription.Operation = PlanningPackagePromote + promote.Prescription.ExpectedPostcondition = "the approved package plan is the canonical delivery plan" + promote.SourcePredicate, promote.AdmissionPredicate, promote.TargetPredicate = "planning-package-approved", "exact-package-promotion", "plan-approved" + promote.Verifier = "verifier:fresh-observation:" + PlanningPackagePromote + promote.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-promote"} + promote.SourceConditions = replacePlanCondition(promote.SourceConditions, model.PlanPackageApproved) + promote.TargetConditions = replacePlanCondition(promote.TargetConditions, model.PlanApproved) + promote.TargetIDs = append([]model.TargetID(nil), admit.TargetIDs...) + promote.OwnedResources = []string{"planning-package-promotion"} + promote.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackagePromote + + return []delivery.Transition{admit, approve, promote}, nil +} + +func replacePlanCondition(values []delivery.FacetCondition, state model.PlanState) []delivery.FacetCondition { + result := append([]delivery.FacetCondition(nil), values...) + for index := range result { + if result[index].Facet == model.FacetPlan { + result[index].Statuses = []model.FactStatus{model.FactKnown} + result[index].Values = []string{string(state)} + } + } + return result +} + +// PlanningPackageFingerprint reads the current repository package projection. +// Effect preflight independently verifies the complete manifest before any +// mutation; this helper only binds the candidate parameter for continuation. +func PlanningPackageFingerprint(repository, deliveryID string) (string, error) { + if !planningPackageSegment.MatchString(deliveryID) { + return "", fmt.Errorf("invalid planning package delivery identity") + } + raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "planning-packages", deliveryID, "manifest.json")) + if err != nil { + return "", err + } + var projection struct { + Fingerprint string `json:"fingerprint"` + } + if err := json.Unmarshal(raw, &projection); err != nil || len(projection.Fingerprint) != 64 { + return "", fmt.Errorf("planning package manifest has no valid fingerprint") + } + return projection.Fingerprint, nil +} diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index ece4d32..eadff4c 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -57,6 +57,29 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s supersession := "" delegation := "" diagnostics := "" + workProtocol := "" + publication := "" + if len(compiled.Document.Work) != 0 { + workProtocol = fmt.Sprintf(` +When a response contains a `+"`work`"+` request, treat it as foreground work for +the selected transition, not as a second Flow. Read its exact instruction, +input bindings, output manifest, and staging root. Write only the declared +outputs beneath that staging root and stay within each media type and size +bound. + +If human input is required, record the typed suspension with: + +`+"`boatstack flow work input-required --repo . --flow %s --entry %s --run-id --work-id --prompt --host %s --format json`"+` + +Ask the user and wait. Store the answer as bounded JSON, then submit it with +`+"`boatstack flow work answer ... --question-id --answer `"+`. +An answer is evidence, never authority. If work succeeds, run +`+"`boatstack flow work complete ...`"+`; if it cannot continue, run +`+"`boatstack flow work block ... --reason `"+`. Resume the same entry +and run ID afterward. Never edit the work record directly or continue in the +background while a question is open. +`, compiled.Document.Program.ID, entry.ID, host) + } if entry.Diagnostics != nil && entry.Diagnostics.ExplainOnSuspend { diagnostics = fmt.Sprintf(` If Boatstack suspends this run without reaching the target or prescribing an @@ -79,13 +102,22 @@ request, then run: `+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human --host %s`"+` -After authorization, use `+"`boatstack flow run --repo . --flow %s --entry %s --run-id --host %s --format json`"+`. +After authorization, use `+"`boatstack flow run --repo . --flow %s --entry %s --run-id --repository-authority --host %s --format json`"+`. Do not request approval again after a restart or typed suspension. Resume the same run and delegation unless Boatstack reports revocation, expiry, drift, or terminal completion. Never authorize on the user's behalf. `, compiled.Document.Program.ID, entry.ID, host, compiled.Document.Program.ID, entry.ID, host) } if entry.Target == "published-pr" { + publication = ` +If Boatstack reports ` + "`WORKSPACE_COMMIT_REQUIRED`" + `, stay in the same +managed worktree and run. Commit only the intended delivery changes on the +current managed branch, excluding generated runtime and publication artifacts +unless they are deliberately part of the delivery, then resume this entry. +Never fabricate an external-provider receipt. Boatstack derives provider +capability through its trusted GitHub boundary and reports a typed blocker when +that capability is unavailable. +` abandonmentSkill, ok := targetEntrySkill(compiled.Document.Program.ID, compiled.Document.Entries, "safely-abandoned") if ok { supersession = fmt.Sprintf(` @@ -109,7 +141,7 @@ Boatstack does not interpret the entry name. %s -Start with `+"`boatstack next --repo . --flow %s --entry %s --host %s --format json`"+`. +Start with `+"`boatstack next --repo . --flow %s --entry %s --repository-authority --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. @@ -121,11 +153,13 @@ background while input is missing. Never synthesize authority. %s %s %s +%s +%s 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, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics, workProtocol, publication)) } func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 2ba7555..35d5e09 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -36,8 +36,9 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } value := string(codex) for _, contract := range []string{ - "--flow product-delivery --entry run", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", + "--flow product-delivery --entry run", "--repository-authority", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", "BOATSTACK_LAUNCHER_NOT_FOUND", ".boatstack/runtime.json", "Never run it", "creates no\nFlow run ID", + "WORKSPACE_COMMIT_REQUIRED", "Commit only the intended delivery changes", "Never fabricate an external-provider receipt", } { if !strings.Contains(value, contract) { t.Fatalf("generated skill lacks %q", contract) @@ -118,6 +119,29 @@ func TestGeneratedSkillExplanationIsEntryOptInWithHostParity(t *testing.T) { } } +func TestGeneratedSkillsProjectForegroundWorkProtocolWithHostParity(t *testing.T) { + // control-law: every supported agent host projects the same foreground-work boundary + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "incident-response"}, + Work: []controlprogram.WorkContract{{ID: "diagnose"}}, + Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated"}}, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + codex := string(files[".agents/skills/incident-response-respond/SKILL.md"]) + claude := string(files[".claude/skills/incident-response-respond/SKILL.md"]) + for _, contract := range []string{"not as a second Flow", "flow work input-required", "flow work answer", "flow work complete", "flow work block", "An answer is evidence, never authority", "Never edit the work record directly"} { + if !strings.Contains(codex, contract) { + t.Fatalf("generated foreground-work skill lacks %q", contract) + } + } + if strings.ReplaceAll(codex, "--host codex", "--host HOST") != strings.ReplaceAll(claude, "--host claude", "--host HOST") { + t.Fatal("Codex and Claude foreground-work projections differ") + } +} + func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testing.T) { compiled := controlprogram.Compiled{Document: controlprogram.Document{ Program: controlprogram.Program{ID: "product-delivery"}, diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 8717de8..77f6375 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -30,6 +30,37 @@ func RunFlowFrontend(ctx context.Context, executable, sourceName string, source return output, nil } +// VerifyFlowProjectionAtRevision proves that a workspace base contains the +// exact active Flow inputs and generated outputs before authority transfers to +// a new Git worktree. +func VerifyFlowProjectionAtRevision(ctx context.Context, repository, revision string, paths []string) error { + if !filepath.IsAbs(repository) || filepath.Clean(repository) != repository || revision == "" || len(paths) == 0 { + return fmt.Errorf("Flow revision projection requires an exact repository, revision, and path set") + } + resolvedRepository, err := filepath.EvalSymlinks(repository) + if err != nil || resolvedRepository != repository { + return fmt.Errorf("Flow revision projection repository is not resolved") + } + seen := map[string]bool{} + for _, relative := range paths { + if relative == "" || filepath.IsAbs(relative) || filepath.ToSlash(filepath.Clean(filepath.FromSlash(relative))) != relative || relative == ".." || strings.HasPrefix(relative, "../") || seen[relative] { + return fmt.Errorf("Flow revision projection contains an invalid path %q", relative) + } + seen[relative] = true + current, readErr := os.ReadFile(filepath.Join(repository, filepath.FromSlash(relative))) + if readErr != nil { + return fmt.Errorf("read active Flow projection %s: %w", relative, readErr) + } + command := exec.CommandContext(ctx, "git", "show", revision+":"+relative) + command.Dir = repository + committed, showErr := command.Output() + if showErr != nil || !bytes.Equal(current, committed) { + return fmt.Errorf("Flow projection %s differs from revision %s", relative, revision) + } + } + return nil +} + type ProjectionWrite struct { Path string Content []byte diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index 3615a6a..0912c93 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -2,6 +2,7 @@ package runtime import ( "bufio" + "context" "os" "os/exec" "path/filepath" @@ -10,6 +11,39 @@ import ( "testing" ) +func TestVerifyFlowProjectionAtRevisionBindsActiveBytesToWorkspaceBase(t *testing.T) { + // control-law: workspace-base-contains-the-active-flow-projection + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{{"init", "-q"}, {"config", "user.name", "Boatstack Tests"}, {"config", "user.email", "boatstack@example.invalid"}} { + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + if output, commandErr := command.CombinedOutput(); commandErr != nil { + t.Fatalf("git %v: %v\n%s", arguments, commandErr, output) + } + } + path := filepath.Join(repository, "generated.txt") + if err := os.WriteFile(path, []byte("committed\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{{"add", "generated.txt"}, {"commit", "-q", "-m", "projection"}} { + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + if output, commandErr := command.CombinedOutput(); commandErr != nil { + t.Fatalf("git %v: %v\n%s", arguments, commandErr, output) + } + } + if err := VerifyFlowProjectionAtRevision(context.Background(), repository, "HEAD", []string{"generated.txt"}); err != nil { + t.Fatalf("exact projection rejected: %v", err) + } + if err := os.WriteFile(path, []byte("regenerated\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := VerifyFlowProjectionAtRevision(context.Background(), repository, "HEAD", []string{"generated.txt"}); err == nil || !strings.Contains(err.Error(), "generated.txt differs") { + t.Fatalf("uncommitted projection result = %v", err) + } +} + func resolvedTemporaryRepository(t *testing.T) string { t.Helper() repository, err := filepath.EvalSymlinks(t.TempDir()) diff --git a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go index a3c723d..64b9c83 100644 --- a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go +++ b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go @@ -28,17 +28,20 @@ var nativeStateHandlerContracts = map[string]nativeStateHandlerContract{ ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeNone}, bindsRequestedObjective: true, }, - "plan-approve": standardNative([]EffectID{"plan.approve"}, ObjectiveScopeBoundExact), - "abandon-delivery": standardNative([]EffectID{"plan.abandon", "publication.abandon"}, ObjectiveScopeBoundExact), - "workspace-cleanup": standardNative([]EffectID{"workspace.cleanup"}, ObjectiveScopeBoundExact), - "workspace-reap": standardNative([]EffectID{"workspace.reap"}, ObjectiveScopeBoundExact), - "workspace-reconcile": standardNative([]EffectID{"workspace.reconcile"}, ObjectiveScopeOptionalPreserve), - "gate-build-record": standardNative([]EffectID{"gate.build.record"}, ObjectiveScopeBoundExact), - "gate-test-record": standardNative([]EffectID{"gate.test.record"}, ObjectiveScopeBoundExact), - "gate-review-record": standardNative([]EffectID{"gate.review.record"}, ObjectiveScopeBoundExact), - "gate-change-record": standardNative([]EffectID{"gate.change.record"}, ObjectiveScopeBoundExact), - "gate-journey-record": standardNative([]EffectID{"gate.journey.record"}, ObjectiveScopeBoundExact), - "visual-evidence-attach": standardNative([]EffectID{"evidence.visual.attach"}, ObjectiveScopeBoundExact), + "plan-approve": standardNative([]EffectID{"plan.approve"}, ObjectiveScopeBoundExact), + "planning-package-admit": standardNative([]EffectID{"planning.package.admit"}, ObjectiveScopeBoundExact), + "planning-package-approve": standardNative([]EffectID{"planning.package.approve"}, ObjectiveScopeBoundExact), + "planning-package-promote": standardNative([]EffectID{"planning.package.promote"}, ObjectiveScopeBoundExact), + "abandon-delivery": standardNative([]EffectID{"plan.abandon", "publication.abandon"}, ObjectiveScopeBoundExact), + "workspace-cleanup": standardNative([]EffectID{"workspace.cleanup"}, ObjectiveScopeBoundExact), + "workspace-reap": standardNative([]EffectID{"workspace.reap"}, ObjectiveScopeBoundExact), + "workspace-reconcile": standardNative([]EffectID{"workspace.reconcile"}, ObjectiveScopeOptionalPreserve), + "gate-build-record": standardNative([]EffectID{"gate.build.record"}, ObjectiveScopeBoundExact), + "gate-test-record": standardNative([]EffectID{"gate.test.record"}, ObjectiveScopeBoundExact), + "gate-review-record": standardNative([]EffectID{"gate.review.record"}, ObjectiveScopeBoundExact), + "gate-change-record": standardNative([]EffectID{"gate.change.record"}, ObjectiveScopeBoundExact), + "gate-journey-record": standardNative([]EffectID{"gate.journey.record"}, ObjectiveScopeBoundExact), + "visual-evidence-attach": standardNative([]EffectID{"evidence.visual.attach"}, ObjectiveScopeBoundExact), "publication-observe": { componentIDs: []string{"boatstack.standard"}, effects: []EffectID{"publication.observe", "publication.reconcile"}, ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeBoundExact, ObjectiveScopeOptionalPreserve}, diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index 3ec7483..ef7a1c0 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "sort" + "strings" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" general "github.com/operatorstack/boatstack/boatstack/kernel" @@ -165,6 +166,35 @@ type ParameterSpec struct { Secret bool `json:"secret"` } +// WorkContract is a bounded foreground-work requirement attached to one +// trusted transition. It can request and verify evidence, but it does not own +// domain effects or define a second transition graph. +type WorkContract struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []WorkInput `json:"inputs,omitempty"` + Outputs []WorkOutput `json:"outputs"` +} + +type WorkInput struct { + ID string `json:"id"` + EntryInput string `json:"entry_input"` +} + +type WorkOutput struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + Required bool `json:"required"` + MaxBytes int64 `json:"max_bytes"` + SchemaPath string `json:"schema_path,omitempty"` + SchemaSHA256 string `json:"schema_sha256,omitempty"` + SchemaContent string `json:"schema_content,omitempty"` +} + // StateEffectKind selects the software-delivery domain's durable-state // interpreter. It is domain ABI data; the general kernel continues to own // transition selection, objective scope, capabilities, and facet ownership. @@ -349,6 +379,7 @@ type Transition struct { ExecutionContext string `json:"execution_context,omitempty"` BindsSourceRevision bool `json:"binds_source_revision,omitempty"` AuthorityFingerprintParameter string `json:"authority_fingerprint_parameter,omitempty"` + Work *WorkContract `json:"work,omitempty"` } func (t Transition) Controllable() bool { return t.Class.Controllable() } @@ -640,6 +671,53 @@ func validateTransition(t Transition) error { if t.Priority < 1 { return fmt.Errorf("%s: priority must be positive", t.ID) } + if err := validateWorkContract(t); err != nil { + return err + } + return nil +} + +func validateWorkContract(t Transition) error { + if t.Work == nil { + return nil + } + work := t.Work + if !t.Controllable() || work.ID == "" || !semanticID.MatchString(work.ID) || len(work.Fingerprint) != 64 || + work.InstructionPath == "" || len(work.InstructionSHA256) != 64 || strings.TrimSpace(work.InstructionContent) == "" || len(work.Outputs) == 0 { + return fmt.Errorf("%s: foreground work contract is incomplete", t.ID) + } + if fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []WorkInput `json:"inputs,omitempty"` + Outputs []WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}); err != nil || fingerprint != work.Fingerprint { + return fmt.Errorf("%s: foreground work contract fingerprint is invalid", t.ID) + } + inputs := map[string]bool{} + for _, input := range work.Inputs { + if !semanticID.MatchString(input.ID) || !semanticID.MatchString(input.EntryInput) || inputs[input.ID] { + return fmt.Errorf("%s: foreground work inputs must be semantic and unique", t.ID) + } + inputs[input.ID] = true + } + outputs := map[string]bool{} + paths := map[string]bool{} + for _, output := range work.Outputs { + if !semanticID.MatchString(output.ID) || output.Path == "" || outputs[output.ID] || paths[output.Path] || output.MaxBytes < 1 || output.MaxBytes > 16<<20 { + return fmt.Errorf("%s: foreground work outputs must be bounded and unique", t.ID) + } + if output.MediaType != "text/markdown" && output.MediaType != "text/plain" && output.MediaType != "application/json" { + return fmt.Errorf("%s: foreground work output %q has unsupported media type", t.ID, output.ID) + } + hasSchema := output.SchemaPath != "" || output.SchemaSHA256 != "" || output.SchemaContent != "" + if hasSchema && (output.SchemaPath == "" || len(output.SchemaSHA256) != 64 || output.SchemaContent == "" || output.MediaType != "application/json") { + return fmt.Errorf("%s: foreground work output %q has invalid schema binding", t.ID, output.ID) + } + outputs[output.ID], paths[output.Path] = true, true + } return nil } @@ -752,6 +830,12 @@ func cloneTransition(value Transition) Transition { value.Interruption.Points = append([]string(nil), value.Interruption.Points...) value.Interruption.PartialState = append([]string(nil), value.Interruption.PartialState...) value.Policy.ManagedOperations = append([]string(nil), value.Policy.ManagedOperations...) + if value.Work != nil { + work := *value.Work + work.Inputs = append([]WorkInput(nil), value.Work.Inputs...) + work.Outputs = append([]WorkOutput(nil), value.Work.Outputs...) + value.Work = &work + } return value } diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index d4a6721..dde8ffc 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -12,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const StateSchemaVersion = 4 +const StateSchemaVersion = 5 type GateEvidence struct { Gate string `json:"gate"` @@ -21,56 +21,57 @@ type GateEvidence struct { } type State struct { - SchemaVersion int `json:"schema_version"` - RepositoryID string `json:"repository_id"` - GitCommonID string `json:"git_common_id"` - WorktreeID string `json:"worktree_id"` - ProgramFingerprint string `json:"program_fingerprint,omitempty"` - Revision uint64 `json:"revision"` - Phase model.ProtocolPhase `json:"phase"` - Engagement model.EngagementState `json:"engagement"` - Delivery model.DeliveryState `json:"delivery"` - Workspace model.WorkspaceState `json:"workspace"` - Plan model.PlanState `json:"plan"` - Configuration model.ConfigurationState `json:"configuration"` - Runtime model.RuntimeState `json:"runtime"` - Publication model.PublicationState `json:"publication"` - Verification model.VerificationState `json:"verification"` - Recovery model.RecoveryState `json:"recovery"` - Transaction model.TransactionState `json:"transaction"` - Terminal model.TerminalStatus `json:"terminal"` - Objective model.Objective `json:"objective"` - SourceRevision string `json:"source_revision,omitempty"` - WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` - ConfigFingerprint string `json:"config_fingerprint,omitempty"` - PlanApprovalPolicy string `json:"plan_approval_policy,omitempty"` - VisualEvidencePolicy string `json:"visual_evidence_policy,omitempty"` - ExternalEffectPolicy string `json:"external_effect_policy,omitempty"` - IndependentReview bool `json:"independent_review_for_high_risk,omitempty"` - EnabledHosts []string `json:"enabled_hosts,omitempty"` - RuntimeVersion string `json:"runtime_version,omitempty"` - RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` - RuntimeSource string `json:"runtime_source_revision,omitempty"` - PlanFingerprint string `json:"plan_fingerprint,omitempty"` - ApprovalFingerprint string `json:"approval_fingerprint,omitempty"` - WorkspaceBranch string `json:"workspace_branch,omitempty"` - WorkspacePath string `json:"workspace_path,omitempty"` - WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"` - WorkspaceSourcePath string `json:"workspace_source_path,omitempty"` - WorkspaceSourceID string `json:"workspace_source_worktree_id,omitempty"` - WorkspaceSourceRef string `json:"workspace_source_ref,omitempty"` - PublicationID string `json:"publication_id,omitempty"` - PublicationURL string `json:"publication_url,omitempty"` - PreviewFingerprint string `json:"preview_fingerprint,omitempty"` - TransactionID string `json:"transaction_id,omitempty"` - TransactionTransition string `json:"transaction_transition,omitempty"` - RecoveryCause string `json:"recovery_cause,omitempty"` - RecoverySourcePhase model.ProtocolPhase `json:"recovery_source_phase,omitempty"` - RecoveryResumption model.ProtocolPhase `json:"recovery_resumption,omitempty"` - RecoveryBudget int `json:"recovery_budget_remaining,omitempty"` - LastTransition catalog.TransitionID `json:"last_transition,omitempty"` - Gates []GateEvidence `json:"gates,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + SchemaVersion int `json:"schema_version"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + WorktreeID string `json:"worktree_id"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + Revision uint64 `json:"revision"` + Phase model.ProtocolPhase `json:"phase"` + Engagement model.EngagementState `json:"engagement"` + Delivery model.DeliveryState `json:"delivery"` + Workspace model.WorkspaceState `json:"workspace"` + Plan model.PlanState `json:"plan"` + Configuration model.ConfigurationState `json:"configuration"` + Runtime model.RuntimeState `json:"runtime"` + Publication model.PublicationState `json:"publication"` + Verification model.VerificationState `json:"verification"` + Recovery model.RecoveryState `json:"recovery"` + Transaction model.TransactionState `json:"transaction"` + Terminal model.TerminalStatus `json:"terminal"` + Objective model.Objective `json:"objective"` + SourceRevision string `json:"source_revision,omitempty"` + WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` + ConfigFingerprint string `json:"config_fingerprint,omitempty"` + PlanApprovalPolicy string `json:"plan_approval_policy,omitempty"` + VisualEvidencePolicy string `json:"visual_evidence_policy,omitempty"` + ExternalEffectPolicy string `json:"external_effect_policy,omitempty"` + IndependentReview bool `json:"independent_review_for_high_risk,omitempty"` + EnabledHosts []string `json:"enabled_hosts,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` + RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` + RuntimeSource string `json:"runtime_source_revision,omitempty"` + PlanFingerprint string `json:"plan_fingerprint,omitempty"` + PlanningPackageFingerprint string `json:"planning_package_fingerprint,omitempty"` + ApprovalFingerprint string `json:"approval_fingerprint,omitempty"` + WorkspaceBranch string `json:"workspace_branch,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` + WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"` + WorkspaceSourcePath string `json:"workspace_source_path,omitempty"` + WorkspaceSourceID string `json:"workspace_source_worktree_id,omitempty"` + WorkspaceSourceRef string `json:"workspace_source_ref,omitempty"` + PublicationID string `json:"publication_id,omitempty"` + PublicationURL string `json:"publication_url,omitempty"` + PreviewFingerprint string `json:"preview_fingerprint,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + TransactionTransition string `json:"transaction_transition,omitempty"` + RecoveryCause string `json:"recovery_cause,omitempty"` + RecoverySourcePhase model.ProtocolPhase `json:"recovery_source_phase,omitempty"` + RecoveryResumption model.ProtocolPhase `json:"recovery_resumption,omitempty"` + RecoveryBudget int `json:"recovery_budget_remaining,omitempty"` + LastTransition catalog.TransitionID `json:"last_transition,omitempty"` + Gates []GateEvidence `json:"gates,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } func Default(invocation model.InvocationContext, now time.Time) State { @@ -103,6 +104,12 @@ func (s State) Validate() error { if s.ProgramFingerprint != "" && len(s.ProgramFingerprint) != 64 { return fmt.Errorf("durable state has invalid program fingerprint") } + if s.PlanningPackageFingerprint != "" && len(s.PlanningPackageFingerprint) != 64 { + return fmt.Errorf("durable state has invalid planning package fingerprint") + } + if s.Plan == model.PlanPackageApproved && s.PlanningPackageFingerprint == "" { + return fmt.Errorf("approved planning package requires an exact package fingerprint") + } if !s.Phase.Valid() || !s.Engagement.Valid() || !s.Delivery.Valid() || !s.Workspace.Valid() || !s.Plan.Valid() || !s.Configuration.Valid() || !s.Runtime.Valid() || !s.Publication.Valid() || !s.Verification.Valid() || !s.Recovery.Valid() || !s.Transaction.Valid() || !s.Terminal.Valid() { diff --git a/boatstack/internal/softwaredelivery/durable/state_facet.go b/boatstack/internal/softwaredelivery/durable/state_facet.go index 5413ed1..466909c 100644 --- a/boatstack/internal/softwaredelivery/durable/state_facet.go +++ b/boatstack/internal/softwaredelivery/durable/state_facet.go @@ -21,7 +21,7 @@ var stateFieldFacets = map[string]model.StateFacet{ "ConfigFingerprint": model.StateFacetControl, "PlanApprovalPolicy": model.StateFacetControl, "VisualEvidencePolicy": model.StateFacetControl, "ExternalEffectPolicy": model.StateFacetControl, "IndependentReview": model.StateFacetControl, "EnabledHosts": model.StateFacetControl, "RuntimeVersion": model.StateFacetInstallation, "RuntimeFingerprint": model.StateFacetInstallation, "RuntimeSource": model.StateFacetInstallation, - "PlanFingerprint": model.StateFacetProduct, "ApprovalFingerprint": model.StateFacetProduct, + "PlanFingerprint": model.StateFacetProduct, "PlanningPackageFingerprint": model.StateFacetProduct, "ApprovalFingerprint": model.StateFacetProduct, "WorkspaceBranch": model.StateFacetProduct, "WorkspacePath": model.StateFacetProduct, "WorkspaceBaseRef": model.StateFacetProduct, "WorkspaceSourcePath": model.StateFacetProduct, "WorkspaceSourceID": model.StateFacetProduct, "WorkspaceSourceRef": model.StateFacetProduct, "PublicationID": model.StateFacetProduct, "PublicationURL": model.StateFacetProduct, "PreviewFingerprint": model.StateFacetProduct, diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 685d066..d464b14 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" @@ -36,12 +37,31 @@ func prepareAttachBinding(layout ports.ControllerLayout, admission protocol.Admi } type approvalArtifact struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - PlanFingerprint string `json:"plan_fingerprint"` - Actor string `json:"actor"` - AdmissionID string `json:"admission_id"` - ApprovedAt time.Time `json:"approved_at"` + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + PlanFingerprint string `json:"plan_fingerprint"` + PackageFingerprint string `json:"package_fingerprint,omitempty"` + Actor string `json:"actor"` + AdmissionID string `json:"admission_id"` + ApprovedAt time.Time `json:"approved_at"` +} + +type planningPackageOutput struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type planningPackageManifest struct { + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + WorkRequestFingerprint string `json:"work_request_fingerprint"` + WorkResultFingerprint string `json:"work_result_fingerprint"` + PlanFingerprint string `json:"plan_fingerprint"` + Outputs []planningPackageOutput `json:"outputs"` + Fingerprint string `json:"fingerprint"` } type gateArtifact struct { @@ -77,14 +97,16 @@ func decodeStrictArtifact(raw []byte, value any) error { } type publicationPreview struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - BaseRef string `json:"base_ref"` - HeadRef string `json:"head_ref"` - BodyPath string `json:"body_path"` - BodySHA256 string `json:"body_sha256"` - Fingerprint string `json:"fingerprint"` - CreatedAt time.Time `json:"created_at"` + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + BaseRef string `json:"base_ref"` + HeadRef string `json:"head_ref"` + SourceRevision string `json:"source_revision"` + WorktreeFingerprint string `json:"worktree_fingerprint"` + BodyPath string `json:"body_path"` + BodySHA256 string `json:"body_sha256"` + Fingerprint string `json:"fingerprint"` + CreatedAt time.Time `json:"created_at"` } func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition, state *durable.State) ([]ports.ResourceMutation, error) { @@ -151,7 +173,112 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } mutations = append(mutations, approvalMutation) state.PlanFingerprint = fingerprint + state.PlanningPackageFingerprint = "" state.ApprovalFingerprint = "" + case "planning.package.admit": + if admission.Work == nil || len(admission.Work.Outputs) == 0 { + return nil, fmt.Errorf("planning package admission requires exact foreground work evidence") + } + packageRoot := filepath.Join(artifactRoot, "planning-packages", deliveryID) + manifest := planningPackageManifest{SchemaVersion: 1, DeliveryID: deliveryID, WorkRequestFingerprint: admission.Work.RequestFingerprint, WorkResultFingerprint: admission.Work.ResultFingerprint} + for _, output := range admission.Work.Outputs { + destination, pathErr := planningPackageOutputPath(packageRoot, output.Path) + if pathErr != nil { + return nil, pathErr + } + mutation, mutationErr := mutationFor(destination, []byte(output.Content), 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, mutation) + manifest.Outputs = append(manifest.Outputs, planningPackageOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: output.SHA256, Size: output.Size}) + if output.ID == "plan" { + manifest.PlanFingerprint = output.SHA256 + } + } + if manifest.PlanFingerprint == "" { + return nil, fmt.Errorf("planning package requires a declared output with id plan") + } + identity := manifest + identity.Fingerprint = "" + identityRaw, encodeErr := encodeJSON(identity) + if encodeErr != nil { + return nil, encodeErr + } + manifest.Fingerprint = sha256Bytes(identityRaw) + manifestRaw, encodeErr := encodeJSON(manifest) + if encodeErr != nil { + return nil, encodeErr + } + manifestMutation, mutationErr := mutationFor(filepath.Join(packageRoot, "manifest.json"), manifestRaw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, manifestMutation) + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, "" + case "planning.package.approve": + manifest, manifestRaw, loadErr := loadPlanningPackageManifest(artifactRoot, deliveryID) + if loadErr != nil { + return nil, loadErr + } + expected, _ := admission.Parameters.Get("package_fingerprint") + if expected == "" || expected != manifest.Fingerprint { + return nil, fmt.Errorf("planning package approval fingerprint is stale") + } + if state.PlanningPackageFingerprint != manifest.Fingerprint { + return nil, fmt.Errorf("planning package state fingerprint is stale") + } + actor := authorityActor(admission) + artifact := approvalArtifact{SchemaVersion: 1, DeliveryID: deliveryID, PlanFingerprint: manifest.PlanFingerprint, PackageFingerprint: manifest.Fingerprint, Actor: actor, AdmissionID: admission.ID, ApprovedAt: admission.IssuedAt.UTC()} + raw, encodeErr := encodeJSON(artifact) + if encodeErr != nil { + return nil, encodeErr + } + mutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "planning-packages", deliveryID, "approval.json"), raw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, mutation) + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, sha256Bytes(append(manifestRaw, raw...)) + case "planning.package.promote": + manifest, _, loadErr := loadPlanningPackageManifest(artifactRoot, deliveryID) + if loadErr != nil { + return nil, loadErr + } + approvalPath := filepath.Join(artifactRoot, "planning-packages", deliveryID, "approval.json") + approvalRaw, readErr := os.ReadFile(approvalPath) + if readErr != nil { + return nil, fmt.Errorf("read planning package approval: %w", readErr) + } + var approval approvalArtifact + if decodeErr := decodeStrictArtifact(approvalRaw, &approval); decodeErr != nil || approval.SchemaVersion != 1 || approval.DeliveryID != deliveryID || approval.PackageFingerprint != manifest.Fingerprint || approval.PlanFingerprint != manifest.PlanFingerprint || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { + return nil, fmt.Errorf("planning package approval does not bind the exact package") + } + planPath := "" + for _, output := range manifest.Outputs { + if output.ID == "plan" { + planPath = output.Path + break + } + } + planArtifact, pathErr := planningPackageOutputPath(filepath.Join(artifactRoot, "planning-packages", deliveryID), planPath) + if pathErr != nil { + return nil, pathErr + } + planRaw, readErr := readRegularWorkspacePlanArtifact(planArtifact) + if readErr != nil || sha256Bytes(planRaw) != manifest.PlanFingerprint { + return nil, fmt.Errorf("planning package plan changed after approval") + } + planMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "plans", deliveryID+".source"), planRaw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + approvalMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), approvalRaw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, planMutation, approvalMutation) + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, sha256Bytes(approvalRaw) case "plan.validate": path := filepath.Join(artifactRoot, "plans", deliveryID+".source") raw, readErr := os.ReadFile(path) @@ -159,6 +286,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, fmt.Errorf("validate source plan %s: %w", path, readErr) } state.PlanFingerprint = sha256Bytes(raw) + state.PlanningPackageFingerprint = "" case "plan.approve", "plan.approve-amendment": fingerprint, _ := admission.Parameters.Get("plan_fingerprint") actor, _ := admission.Parameters.Get("actor") @@ -258,7 +386,14 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if readErr != nil { return nil, readErr } - preview := publicationPreview{SchemaVersion: 1, DeliveryID: deliveryID, BaseRef: baseRef, HeadRef: headRef, BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: admission.IssuedAt.UTC()} + if admission.SourceRevision == "" || admission.WorktreeFingerprint == "" { + return nil, fmt.Errorf("publication preview requires an exact committed source and worktree identity") + } + preview := publicationPreview{ + SchemaVersion: 2, DeliveryID: deliveryID, BaseRef: baseRef, HeadRef: headRef, + SourceRevision: admission.SourceRevision, WorktreeFingerprint: admission.WorktreeFingerprint, + BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: admission.IssuedAt.UTC(), + } identity := preview identity.Fingerprint, identity.CreatedAt = "", time.Time{} identityRaw, encodeErr := json.Marshal(identity) @@ -369,6 +504,7 @@ func readRegularWorkspacePlanArtifact(path string) ([]byte, error) { func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool { switch id { case "plan.create", "plan.amend", "plan.validate", "plan.approve", "plan.approve-amendment", + "planning.package.admit", "planning.package.approve", "planning.package.promote", "evidence.approval.revoke", "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record", "evidence.visual.attach", "publication.preview", "publication.execute", "publication.correct": @@ -378,6 +514,41 @@ func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool { } } +func planningPackageOutputPath(root, relative string) (string, error) { + if relative == "" || filepath.IsAbs(relative) || filepath.Clean(relative) != relative || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("planning package output path is unsafe") + } + return filepath.Join(root, relative), nil +} + +func loadPlanningPackageManifest(artifactRoot, deliveryID string) (planningPackageManifest, []byte, error) { + path := filepath.Join(artifactRoot, "planning-packages", deliveryID, "manifest.json") + raw, err := os.ReadFile(path) + if err != nil { + return planningPackageManifest{}, nil, fmt.Errorf("read planning package manifest: %w", err) + } + var manifest planningPackageManifest + if err := decodeStrictArtifact(raw, &manifest); err != nil || manifest.SchemaVersion != 1 || manifest.DeliveryID != deliveryID || manifest.WorkRequestFingerprint == "" || manifest.WorkResultFingerprint == "" || manifest.PlanFingerprint == "" || len(manifest.Outputs) == 0 || manifest.Fingerprint == "" { + return planningPackageManifest{}, nil, fmt.Errorf("planning package manifest is invalid") + } + identity := manifest + identity.Fingerprint = "" + identityRaw, err := encodeJSON(identity) + if err != nil || sha256Bytes(identityRaw) != manifest.Fingerprint { + return planningPackageManifest{}, nil, fmt.Errorf("planning package manifest fingerprint is invalid") + } + return manifest, raw, nil +} + +func authorityActor(admission protocol.Admission) string { + for _, receipt := range admission.Authority.Receipts { + if receipt.Subject != "" { + return receipt.Subject + } + } + return "authorized-actor" +} + func loadPublicationPreview(path string) (publicationPreview, error) { raw, err := os.ReadFile(path) if err != nil { @@ -387,7 +558,7 @@ func loadPublicationPreview(path string) (publicationPreview, error) { if err := decodeStrictArtifact(raw, &preview); err != nil { return publicationPreview{}, err } - if preview.SchemaVersion != 1 || preview.DeliveryID == "" || preview.BaseRef == "" || preview.HeadRef == "" || preview.BodyPath == "" || preview.BodySHA256 == "" || preview.Fingerprint == "" || preview.CreatedAt.IsZero() { + if preview.SchemaVersion != 2 || preview.DeliveryID == "" || preview.BaseRef == "" || preview.HeadRef == "" || preview.SourceRevision == "" || preview.WorktreeFingerprint == "" || preview.BodyPath == "" || preview.BodySHA256 == "" || preview.Fingerprint == "" || preview.CreatedAt.IsZero() { return publicationPreview{}, fmt.Errorf("invalid publication preview") } if err := protocol.ValidateGitReference(preview.BaseRef); err != nil { @@ -430,6 +601,9 @@ func validatePublicationPreviewForAdmission(layout ports.ControllerLayout, admis if admission.Invocation.Ref != "refs/heads/"+preview.HeadRef { return fmt.Errorf("publication preview head does not match the exact invoking branch") } + if admission.SourceRevision != preview.SourceRevision || admission.WorktreeFingerprint != preview.WorktreeFingerprint { + return fmt.Errorf("publication preview does not match the exact committed HEAD and worktree") + } configRaw, err := os.ReadFile(layout.ConfigPath) if err != nil { return err diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index cbf4b4d..9cc7f6d 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -2,13 +2,16 @@ package effects import ( "context" + "crypto/sha256" "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" "runtime" "strings" + "time" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" @@ -53,11 +56,67 @@ type pullRequestObservation struct { IsCrossRepository bool `json:"isCrossRepository"` } +type githubAuthorityObservation struct { + NameWithOwner string `json:"nameWithOwner"` + URL string `json:"url"` + ViewerPermission string `json:"viewerPermission"` +} + +// ResolveGitHubProviderAuthority derives short-lived provider capability from +// the trusted GitHub CLI boundary. It is capability evidence, not human +// approval; run delegation remains the independent approval source. +func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repository, previewFingerprint string, now time.Time) (protocol.AuthorityReceipt, error) { + if len(previewFingerprint) != 64 || strings.Trim(previewFingerprint, "0123456789abcdef") != "" { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: preview fingerprint must be lowercase SHA-256") + } + output, err := b.runner.CombinedOutput(ctx, repository, "gh", "repo", "view", "--json", "nameWithOwner,viewerPermission,url") + if err != nil { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_UNAVAILABLE: GitHub repository identity or authenticated access is unavailable") + } + var observed githubAuthorityObservation + decoder := json.NewDecoder(strings.NewReader(string(output))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&observed); err != nil { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub authority response is invalid") + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub authority response contains trailing JSON") + } + switch observed.ViewerPermission { + case "ADMIN", "MAINTAIN", "WRITE": + default: + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_DENIED: GitHub identity lacks write permission for the repository") + } + if observed.NameWithOwner == "" || observed.URL == "" { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub repository identity is incomplete") + } + issued := now.UTC() + subject := "github:" + observed.NameWithOwner + digest := sha256.Sum256([]byte(subject + "\x00" + previewFingerprint)) + return protocol.AuthorityReceipt{ + ID: "provider-" + fmt.Sprintf("%x", digest[:8]), Class: catalog.AuthorityProvider, + Subject: subject, Fingerprint: previewFingerprint, IssuedAt: issued, ExpiresAt: issued.Add(2 * time.Minute), + }, nil +} + func (b NativeBoundary) PrepareObservation(ctx context.Context, admission protocol.Admission, transition catalog.Transition, layout ports.ControllerLayout, state *durable.State) error { if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { return err } switch transition.ID { + case "publication.preview": + output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "status", "--porcelain=v1", "-z", "--untracked-files=all") + if err != nil { + return fmt.Errorf("WORKSPACE_COMMIT_REQUIRED: inspect worktree before publication preview") + } + if publicationProductStatus(string(output)) != "" { + return fmt.Errorf("WORKSPACE_COMMIT_REQUIRED: commit the intended delivery changes before publication preview") + } + head, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "rev-parse", "--verify", "HEAD^{commit}") + if err != nil || strings.TrimSpace(string(head)) != admission.SourceRevision { + return fmt.Errorf("WORKSPACE_HEAD_CHANGED: publication preview is not bound to the exact committed HEAD") + } case "publication.observe", "publication.reconcile": publicationID, _ := admission.Parameters.Get("publication_id") if state.PublicationID != "" && state.PublicationID != publicationID { @@ -236,7 +295,8 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if err := validatePublicationPreviewForAdmission(layout, admission, preview); err != nil { return settled, err } - if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "--set-upstream", "origin", preview.HeadRef); err != nil { + refspec := admission.SourceRevision + ":refs/heads/" + preview.HeadRef + if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "origin", refspec); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "create", "--base", preview.BaseRef, "--head", preview.HeadRef, "--fill-first", "--body-file", preview.BodyPath); err != nil { @@ -268,6 +328,43 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio return settled, nil } +func publicationProductStatus(status string) string { + records := strings.Split(status, "\x00") + kept := make([]string, 0, len(records)) + for index := 0; index < len(records); index++ { + record := records[index] + if len(record) < 4 { + continue + } + code, name := record[:2], filepath.ToSlash(record[3:]) + prior := "" + if (code[0] == 'R' || code[0] == 'C' || code[1] == 'R' || code[1] == 'C') && index+1 < len(records) { + index++ + prior = filepath.ToSlash(records[index]) + } + if publicationGeneratedPath(name) && (prior == "" || publicationGeneratedPath(prior)) { + continue + } + kept = append(kept, record) + if prior != "" { + kept = append(kept, prior) + } + } + return strings.Join(kept, "\x00") +} + +func publicationGeneratedPath(name string) bool { + for _, prefix := range []string{ + ".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/planning-packages/", + ".boatstack/plans/", ".boatstack/publication/", + } { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + func (b NativeBoundary) workspaceHasOnlyManagedRuntimePin(ctx context.Context, state durable.State) (bool, error) { output, err := b.runner.CombinedOutput(ctx, state.WorkspacePath, "git", "status", "--porcelain", "--untracked-files=all") if err != nil { diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index c8287a8..50bf1ad 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -22,9 +22,11 @@ type boundaryRunner struct { calls int err error output []byte + outputs [][]byte directory string name string arguments []string + history [][]string } func (r *boundaryRunner) CombinedOutput(_ context.Context, directory, name string, arguments ...string) ([]byte, error) { @@ -32,6 +34,10 @@ func (r *boundaryRunner) CombinedOutput(_ context.Context, directory, name strin r.directory = directory r.name = name r.arguments = append([]string(nil), arguments...) + r.history = append(r.history, append([]string{name}, arguments...)) + if len(r.outputs) >= r.calls { + return append([]byte(nil), r.outputs[r.calls-1]...), r.err + } if r.output != nil { return append([]byte(nil), r.output...), r.err } @@ -57,6 +63,65 @@ func boundaryAdmission(transition catalog.Transition) protocol.Admission { return protocol.Admission{RequiredCapabilities: required, EffectiveCapabilities: required} } +func TestGitHubProviderAuthorityIsDerivedFromWriteCapability(t *testing.T) { + now := time.Unix(100, 0).UTC() + runner := &boundaryRunner{output: []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`)} + boundary, err := NewNativeBoundaryWithRunner(runner) + if err != nil { + t.Fatal(err) + } + fingerprint := strings.Repeat("a", 64) + receipt, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), fingerprint, now) + if err != nil { + t.Fatal(err) + } + if receipt.Class != catalog.AuthorityProvider || receipt.Subject != "github:owner/repository" || receipt.Fingerprint != fingerprint || !receipt.ExpiresAt.Equal(now.Add(2*time.Minute)) { + t.Fatalf("provider receipt = %#v", receipt) + } + if runner.name != "gh" || strings.Join(runner.arguments, " ") != "repo view --json nameWithOwner,viewerPermission,url" { + t.Fatalf("provider observation command = %s %v", runner.name, runner.arguments) + } + renewed, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), fingerprint, now.Add(time.Minute)) + if err != nil || renewed.ID != receipt.ID { + t.Fatalf("provider identity changed across renewal: first=%q renewed=%q err=%v", receipt.ID, renewed.ID, err) + } +} + +func TestGitHubProviderAuthorityRejectsReadOnlyIdentity(t *testing.T) { + runner := &boundaryRunner{output: []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"READ"}`)} + boundary, _ := NewNativeBoundaryWithRunner(runner) + _, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), strings.Repeat("a", 64), time.Unix(100, 0).UTC()) + if err == nil || !strings.Contains(err.Error(), "PROVIDER_AUTHORITY_DENIED") { + t.Fatalf("read-only provider authority error = %v", err) + } +} + +func TestPublicationPreviewRequiresCommittedProductWorktree(t *testing.T) { + transition, _ := testprogram.StandardRegistry().Lookup("publication.preview") + admission := boundaryAdmission(transition) + admission.SourceRevision = "revision" + runner := &boundaryRunner{output: []byte(" M product.go\x00")} + boundary, _ := NewNativeBoundaryWithRunner(runner) + err := boundary.PrepareObservation(context.Background(), admission, transition, writeBoundaryConfig(t, "go test ./..."), &durable.State{}) + if err == nil || !strings.Contains(err.Error(), "WORKSPACE_COMMIT_REQUIRED") || runner.calls != 1 { + t.Fatalf("dirty publication preview error=%v calls=%d", err, runner.calls) + } +} + +func TestPublicationPreviewAcceptsExactCleanCommittedHead(t *testing.T) { + transition, _ := testprogram.StandardRegistry().Lookup("publication.preview") + admission := boundaryAdmission(transition) + admission.SourceRevision = "revision" + runner := &boundaryRunner{outputs: [][]byte{[]byte("?? .boatstack/publication/delivery.md\x00"), []byte("revision\n")}} + boundary, _ := NewNativeBoundaryWithRunner(runner) + if err := boundary.PrepareObservation(context.Background(), admission, transition, writeBoundaryConfig(t, "go test ./..."), &durable.State{}); err != nil { + t.Fatal(err) + } + if runner.calls != 2 { + t.Fatalf("publication preflight calls = %d", runner.calls) + } +} + func TestConfiguredBuildCommandMustPassBeforeGateInstallation(t *testing.T) { runner := &boundaryRunner{err: errors.New("exit status 1")} boundary, err := NewNativeBoundaryWithRunner(runner) @@ -152,7 +217,8 @@ func TestPublicationPreviewRejectsFieldTamperingUnderAnOldFingerprint(t *testing t.Fatal(err) } preview := publicationPreview{ - SchemaVersion: 1, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SchemaVersion: 2, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SourceRevision: "revision", WorktreeFingerprint: "worktree", BodyPath: bodyPath, BodySHA256: sha256Bytes([]byte("reviewed body")), CreatedAt: time.Unix(10, 0).UTC(), } identity := preview @@ -189,7 +255,11 @@ func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { if err := os.WriteFile(bodyPath, body, 0o600); err != nil { t.Fatal(err) } - preview := publicationPreview{SchemaVersion: 1, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: time.Unix(10, 0).UTC()} + preview := publicationPreview{ + SchemaVersion: 2, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SourceRevision: "revision", WorktreeFingerprint: "worktree", + BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: time.Unix(10, 0).UTC(), + } identity := preview identity.CreatedAt = time.Time{} raw, err := json.Marshal(identity) @@ -209,9 +279,8 @@ func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { t.Fatal(err) } admission := protocol.Admission{ - Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, - Objective: model.Objective{DeliveryID: "delivery"}, - Parameters: protocol.Parameters{{Name: "preview_fingerprint", Value: preview.Fingerprint}}, + Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision", WorktreeFingerprint: "worktree", + Objective: model.Objective{DeliveryID: "delivery"}, Parameters: protocol.Parameters{{Name: "preview_fingerprint", Value: preview.Fingerprint}}, } admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) admission.EffectiveCapabilities = admission.RequiredCapabilities @@ -222,6 +291,34 @@ func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { if runner.name != "gh" || strings.Join(runner.arguments, "\x00") != strings.Join(want, "\x00") { t.Fatalf("publication command = %s %q, want gh %q", runner.name, runner.arguments, want) } + if len(runner.history) != 2 || strings.Join(runner.history[0], " ") != "git push origin revision:refs/heads/feature" { + t.Fatalf("publication push history = %v", runner.history) + } +} + +func TestPublicationExecutionRejectsCommittedHeadDrift(t *testing.T) { + layout := writeBoundaryConfig(t, "go test ./...") + bodyPath := filepath.Join(layout.RepositoryRoot, "body.md") + body := []byte("reviewed body") + if err := os.WriteFile(bodyPath, body, 0o600); err != nil { + t.Fatal(err) + } + preview := publicationPreview{ + SchemaVersion: 2, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SourceRevision: "old-revision", WorktreeFingerprint: "worktree", + BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: time.Unix(10, 0).UTC(), + } + identity := preview + identity.CreatedAt = time.Time{} + raw, _ := json.Marshal(identity) + preview.Fingerprint = sha256Bytes(raw) + admission := protocol.Admission{ + Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "new-revision", WorktreeFingerprint: "worktree", + Objective: model.Objective{DeliveryID: "delivery"}, Parameters: protocol.Parameters{{Name: "preview_fingerprint", Value: preview.Fingerprint}}, + } + if err := validatePublicationPreviewForAdmission(layout, admission, preview); err == nil || !strings.Contains(err.Error(), "exact committed HEAD") { + t.Fatalf("committed-head drift error = %v", err) + } } func TestPublicationCorrectionRejectsBodyDriftBeforeProviderCall(t *testing.T) { diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 4d7c576..3e29478 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -282,6 +282,10 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing ID: "external-config-human", Class: catalog.AuthorityHuman, Subject: "integration", Fingerprint: "explicit-human", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), }}} + autonomy := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ + ID: "external-config-autonomy", Class: catalog.AuthorityAutonomy, Subject: "integration", Fingerprint: "explicit-delegation", + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} apply := func(id catalog.TransitionID, authority protocol.AuthorityBundle, repositoryAuthority bool, parameters protocol.Parameters) surfaces.Response { t.Helper() request := surfaces.Request{ @@ -305,7 +309,11 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing if err := os.WriteFile(initialPath, initialConfig, 0o600); err != nil { t.Fatal(err) } - apply("installation.initialize", human, false, protocol.Parameters{ + // A continuation may request repository authority before fresh-state + // initialization. The controller must preserve delegated autonomy without + // fabricating repository authority, then derive repository authority from + // the configuration evidence committed by this transition on later steps. + apply("installation.initialize", autonomy, true, protocol.Parameters{ {Name: "source_revision", Value: "external-config-fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: initialPath}, {Name: "config_sha256", Value: configFingerprint(t, initialConfig)}, }) diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go new file mode 100644 index 0000000..2f0e40e --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -0,0 +1,124 @@ +package effects + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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 TestPlanningPackageAdmitApprovePromoteUsesExactWorkEvidence(t *testing.T) { + // control-law: planning-package promotion requires exact work and approval lineage + repository := t.TempDir() + layout := ports.ControllerLayout{RepositoryRoot: repository} + objective := model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} + now := time.Unix(100, 0).UTC() + plan := "# Approved implementation plan\n" + feature := "# Feature specification\n" + work := &protocol.WorkEvidence{ + RequestFingerprint: strings.Repeat("a", 64), ResultFingerprint: strings.Repeat("b", 64), + Outputs: []protocol.WorkOutputEvidence{ + {ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: sha256Bytes([]byte(plan)), Size: int64(len(plan)), Content: plan}, + {ID: "feature-spec", Path: "feature-spec.md", MediaType: "text/markdown", SHA256: sha256Bytes([]byte(feature)), Size: int64(len(feature)), Content: feature}, + }, + } + state := durable.State{Plan: model.PlanAbsent, Delivery: model.DeliveryUninitialized, Phase: model.PhaseObserved, Terminal: model.TerminalNonterminal} + admit := catalog.Transition{ID: "planning.package.admit", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-admit"}} + admission := protocol.Admission{ID: "adm-admit", Objective: objective, Work: work, IssuedAt: now} + if err := applyStateTransition(&state, admission, admit); err != nil { + t.Fatal(err) + } + mutations, err := prepareArtifacts(layout, admission, admit, &state) + if err != nil { + t.Fatal(err) + } + installFixtureMutations(t, mutations) + manifest, _, err := loadPlanningPackageManifest(filepath.Join(repository, ".boatstack"), "delivery") + if err != nil { + t.Fatal(err) + } + if state.Plan != model.PlanValid || state.PlanFingerprint != sha256Bytes([]byte(plan)) || manifest.WorkResultFingerprint != work.ResultFingerprint { + t.Fatalf("admitted state=%#v manifest=%#v", state, manifest) + } + + approve := catalog.Transition{ID: "planning.package.approve", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-approve"}} + admission = protocol.Admission{ + ID: "adm-approve", Objective: objective, IssuedAt: now.Add(time.Minute), Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: manifest.Fingerprint}}, + Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer"}}}, + } + if err := applyStateTransition(&state, admission, approve); err != nil { + t.Fatal(err) + } + mutations, err = prepareArtifacts(layout, admission, approve, &state) + if err != nil { + t.Fatal(err) + } + installFixtureMutations(t, mutations) + if state.Plan != model.PlanPackageApproved || state.ApprovalFingerprint == "" { + t.Fatalf("approved package state=%#v", state) + } + + promote := catalog.Transition{ID: "planning.package.promote", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-promote"}} + admission = protocol.Admission{ID: "adm-promote", Objective: objective, IssuedAt: now.Add(2 * time.Minute)} + if err := applyStateTransition(&state, admission, promote); err != nil { + t.Fatal(err) + } + mutations, err = prepareArtifacts(layout, admission, promote, &state) + if err != nil { + t.Fatal(err) + } + installFixtureMutations(t, mutations) + canonicalPlan, err := os.ReadFile(filepath.Join(repository, ".boatstack", "plans", "delivery.source")) + if err != nil { + t.Fatal(err) + } + if state.Plan != model.PlanApproved || string(canonicalPlan) != plan { + t.Fatalf("promoted state=%#v plan=%q", state, canonicalPlan) + } +} + +func TestPlanningPackageApprovalRejectsFingerprintDrift(t *testing.T) { + // control-law: approval cannot cross planning-package manifest drift + repository := t.TempDir() + manifestRoot := filepath.Join(repository, ".boatstack", "planning-packages", "delivery") + if err := os.MkdirAll(manifestRoot, 0o700); err != nil { + t.Fatal(err) + } + manifest := planningPackageManifest{SchemaVersion: 1, DeliveryID: "delivery", WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), PlanFingerprint: strings.Repeat("c", 64), Outputs: []planningPackageOutput{{ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: strings.Repeat("c", 64), Size: 1}}} + identityRaw, _ := encodeJSON(manifest) + manifest.Fingerprint = sha256Bytes(identityRaw) + raw, _ := encodeJSON(manifest) + if err := os.WriteFile(filepath.Join(manifestRoot, "manifest.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + state := durable.State{Plan: model.PlanPackageApproved} + _, err := prepareArtifacts(ports.ControllerLayout{RepositoryRoot: repository}, protocol.Admission{ + ID: "adm", Objective: model.Objective{DeliveryID: "delivery"}, Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: strings.Repeat("d", 64)}}, + }, catalog.Transition{ID: "planning.package.approve"}, &state) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("fingerprint drift result = %v", err) + } +} + +func installFixtureMutations(t *testing.T, mutations []ports.ResourceMutation) { + t.Helper() + for _, mutation := range mutations { + if mutation.Delete { + _ = os.Remove(mutation.Path) + continue + } + if err := os.MkdirAll(filepath.Dir(mutation.Path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mutation.Path, mutation.Target, 0o600); err != nil { + t.Fatal(err) + } + } +} diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 61ddcaf..cbdafa9 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -103,36 +103,60 @@ func scanCommittedReceipts(layout ports.ControllerLayout, visit func(journalReco // identity for the current objective. Projected receipt files are deliberately // not used because projection is best effort. func FindLatestCommittedFlowForObjective(layout ports.ControllerLayout, invocation model.InvocationContext, objective model.Objective, maximumRevision uint64) (protocol.TransitionReceipt, bool, error) { + records := []journalRecord{} + if err := scanCommittedReceipts(layout, func(record journalRecord) error { + records = append(records, record) + return nil + }); err != nil { + return protocol.TransitionReceipt{}, false, err + } + return findLatestCommittedFlowForObjective(records, invocation, objective, maximumRevision) +} + +func findLatestCommittedFlowForObjective(records []journalRecord, invocation model.InvocationContext, objective model.Objective, maximumRevision uint64) (protocol.TransitionReceipt, bool, error) { var found protocol.TransitionReceipt - err := scanCommittedReceipts(layout, func(record journalRecord) error { + for _, record := range records { receipt := *record.Receipt - if !sameStateLineage(record.Admission.Invocation, invocation) { - return nil + if !matchesObjectiveBinding(receipt, objective, maximumRevision) { + continue + } + bindingInvocation := record.Admission.Invocation + authorized := sameStateLineage(bindingInvocation, invocation) + if !authorized && bindingInvocation.ControllerID == invocation.ControllerID { + var err error + authorized, err = invocationAuthorizedByRecords(records, receipt.FlowID, bindingInvocation, invocation) + if err != nil { + return protocol.TransitionReceipt{}, false, err + } } - if matchesObjectiveBinding(receipt, objective, maximumRevision) && (found.ID == "" || receipt.ResultingStateRevision > found.ResultingStateRevision) { + if authorized && (found.ID == "" || receipt.ResultingStateRevision > found.ResultingStateRevision) { found = receipt } - return nil - }) - return found, found.ID != "", err + } + return found, found.ID != "", nil } // InvocationAuthorizedByFlow reconstructs worktree lineage only from valid, // committed transition receipts. Mutable delegation records cannot invent a // context transfer. func InvocationAuthorizedByFlow(layout ports.ControllerLayout, flowID string, initial, current model.InvocationContext) (bool, error) { - receipts := []protocol.TransitionReceipt{} + records := []journalRecord{} if err := scanCommittedReceipts(layout, func(record journalRecord) error { - if record.Receipt != nil && record.Receipt.FlowID == flowID { - if err := record.Receipt.Validate(); err != nil { - return err - } - receipts = append(receipts, *record.Receipt) - } + records = append(records, record) return nil }); err != nil { return false, err } + return invocationAuthorizedByRecords(records, flowID, initial, current) +} + +func invocationAuthorizedByRecords(records []journalRecord, flowID string, initial, current model.InvocationContext) (bool, error) { + receipts := []protocol.TransitionReceipt{} + for _, record := range records { + if record.Receipt != nil && record.Receipt.FlowID == flowID { + receipts = append(receipts, *record.Receipt) + } + } sort.Slice(receipts, func(i, j int) bool { return receipts[i].Sequence < receipts[j].Sequence }) contextKey := func(invocation model.InvocationContext) string { return invocation.WorktreeID + "\x00" + invocation.Ref diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index e55e4c9..4c5fec5 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -43,3 +43,42 @@ func TestActiveFlowIdentityRequiresExactWorktreeLineage(t *testing.T) { t.Fatal("different controller lineage was accepted for the current durable state") } } + +func TestActiveFlowIdentityFollowsCommittedWorkspaceTransfer(t *testing.T) { + objective := model.Objective{ID: "objective-product-delivery-run-one", TargetID: "published-pr", TrustedClass: model.ObjectiveOpenPR, DeliveryID: "one"} + source := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "source", Ref: "detached:base", ControllerID: "controller"} + destination := source + destination.WorktreeID = "destination" + destination.Ref = "refs/heads/feature" + + binding := protocol.TransitionReceipt{ + ID: "binding", FlowID: "run-original", Sequence: 1, TransitionID: "objective.bind", + ObjectiveID: objective.ID, TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: objective.DeliveryID, + ResultingStateRevision: 4, + } + cut := protocol.TransitionReceipt{ + ID: "cut", FlowID: "run-original", Sequence: 2, TransitionID: "workspace.cut", + ExecutionContext: "advance", PriorInvocation: &source, ResultingInvocation: &destination, + } + records := []journalRecord{ + {Admission: protocol.Admission{Invocation: source}, Receipt: &binding}, + {Admission: protocol.Admission{Invocation: source}, Receipt: &cut}, + } + + found, ok, err := findLatestCommittedFlowForObjective(records, destination, objective, 16) + if err != nil || !ok || found.FlowID != "run-original" { + t.Fatalf("transferred active Flow identity = %#v, %t, %v", found, ok, err) + } + + unchained := destination + unchained.WorktreeID = "unchained" + if found, ok, err := findLatestCommittedFlowForObjective(records, unchained, objective, 16); err != nil || ok { + t.Fatalf("unchained worktree inherited Flow identity = %#v, %t, %v", found, ok, err) + } + + otherController := destination + otherController.ControllerID = "other-controller" + if found, ok, err := findLatestCommittedFlowForObjective(records, otherController, objective, 16); err != nil || ok { + t.Fatalf("other controller inherited Flow identity = %#v, %t, %v", found, ok, err) + } +} diff --git a/boatstack/internal/softwaredelivery/effects/runtime_store.go b/boatstack/internal/softwaredelivery/effects/runtime_store.go new file mode 100644 index 0000000..3bab78d --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/runtime_store.go @@ -0,0 +1,51 @@ +package effects + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" +) + +type runtimeStore struct{} + +func NewRuntimeStore() ports.RuntimeStore { return runtimeStore{} } + +func (runtimeStore) EnsureDirectory(path string, mode uint32) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("runtime store directory must be canonical and absolute") + } + return os.MkdirAll(path, os.FileMode(mode)) +} + +func (runtimeStore) WriteAtomic(path string, raw []byte, mode uint32) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("runtime store path must be canonical and absolute") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".runtime-record-*.tmp") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(os.FileMode(mode)); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(raw); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, path) +} diff --git a/boatstack/internal/softwaredelivery/effects/runtime_store_test.go b/boatstack/internal/softwaredelivery/effects/runtime_store_test.go new file mode 100644 index 0000000..8c9c6c2 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/runtime_store_test.go @@ -0,0 +1,29 @@ +package effects + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRuntimeStoreOwnsAtomicForegroundRecordMutation(t *testing.T) { + // control-law: runtime records mutate only through the effects-owned atomic store + store := NewRuntimeStore() + path := filepath.Join(t.TempDir(), "work", "record.json") + if err := store.EnsureDirectory(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := store.WriteAtomic(path, []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := store.WriteAtomic(path, []byte("second\n"), 0o600); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil || string(raw) != "second\n" { + t.Fatalf("runtime record = %q err=%v", raw, err) + } + if err := store.WriteAtomic("relative/record.json", []byte("invalid"), 0o600); err == nil { + t.Fatal("relative runtime record path was accepted") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index df72e81..81dec9a 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -85,6 +85,9 @@ var nativeStateHandlers = map[string]nativeStateHandler{ "catalog-reconcile": applyCatalogReconcile, "objective-bind": applyObjectiveBind, "plan-approve": applyPlanApprove, + "planning-package-admit": applyPlanningPackageAdmit, + "planning-package-approve": applyPlanningPackageApprove, + "planning-package-promote": applyPlanningPackagePromote, "abandon-delivery": applyAbandonDelivery, "workspace-cleanup": applyWorkspaceCleanup, "workspace-reap": applyWorkspaceReap, @@ -372,7 +375,7 @@ func resetDeliveryState(state *durable.State) { state.Delivery, state.Plan = model.DeliveryUninitialized, model.PlanAbsent state.Workspace = model.WorkspaceAbsent state.Publication, state.Verification = model.PublicationNone, model.VerificationUnverified - state.PlanFingerprint, state.ApprovalFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "", "" + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "", "", "" state.WorkspaceBranch, state.WorkspacePath, state.WorkspaceBaseRef = "", "", "" state.WorkspaceSourcePath, state.WorkspaceSourceID, state.WorkspaceSourceRef = "", "", "" state.Gates = nil @@ -386,6 +389,24 @@ func applyPlanApprove(state *durable.State, admission protocol.Admission, _ cata return nil } +func applyPlanningPackageAdmit(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { + state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanValid, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal + return nil +} + +func applyPlanningPackageApprove(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { + state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanPackageApproved, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal + return nil +} + +func applyPlanningPackagePromote(state *durable.State, admission protocol.Admission, _ catalog.Transition) error { + state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive + if admission.Objective.TrustedObjectiveClass() == model.ObjectiveApprovedPlan { + establishTerminal(state, model.PhaseTerminal) + } + return nil +} + func applyAbandonDelivery(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { state.Delivery = model.DeliveryDiscarded if state.Workspace != model.WorkspaceAbsent { diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index 6dc1486..459e0b8 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -45,6 +45,7 @@ type ResolveRequest struct { Parameters protocol.Parameters Requested catalog.TransitionID Trace bool + Work *protocol.WorkEvidence } type Resolution struct { @@ -120,6 +121,28 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution decision.Transition = nil } } else { + if decision.Transition.Work != nil { + if request.Work == nil { + decision.Kind = supervisor.DecisionCandidate + decision.Reason = fmt.Sprintf("transition %q requires foreground work %q", decision.Transition.ID, decision.Transition.Work.ID) + decision.Candidates = []catalog.TransitionID{decision.Transition.ID} + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } + if workErr := request.Work.ValidateCurrent(snapshot, *decision.Transition); workErr != nil { + decision.Kind = supervisor.DecisionRefused + decision.Reason = workErr.Error() + decision.Transition = nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } + } else if request.Work != nil { + decision.Kind = supervisor.DecisionRefused + decision.Reason = fmt.Sprintf("transition %q does not accept foreground work evidence", decision.Transition.ID) + decision.Transition = nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } capabilities, capabilityErr := protocol.ProjectCapabilities(snapshot, *decision.Transition, request.Authority, now) if capabilityErr != nil { decision.Kind = supervisor.DecisionRefused @@ -128,7 +151,7 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution updateDecisionTrace(decisionTrace, decision) return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } - prescription, prescriptionErr := protocol.NewPrescription(snapshot, *decision.Transition, capabilities) + prescription, prescriptionErr := protocol.NewPrescriptionWithWork(snapshot, *decision.Transition, capabilities, request.Work) if prescriptionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = prescriptionErr.Error() @@ -136,7 +159,7 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution updateDecisionTrace(decisionTrace, decision) return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } - admission, admissionErr := protocol.NewAdmission(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, now, 2*time.Minute) + admission, admissionErr := protocol.NewAdmissionWithWork(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, request.Work, now, 2*time.Minute) if admissionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = admissionErr.Error() @@ -354,7 +377,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe } transition := *resolution.Decision.Transition now := e.clock.Now() - admission, err := protocol.NewAdmission(resolution.Snapshot, request.Objective, transition, request.Prescription, request.Authority, request.Parameters, now, request.AdmissionLifetime) + admission, err := protocol.NewAdmissionWithWork(resolution.Snapshot, request.Objective, transition, request.Prescription, request.Authority, request.Parameters, request.Work, now, request.AdmissionLifetime) if err != nil { return result, err } @@ -579,6 +602,13 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques if request.Requested != "" && prior.TransitionID != request.Requested { return fmt.Errorf("idempotency receipt belongs to transition %q, not %q", prior.TransitionID, request.Requested) } + workFingerprint := "" + if request.Work != nil { + workFingerprint = request.Work.ResultFingerprint + } + if prior.WorkResultFingerprint != workFingerprint { + return fmt.Errorf("idempotency receipt belongs to a different foreground work result") + } return nil } diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index 5ee2b7b..89dab5b 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -2,6 +2,8 @@ package engine import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "path/filepath" @@ -360,6 +362,96 @@ func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T } } +func mustWorkContextFingerprint(t *testing.T, snapshot model.Snapshot) string { + t.Helper() + fingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) + if err != nil { + t.Fatal(err) + } + return fingerprint +} + +func TestResolutionBindsForegroundWorkBeforeTrustedAdmission(t *testing.T) { + // control-law: foreground-work-produces-evidence-but-cannot-bypass-trusted-admission + now := time.Unix(30, 0).UTC() + transitions := testRegistry(t).All() + var work *catalog.WorkContract + for index := range transitions { + if transitions[index].ID != "test.advance" { + continue + } + instructionDigest := sha256.Sum256([]byte("Inspect the incident.")) + work = &catalog.WorkContract{ID: "diagnose", InstructionPath: "instructions.md", InstructionSHA256: hex.EncodeToString(instructionDigest[:]), InstructionContent: "Inspect the incident.", Outputs: []catalog.WorkOutput{{ID: "diagnosis", Path: "diagnosis.md", MediaType: "text/markdown", Required: true, MaxBytes: 1024}}} + fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []catalog.WorkInput `json:"inputs,omitempty"` + Outputs []catalog.WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}) + if err != nil { + t.Fatal(err) + } + work.Fingerprint = fingerprint + transitions[index].Work = work + transitions[index].OwnedResources = append(transitions[index].OwnedResources, "foreground-work-diagnose") + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + observer := &sequenceObserver{items: []model.Observation{ + observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), + }} + kernel, err := New(registry, syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + req := request(t, now).ResolveRequest + candidate, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if candidate.Decision.Kind != supervisor.DecisionCandidate || candidate.Decision.Transition == nil || candidate.Prescription.ID != "" { + t.Fatalf("workless resolution = %#v", candidate) + } + content := "Cause: overload." + contentDigest := sha256.Sum256([]byte(content)) + evidence, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ + SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: "work-request", RequestFingerprint: strings.Repeat("e", 64), + ContractID: work.ID, ContractFingerprint: work.Fingerprint, TransitionID: "test.advance", + ProgramFingerprint: candidate.Snapshot.ProgramFingerprint, ContextFingerprint: mustWorkContextFingerprint(t, candidate.Snapshot), StateRevision: candidate.Snapshot.StateRevision, + RepositoryID: candidate.Snapshot.Invocation.RepositoryID, WorktreeID: candidate.Snapshot.Invocation.WorktreeID, + Outputs: []protocol.WorkOutputEvidence{{ID: "diagnosis", Path: "diagnosis.md", MediaType: "text/markdown", SHA256: hex.EncodeToString(contentDigest[:]), Size: int64(len(content)), Content: content}}, + }) + if err != nil { + t.Fatal(err) + } + req.Work = &evidence + prescribed, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if prescribed.Decision.Kind != supervisor.DecisionPrescribed || prescribed.Prescription.WorkResultFingerprint != evidence.ResultFingerprint || prescribed.Admission.Work == nil { + t.Fatalf("work-bound resolution = %#v", prescribed) + } + stale := evidence + stale.ContextFingerprint = strings.Repeat("f", 64) + stale, err = protocol.SealWorkEvidence(stale) + if err != nil { + t.Fatal(err) + } + req.Work = &stale + refused, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if refused.Decision.Kind != supervisor.DecisionRefused || refused.Prescription.ID != "" { + t.Fatalf("stale work resolution = %#v", refused) + } +} + func TestResolutionDoesNotPrescribeAnEffectThatDeterministicPreflightRejects(t *testing.T) { // control-law: effect preparation cannot introduce a deterministic apply-only refusal now := time.Unix(30, 0).UTC() diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager.go b/boatstack/internal/softwaredelivery/foregroundwork/manager.go new file mode 100644 index 0000000..d604ef3 --- /dev/null +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager.go @@ -0,0 +1,534 @@ +package foregroundwork + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + general "github.com/operatorstack/boatstack/boatstack/kernel" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +const RecordSchemaVersion = 2 + +type Status string + +const ( + StatusRequested Status = "requested" + StatusInputRequired Status = "input-required" + StatusCompleted Status = "completed" + StatusBlocked Status = "blocked" + StatusInvalidated Status = "invalidated" +) + +type InputBinding struct { + ID string `json:"id"` + EntryInput string `json:"entry_input"` + Value string `json:"value"` + Fingerprint string `json:"fingerprint"` +} + +type Question struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Schema json.RawMessage `json:"schema,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Answer struct { + QuestionID string `json:"question_id"` + Value json.RawMessage `json:"value"` + Fingerprint string `json:"fingerprint"` + AnsweredAt time.Time `json:"answered_at"` +} + +type Event struct { + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint,omitempty"` + At time.Time `json:"at"` +} + +type Request struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + RunID string `json:"run_id"` + ProgramID string `json:"program_id"` + EntryID string `json:"entry_id"` + Objective model.Objective `json:"objective"` + TransitionID catalog.TransitionID `json:"transition_id"` + Contract catalog.WorkContract `json:"contract"` + Inputs []InputBinding `json:"inputs,omitempty"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + WorktreeID string `json:"worktree_id"` + Ref string `json:"ref"` + ProgramFingerprint string `json:"program_fingerprint"` + ContextFingerprint string `json:"context_fingerprint"` + StateRevision uint64 `json:"state_revision"` + InstructionContent string `json:"instruction_content"` + StagingRoot string `json:"staging_root"` + CreatedAt time.Time `json:"created_at"` +} + +type Record struct { + SchemaVersion int `json:"schema_version"` + Revision uint64 `json:"revision"` + Status Status `json:"status"` + Request Request `json:"request"` + Question *Question `json:"question,omitempty"` + Answers []Answer `json:"answers,omitempty"` + Result *protocol.WorkEvidence `json:"result,omitempty"` + BlockReason string `json:"block_reason,omitempty"` + Events []Event `json:"events"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Manager struct { + resolver ports.InvocationResolver + locker ports.Locker + clock ports.Clock + store ports.RuntimeStore +} + +func NewManager(resolver ports.InvocationResolver, locker ports.Locker, clock ports.Clock, store ports.RuntimeStore) (Manager, error) { + if resolver == nil || locker == nil || clock == nil || store == nil { + return Manager{}, fmt.Errorf("foreground work manager requires resolver, locker, clock, and runtime store") + } + return Manager{resolver: resolver, locker: locker, clock: clock, store: store}, nil +} + +func (m Manager) Ensure(ctx context.Context, invocation model.InvocationContext, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]string) (Record, error) { + if transition.Work == nil || runID == "" || programID == "" || entryID == "" { + return Record{}, fmt.Errorf("foreground work request requires run, program, entry, and contract") + } + return m.mutate(ctx, invocation, runID, transition.Work.ID, func(current *Record, layout ports.ControllerLayout) (Record, error) { + request, err := newRequest(m.store, layout, m.clock.Now(), runID, programID, entryID, objective, snapshot, transition, values) + if err != nil { + return Record{}, err + } + if current != nil && current.Request.Fingerprint == request.Fingerprint { + return *current, nil + } + now := m.clock.Now().UTC() + events := []Event{{Kind: "work.requested", Fingerprint: request.Fingerprint, At: now}} + if current != nil { + events = append(append([]Event(nil), current.Events...), Event{Kind: "work.invalidated", Fingerprint: current.Request.Fingerprint, At: now}, events[0]) + } + return Record{SchemaVersion: RecordSchemaVersion, Revision: nextRevision(current), Status: StatusRequested, Request: request, Events: events, UpdatedAt: now}, nil + }) +} + +func (m Manager) Show(ctx context.Context, invocation model.InvocationContext, runID, workID string) (Record, error) { + layout, _, err := m.resolver.ResolveLayout(ctx, invocation) + if err != nil { + return Record{}, err + } + return load(recordPath(layout, runID, workID)) +} + +func (m Manager) InputRequired(ctx context.Context, invocation model.InvocationContext, runID, workID, prompt string, schema json.RawMessage) (Record, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" || len(prompt) > 64<<10 { + return Record{}, fmt.Errorf("foreground work question requires a bounded prompt") + } + if len(schema) != 0 { + if err := validateJSONSchema(schema); err != nil { + return Record{}, fmt.Errorf("foreground work question schema: %w", err) + } + } + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status != StatusRequested && record.Status != StatusInputRequired { + return fmt.Errorf("foreground work question is not admissible from status %q", record.Status) + } + identity, err := general.Fingerprint(struct{ Request, Prompt string }{record.Request.Fingerprint, prompt}) + if err != nil { + return err + } + now := m.clock.Now().UTC() + record.Status, record.Question = StatusInputRequired, &Question{ID: "question-" + identity[:24], Prompt: prompt, Schema: append(json.RawMessage(nil), schema...), CreatedAt: now} + record.Events = append(record.Events, Event{Kind: "work.input-required", Fingerprint: identity, At: now}) + return nil + }) +} + +func (m Manager) Answer(ctx context.Context, invocation model.InvocationContext, runID, workID, questionID string, value json.RawMessage) (Record, error) { + if len(value) == 0 || len(value) > 1<<20 || !json.Valid(value) { + return Record{}, fmt.Errorf("foreground work answer must be bounded JSON") + } + canonical, err := normalizedJSON(value) + if err != nil { + return Record{}, fmt.Errorf("foreground work answer must be bounded JSON: %w", err) + } + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status != StatusInputRequired || record.Question == nil || record.Question.ID != questionID { + return fmt.Errorf("foreground work answer does not match the current question") + } + if len(record.Question.Schema) != 0 { + if err := validateJSON(record.Question.Schema, canonical); err != nil { + return fmt.Errorf("foreground work answer: %w", err) + } + } + fingerprint := digest(canonical) + now := m.clock.Now().UTC() + record.Answers = append(record.Answers, Answer{QuestionID: questionID, Value: canonical, Fingerprint: fingerprint, AnsweredAt: now}) + record.Status, record.Question = StatusRequested, nil + record.Events = append(record.Events, Event{Kind: "work.answered", Fingerprint: fingerprint, At: now}) + return nil + }) +} + +func (m Manager) Complete(ctx context.Context, invocation model.InvocationContext, runID, workID string) (Record, error) { + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status != StatusRequested { + return fmt.Errorf("foreground work completion is not admissible from status %q", record.Status) + } + outputs, err := verifyOutputs(record.Request) + if err != nil { + return err + } + evidence, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ + SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: record.Request.ID, RequestFingerprint: record.Request.Fingerprint, + ContractID: record.Request.Contract.ID, ContractFingerprint: record.Request.Contract.Fingerprint, TransitionID: record.Request.TransitionID, + ProgramFingerprint: record.Request.ProgramFingerprint, ContextFingerprint: record.Request.ContextFingerprint, StateRevision: record.Request.StateRevision, + RepositoryID: record.Request.RepositoryID, WorktreeID: record.Request.WorktreeID, Outputs: outputs, + }) + if err != nil { + return err + } + now := m.clock.Now().UTC() + record.Status, record.Result = StatusCompleted, &evidence + record.Events = append(record.Events, Event{Kind: "work.completed", Fingerprint: evidence.ResultFingerprint, At: now}) + return nil + }) +} + +func (m Manager) Block(ctx context.Context, invocation model.InvocationContext, runID, workID, reason string) (Record, error) { + reason = strings.TrimSpace(reason) + if reason == "" || len(reason) > 64<<10 { + return Record{}, fmt.Errorf("foreground work blocker requires a bounded reason") + } + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status == StatusCompleted || record.Status == StatusInvalidated { + return fmt.Errorf("foreground work blocker is not admissible from status %q", record.Status) + } + now := m.clock.Now().UTC() + record.Status, record.BlockReason, record.Question = StatusBlocked, reason, nil + record.Events = append(record.Events, Event{Kind: "work.blocked", Fingerprint: digest([]byte(reason)), At: now}) + return nil + }) +} + +func (m Manager) update(ctx context.Context, invocation model.InvocationContext, runID, workID string, change func(*Record) error) (Record, error) { + return m.mutate(ctx, invocation, runID, workID, func(current *Record, _ ports.ControllerLayout) (Record, error) { + if current == nil { + return Record{}, fmt.Errorf("foreground work request does not exist") + } + result := *current + result.Answers, result.Events = append([]Answer(nil), current.Answers...), append([]Event(nil), current.Events...) + if current.Question != nil { + question := *current.Question + question.Schema = append(json.RawMessage(nil), current.Question.Schema...) + result.Question = &question + } + if err := change(&result); err != nil { + return Record{}, err + } + result.Revision, result.UpdatedAt = current.Revision+1, m.clock.Now().UTC() + return result, nil + }) +} + +func (m Manager) mutate(ctx context.Context, invocation model.InvocationContext, runID, workID string, change func(*Record, ports.ControllerLayout) (Record, error)) (Record, error) { + if !segment(runID) || !segment(workID) { + return Record{}, fmt.Errorf("foreground work requires semantic run and work identities") + } + lockName := "foreground-work-" + workID + lock, err := m.locker.Acquire(ctx, invocation, []string{lockName}) + if err != nil { + return Record{}, err + } + defer lock.Release() + layout, _, err := m.resolver.ResolveLayout(ctx, invocation) + if err != nil { + return Record{}, err + } + path := recordPath(layout, runID, workID) + current, err := load(path) + if err != nil && !os.IsNotExist(err) { + return Record{}, err + } + var prior *Record + if err == nil { + prior = ¤t + } + next, err := change(prior, layout) + if err != nil { + return Record{}, err + } + if err := save(m.store, path, next); err != nil { + return Record{}, err + } + return next, nil +} + +func newRequest(store ports.RuntimeStore, layout ports.ControllerLayout, now time.Time, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]string) (Request, error) { + work := *transition.Work + bindings := make([]InputBinding, 0, len(work.Inputs)) + for _, input := range work.Inputs { + value, ok := values[input.EntryInput] + if !ok || strings.TrimSpace(value) == "" { + return Request{}, fmt.Errorf("foreground work input %q is not bound by entry input %q", input.ID, input.EntryInput) + } + bindings = append(bindings, InputBinding{ID: input.ID, EntryInput: input.EntryInput, Value: value, Fingerprint: digest([]byte(value))}) + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) + staging := filepath.Join(layout.FlowRoot, "work", runID, work.ID, "staging") + contextFingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) + if err != nil { + return Request{}, fmt.Errorf("foreground work context fingerprint: %w", err) + } + request := Request{ + RunID: runID, ProgramID: programID, EntryID: entryID, Objective: objective, TransitionID: transition.ID, Contract: work, Inputs: bindings, + RepositoryID: snapshot.Invocation.RepositoryID, GitCommonID: snapshot.Invocation.GitCommonID, WorktreeID: snapshot.Invocation.WorktreeID, Ref: snapshot.Invocation.Ref, + ProgramFingerprint: snapshot.ProgramFingerprint, ContextFingerprint: contextFingerprint, StateRevision: snapshot.StateRevision, + InstructionContent: work.InstructionContent, StagingRoot: staging, CreatedAt: now.UTC(), + } + identity := request + identity.ID, identity.Fingerprint, identity.CreatedAt = "", "", time.Time{} + fingerprint, err := general.Fingerprint(identity) + if err != nil { + return Request{}, err + } + request.Fingerprint, request.ID = fingerprint, "work-"+fingerprint[:24] + if err := store.EnsureDirectory(staging, 0o700); err != nil { + return Request{}, err + } + return request, nil +} + +func verifyOutputs(request Request) ([]protocol.WorkOutputEvidence, error) { + var result []protocol.WorkOutputEvidence + for _, output := range request.Contract.Outputs { + path, err := safeStagingFile(request.StagingRoot, output.Path) + if os.IsNotExist(err) && !output.Required { + continue + } + if err != nil { + return nil, fmt.Errorf("foreground work output %q: %w", output.ID, err) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("foreground work output %q: %w", output.ID, err) + } + if int64(len(raw)) > output.MaxBytes || !utf8.Valid(raw) { + return nil, fmt.Errorf("foreground work output %q exceeds its bound or is not UTF-8", output.ID) + } + if output.MediaType == "application/json" { + if !json.Valid(raw) { + return nil, fmt.Errorf("foreground work output %q is not JSON", output.ID) + } + if output.SchemaContent != "" { + if err := validateJSON([]byte(output.SchemaContent), raw); err != nil { + return nil, fmt.Errorf("foreground work output %q: %w", output.ID, err) + } + } + } + result = append(result, protocol.WorkOutputEvidence{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: digest(raw), Size: int64(len(raw)), Content: string(raw)}) + } + return protocol.CanonicalWorkOutputs(result), nil +} + +func validateJSONSchema(raw []byte) error { + var document any + if err := json.Unmarshal(raw, &document); err != nil { + return err + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource("schema.json", document); err != nil { + return err + } + _, err := compiler.Compile("schema.json") + return err +} + +func validateJSON(schemaRaw, valueRaw []byte) error { + var document any + if err := json.Unmarshal(schemaRaw, &document); err != nil { + return err + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource("schema.json", document); err != nil { + return err + } + schema, err := compiler.Compile("schema.json") + if err != nil { + return err + } + var value any + if err := json.Unmarshal(valueRaw, &value); err != nil { + return err + } + return schema.Validate(value) +} + +func safeStagingFile(root, relative string) (string, error) { + if relative == "" || filepath.IsAbs(relative) || filepath.Clean(relative) != relative || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe output path") + } + root, err := filepath.Abs(root) + if err != nil { + return "", err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + path := filepath.Join(root, relative) + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("output is not a regular file") + } + parent, err := filepath.EvalSymlinks(filepath.Dir(path)) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, filepath.Join(parent, filepath.Base(path))) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("output escapes staging root") + } + return filepath.Join(parent, filepath.Base(path)), nil +} + +func recordPath(layout ports.ControllerLayout, runID, workID string) string { + return filepath.Join(layout.FlowRoot, "work", runID, workID, "record.json") +} + +func load(path string) (Record, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Record{}, err + } + var record Record + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&record); err != nil { + return Record{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return Record{}, fmt.Errorf("foreground work record has trailing input") + } + if record.SchemaVersion != RecordSchemaVersion || record.Revision == 0 || record.Request.Fingerprint == "" || record.UpdatedAt.IsZero() { + return Record{}, fmt.Errorf("foreground work record has invalid identity") + } + if !validStatus(record) { + return Record{}, fmt.Errorf("foreground work record has invalid status state") + } + identity := record.Request + wantID, wantFingerprint := identity.ID, identity.Fingerprint + identity.ID, identity.Fingerprint, identity.CreatedAt = "", "", time.Time{} + fingerprint, err := general.Fingerprint(identity) + if err != nil || fingerprint != wantFingerprint || wantID != "work-"+fingerprint[:24] { + return Record{}, fmt.Errorf("foreground work request fingerprint is invalid") + } + if record.Result != nil { + if err := record.Result.Validate(); err != nil || record.Result.RequestFingerprint != record.Request.Fingerprint { + return Record{}, fmt.Errorf("foreground work result is invalid: %v", err) + } + } + for _, answer := range record.Answers { + canonical, err := normalizedJSON(answer.Value) + if !segment(answer.QuestionID) || err != nil || answer.Fingerprint != digest(canonical) || answer.AnsweredAt.IsZero() { + return Record{}, fmt.Errorf("foreground work answer evidence is invalid") + } + } + for _, event := range record.Events { + if !validEventKind(event.Kind) || event.At.IsZero() { + return Record{}, fmt.Errorf("foreground work event is invalid") + } + } + return record, nil +} + +func normalizedJSON(raw []byte) (json.RawMessage, error) { + var normalized bytes.Buffer + if err := json.Compact(&normalized, raw); err != nil { + return nil, err + } + return json.RawMessage(append([]byte(nil), normalized.Bytes()...)), nil +} + +func validStatus(record Record) bool { + switch record.Status { + case StatusRequested, StatusInvalidated: + return record.Question == nil && record.Result == nil && record.BlockReason == "" + case StatusInputRequired: + return record.Question != nil && segment(record.Question.ID) && strings.TrimSpace(record.Question.Prompt) != "" && record.Result == nil && record.BlockReason == "" && record.Question.CreatedAt.IsZero() == false + case StatusCompleted: + return record.Question == nil && record.Result != nil && record.BlockReason == "" + case StatusBlocked: + return record.Question == nil && record.Result == nil && strings.TrimSpace(record.BlockReason) != "" + default: + return false + } +} + +func validEventKind(kind string) bool { + switch kind { + case "work.requested", "work.invalidated", "work.input-required", "work.answered", "work.completed", "work.blocked": + return true + default: + return false + } +} + +func save(store ports.RuntimeStore, path string, record Record) error { + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + return store.WriteAtomic(path, raw, 0o600) +} + +func nextRevision(current *Record) uint64 { + if current == nil { + return 1 + } + return current.Revision + 1 +} + +func digest(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} + +func segment(value string) bool { + if value == "" || value == "." || value == ".." || strings.ContainsAny(value, `/\\`) { + return false + } + for _, char := range value { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' && char != '.' { + return false + } + } + return true +} diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go new file mode 100644 index 0000000..9953da0 --- /dev/null +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go @@ -0,0 +1,233 @@ +package foregroundwork_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + general "github.com/operatorstack/boatstack/boatstack/kernel" +) + +type resolver struct{ layout ports.ControllerLayout } + +func (r resolver) ResolveInvocation(context.Context, string, string, string) (model.InvocationContext, error) { + return invocation(), nil +} +func (r resolver) ResolveLayout(context.Context, model.InvocationContext) (ports.ControllerLayout, model.InvocationContext, error) { + return r.layout, invocation(), nil +} + +type clock struct{ at time.Time } + +func (c clock) Now() time.Time { return c.at } + +func invocation() model.InvocationContext { + return model.InvocationContext{RepositoryID: "repository", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main"} +} + +func fixture(t *testing.T) (foregroundwork.Manager, model.Snapshot, catalog.Transition, string) { + t.Helper() + root := t.TempDir() + resolved := resolver{layout: ports.ControllerLayout{FlowRoot: filepath.Join(root, "flow"), LockRoot: filepath.Join(root, "locks")}} + locker, err := effects.NewLocker(resolved) + if err != nil { + t.Fatal(err) + } + manager, err := foregroundwork.NewManager(resolved, locker, clock{at: time.Unix(100, 0).UTC()}, effects.NewRuntimeStore()) + if err != nil { + t.Fatal(err) + } + outputs := []catalog.WorkOutput{{ + ID: "diagnosis", Path: "diagnosis.json", MediaType: "application/json", Required: true, MaxBytes: 1024, + SchemaPath: "schema.json", SchemaSHA256: strings.Repeat("b", 64), + SchemaContent: `{"type":"object","properties":{"cause":{"type":"string"}},"required":["cause"],"additionalProperties":false}`, + }} + work := &catalog.WorkContract{ID: "diagnose", InstructionPath: "instructions.md", InstructionSHA256: strings.Repeat("a", 64), InstructionContent: "Diagnose.", Inputs: []catalog.WorkInput{{ID: "incident", EntryInput: "incident"}}, Outputs: outputs} + fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []catalog.WorkInput `json:"inputs,omitempty"` + Outputs []catalog.WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}) + if err != nil { + t.Fatal(err) + } + work.Fingerprint = fingerprint + transition := catalog.Transition{ID: "incident.diagnose", Work: work} + snapshot := model.Snapshot{Observation: model.Observation{ProgramFingerprint: strings.Repeat("c", 64), StateRevision: 4, Invocation: invocation()}, Fingerprint: strings.Repeat("d", 64)} + return manager, snapshot, transition, root +} + +func TestForegroundWorkQuestionCompletionAndDrift(t *testing.T) { + // control-law: questions suspend one work request and drift invalidates its evidence + manager, snapshot, transition, _ := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + if err != nil { + t.Fatal(err) + } + if record.Status != foregroundwork.StatusRequested || record.Request.InstructionContent != "Diagnose." { + t.Fatalf("request = %#v", record) + } + record, err = manager.InputRequired(ctx, invocation(), "run-1", "diagnose", "Which service?", []byte(`{"type":"string","minLength":1}`)) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Answer(ctx, invocation(), "run-1", "diagnose", record.Question.ID, []byte(`""`)); err == nil { + t.Fatal("schema-invalid answer was accepted") + } + record, err = manager.Answer(ctx, invocation(), "run-1", "diagnose", record.Question.ID, []byte(" \"api\"\n")) + if err != nil || record.Status != foregroundwork.StatusRequested { + t.Fatalf("answer = %#v err=%v", record, err) + } + record, err = manager.Show(ctx, invocation(), "run-1", "diagnose") + if err != nil || string(record.Answers[0].Value) != `"api"` { + t.Fatalf("persisted canonical answer = %#v err=%v", record.Answers, err) + } + if err := os.WriteFile(filepath.Join(record.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":"overload"}`), 0o600); err != nil { + t.Fatal(err) + } + record, err = manager.Complete(ctx, invocation(), "run-1", "diagnose") + if err != nil || record.Result == nil || record.Result.Outputs[0].Content != `{"cause":"overload"}` { + t.Fatalf("completion = %#v err=%v", record, err) + } + snapshot.StateRevision++ + record, err = manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + if err != nil || record.Status != foregroundwork.StatusRequested || len(record.Events) < 4 || record.Events[len(record.Events)-2].Kind != "work.invalidated" { + t.Fatalf("drift reset = %#v err=%v", record, err) + } +} + +func TestForegroundWorkSurvivesInvocationLocalRestartIdentity(t *testing.T) { + // control-law: restart-local driver identity cannot invalidate otherwise-current work + manager, snapshot, transition, _ := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + first, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + if err != nil { + t.Fatal(err) + } + restarted := snapshot + restarted.Invocation.ControllerID = "controller-after-restart" + restarted.Invocation.Host = "claude" + restarted.Invocation.Correlation = "correlation-after-restart" + restarted.Invocation.RuntimePath = "/different/immutable/runtime/location" + restarted.Fingerprint = strings.Repeat("e", 64) + second, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, map[string]string{"incident": "incident.json"}) + if err != nil { + t.Fatal(err) + } + if second.Request.Fingerprint != first.Request.Fingerprint || second.Revision != first.Revision || len(second.Events) != len(first.Events) { + t.Fatalf("restart invalidated stable work: before=%#v after=%#v", first.Request, second.Request) + } + restarted.Invocation.Ref = "refs/heads/other" + third, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, map[string]string{"incident": "incident.json"}) + if err != nil { + t.Fatal(err) + } + if third.Request.Fingerprint == first.Request.Fingerprint || third.Events[len(third.Events)-2].Kind != "work.invalidated" { + t.Fatalf("repository context drift did not invalidate work: %#v", third) + } +} + +func TestForegroundWorkRejectsMissingInvalidAndEscapingOutputs(t *testing.T) { + // control-law: only declared bounded regular staged outputs become work evidence + manager, snapshot, transition, root := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("missing required output was accepted") + } + if err := os.WriteFile(filepath.Join(record.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":1}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("schema-invalid output was accepted") + } + outside := filepath.Join(root, "outside.json") + if err := os.WriteFile(outside, []byte(`{"cause":"outside"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(record.Request.StagingRoot, "diagnosis.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(record.Request.StagingRoot, "diagnosis.json")); err != nil { + t.Fatal(err) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("symlink output was accepted") + } +} + +func TestForegroundWorkConcurrentCompletionIsSerialized(t *testing.T) { + // control-law: one work request has at most one successful completion mutation + manager, snapshot, transition, _ := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(record.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":"overload"}`), 0o600); err != nil { + t.Fatal(err) + } + var wait sync.WaitGroup + errors := make(chan error, 2) + for i := 0; i < 2; i++ { + wait.Add(1) + go func() { + defer wait.Done() + _, completeErr := manager.Complete(ctx, invocation(), "run-1", "diagnose") + errors <- completeErr + }() + } + wait.Wait() + close(errors) + successes := 0 + for err := range errors { + if err == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("concurrent completions succeeded %d times", successes) + } +} + +func TestForegroundWorkRejectsTamperedRuntimeRecord(t *testing.T) { + // control-law: runtime work state must preserve typed status and event evidence + manager, snapshot, transition, root := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + if _, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "flow", "work", "run-1", "diagnose", "record.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + tampered := strings.Replace(string(raw), `"status": "requested"`, `"status": "completed"`, 1) + if err := os.WriteFile(path, []byte(tampered), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.Show(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("tampered status was accepted") + } +} diff --git a/boatstack/internal/softwaredelivery/model/state.go b/boatstack/internal/softwaredelivery/model/state.go index 19ab993..e41b308 100644 --- a/boatstack/internal/softwaredelivery/model/state.go +++ b/boatstack/internal/softwaredelivery/model/state.go @@ -11,6 +11,8 @@ import ( const SnapshotSchemaVersion = 5 +const foregroundWorkContextIdentity = "foreground-work-context" + type ProtocolPhase string const ( @@ -130,6 +132,7 @@ const ( PlanAbsent PlanState = "absent" PlanDraft PlanState = "draft" PlanValid PlanState = "valid" + PlanPackageApproved PlanState = "package-approved" PlanApproved PlanState = "approved" PlanLocked PlanState = "locked" PlanStale PlanState = "stale" @@ -139,7 +142,7 @@ const ( func (s PlanState) Valid() bool { switch s { - case PlanAbsent, PlanDraft, PlanValid, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: + case PlanAbsent, PlanDraft, PlanValid, PlanPackageApproved, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: return true default: return false @@ -590,6 +593,29 @@ func Canonicalize(observation Observation) (Snapshot, error) { } } snapshot := Snapshot{Observation: observation} + fingerprint, err := observationFingerprint(observation) + if err != nil { + return Snapshot{}, err + } + snapshot.Fingerprint = fingerprint + return snapshot, nil +} + +// ForegroundWorkContextFingerprint binds long-running work to the durable and +// admission-relevant observation while excluding invocation-local driver IDs. +// A restart may change controller, host, correlation, or the executable's +// installation path without changing the repository, runtime, program, or +// control state that the work was authorized to inspect. +func ForegroundWorkContextFingerprint(snapshot Snapshot) (string, error) { + projection := snapshot.Observation + projection.Invocation.ControllerID = foregroundWorkContextIdentity + projection.Invocation.Host = foregroundWorkContextIdentity + projection.Invocation.Correlation = foregroundWorkContextIdentity + projection.Invocation.RuntimePath = "" + return observationFingerprint(projection) +} + +func observationFingerprint(observation Observation) (string, error) { projection := observation projection.ObservedAt = time.Time{} zeroEvidenceTimes(&projection.Program) @@ -619,11 +645,10 @@ func Canonicalize(observation Observation) (Snapshot, error) { } raw, err := json.Marshal(Snapshot{Observation: projection}) if err != nil { - return Snapshot{}, fmt.Errorf("snapshot: canonical encoding: %w", err) + return "", fmt.Errorf("snapshot: canonical encoding: %w", err) } digest := sha256.Sum256(raw) - snapshot.Fingerprint = hex.EncodeToString(digest[:]) - return snapshot, nil + return hex.EncodeToString(digest[:]), nil } func zeroEvidenceTimes[T any](fact *Fact[T]) { diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 0f5541e..fd15c19 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -251,6 +251,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) verificationEvidence := append(append([]model.Evidence(nil), deliveryEvidence...), artifactEvidence...) planEvidence = append(append([]model.Evidence(nil), stateEvidence...), planEvidence...) terminalEvidence := append(append([]model.Evidence(nil), stateEvidence...), artifactEvidence...) + publication := currentPublicationState(layout, state, head, worktreeFingerprint) configurationEvidence := stateEvidence if configEvidence.Source != "" { configurationEvidence = append(append([]model.Evidence(nil), stateEvidence...), configEvidence) @@ -269,7 +270,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) Configuration: model.Fact[model.ConfigurationState]{Status: model.FactKnown, Value: configuration, Evidence: configurationEvidence}, ConfigurationPolicy: configurationPolicy, Runtime: model.Fact[model.RuntimeState]{Status: model.FactKnown, Value: runtimeState, Evidence: runtimeEvidence}, - Publication: model.Fact[model.PublicationState]{Status: model.FactKnown, Value: state.Publication, Evidence: stateEvidence}, + Publication: model.Fact[model.PublicationState]{Status: model.FactKnown, Value: publication, Evidence: stateEvidence}, Verification: model.Fact[model.VerificationState]{Status: model.FactKnown, Value: verification, Evidence: verificationEvidence}, Recovery: recoveryFact, Transaction: transactionFact, @@ -281,6 +282,38 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) }, nil } +type observedPublicationPreview struct { + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + SourceRevision string `json:"source_revision"` + WorktreeFingerprint string `json:"worktree_fingerprint"` +} + +func currentPublicationState(layout ports.ControllerLayout, state durable.State, head, worktreeFingerprint string) model.PublicationState { + if state.Publication != model.PublicationCandidate { + return state.Publication + } + deliveryID := state.Objective.DeliveryID + if deliveryID == "" || filepath.Base(deliveryID) != deliveryID || deliveryID == "." || deliveryID == ".." { + return model.PublicationNone + } + raw, err := os.ReadFile(filepath.Join(layout.RepositoryRoot, ".boatstack", "publication", deliveryID+".preview.json")) + if err != nil { + return model.PublicationNone + } + var preview observedPublicationPreview + decoder := json.NewDecoder(bytes.NewReader(raw)) + if decoder.Decode(&preview) != nil || preview.SchemaVersion != 2 || preview.DeliveryID != deliveryID || + preview.SourceRevision != head || preview.WorktreeFingerprint != worktreeFingerprint { + return model.PublicationNone + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return model.PublicationNone + } + return state.Publication +} + func (o Observer) highRiskChange(ctx context.Context, repository, defaultBranch string, patterns []string) (bool, error) { if len(patterns) == 0 { return false, nil @@ -429,7 +462,7 @@ func canonicalProductStatus(status string) string { func generatedBoatstackPath(name string) bool { name = strings.TrimPrefix(filepath.ToSlash(name), "./") - for _, prefix := range []string{".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/plans/", ".boatstack/publication/"} { + for _, prefix := range []string{".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/planning-packages/", ".boatstack/plans/", ".boatstack/publication/"} { if strings.HasPrefix(name, prefix) { return true } @@ -460,12 +493,31 @@ func sameConfigurationPolicy(one, two model.ConfigurationPolicy) bool { } type observedApproval struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - PlanFingerprint string `json:"plan_fingerprint"` - Actor string `json:"actor"` - AdmissionID string `json:"admission_id"` - ApprovedAt time.Time `json:"approved_at"` + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + PlanFingerprint string `json:"plan_fingerprint"` + PackageFingerprint string `json:"package_fingerprint,omitempty"` + Actor string `json:"actor"` + AdmissionID string `json:"admission_id"` + ApprovedAt time.Time `json:"approved_at"` +} + +type observedPlanningPackageOutput struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type observedPlanningPackageManifest struct { + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + WorkRequestFingerprint string `json:"work_request_fingerprint"` + WorkResultFingerprint string `json:"work_result_fingerprint"` + PlanFingerprint string `json:"plan_fingerprint"` + Outputs []observedPlanningPackageOutput `json:"outputs"` + Fingerprint string `json:"fingerprint"` } type observedGate struct { @@ -507,7 +559,17 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta return plan, verification, terminal, planEvidence, verificationEvidence, nil } deliveryID := state.Objective.DeliveryID - if state.Plan != model.PlanAbsent { + packagePlan := (state.Plan == model.PlanValid && state.PlanningPackageFingerprint != "") || state.Plan == model.PlanPackageApproved + if packagePlan { + evidence, valid, err := observePlanningPackage(layout, state, now) + if err != nil { + return plan, verification, terminal, nil, nil, err + } + planEvidence = append(planEvidence, evidence...) + if !valid { + plan, terminal = model.PlanStale, model.TerminalStale + } + } else if state.Plan != model.PlanAbsent { path := filepath.Join(layout.RepositoryRoot, ".boatstack", "plans", deliveryID+".source") evidence, fingerprint, exists, err := fileEvidence(path, "plan", now) if err != nil { @@ -534,6 +596,7 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta var approval observedApproval valid = valid && decodeStrictJSON(raw, &approval) == nil && approval.SchemaVersion == 1 && approval.DeliveryID == deliveryID && approval.PlanFingerprint == state.PlanFingerprint && + approval.PackageFingerprint == state.PlanningPackageFingerprint && approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero() } if !valid { @@ -607,6 +670,76 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta return plan, verification, terminal, planEvidence, verificationEvidence, nil } +func observePlanningPackage(layout ports.ControllerLayout, state durable.State, now time.Time) ([]model.Evidence, bool, error) { + root := filepath.Join(layout.RepositoryRoot, ".boatstack", "planning-packages", state.Objective.DeliveryID) + manifestPath := filepath.Join(root, "manifest.json") + manifestEvidence, manifestFileFingerprint, exists, err := fileEvidence(manifestPath, "planning-package", now) + evidence := []model.Evidence{manifestEvidence} + if err != nil || !exists { + return evidence, false, err + } + manifestRaw, err := os.ReadFile(manifestPath) + if err != nil { + return evidence, false, err + } + var manifest observedPlanningPackageManifest + valid := decodeStrictJSON(manifestRaw, &manifest) == nil && manifest.SchemaVersion == 1 && + manifest.DeliveryID == state.Objective.DeliveryID && len(manifest.WorkRequestFingerprint) == 64 && len(manifest.WorkResultFingerprint) == 64 && + manifest.PlanFingerprint == state.PlanFingerprint && manifest.Fingerprint == state.PlanningPackageFingerprint && len(manifest.Outputs) > 0 + if valid { + identity := manifest + identity.Fingerprint = "" + identityRaw, encodeErr := json.MarshalIndent(identity, "", " ") + if encodeErr != nil { + return evidence, false, encodeErr + } + identityRaw = append(identityRaw, '\n') + valid = hashBytes(identityRaw) == manifest.Fingerprint && manifestFileFingerprint == hashBytes(manifestRaw) + } + planPath := "" + seen := map[string]bool{} + for _, output := range manifest.Outputs { + clean := filepath.Clean(filepath.FromSlash(output.Path)) + if output.ID == "" || output.Path == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || + output.MediaType == "" || len(output.SHA256) != 64 || output.Size < 0 || seen[output.ID] { + valid = false + continue + } + seen[output.ID] = true + if output.ID == "plan" { + planPath = clean + } + } + if planPath == "" { + valid = false + } else { + planEvidence, fingerprint, planExists, planErr := fileEvidence(filepath.Join(root, planPath), "planning-package-plan", now) + evidence = append(evidence, planEvidence) + if planErr != nil { + return evidence, false, planErr + } + valid = valid && planExists && fingerprint == manifest.PlanFingerprint + } + if state.Plan == model.PlanPackageApproved { + approvalPath := filepath.Join(root, "approval.json") + approvalEvidence, _, approvalExists, approvalErr := fileEvidence(approvalPath, "planning-package-approval", now) + evidence = append(evidence, approvalEvidence) + if approvalErr != nil { + return evidence, false, approvalErr + } + approvalRaw, readErr := os.ReadFile(approvalPath) + if readErr != nil && !os.IsNotExist(readErr) { + return evidence, false, readErr + } + var approval observedApproval + valid = valid && approvalExists && decodeStrictJSON(approvalRaw, &approval) == nil && approval.SchemaVersion == 1 && + approval.DeliveryID == state.Objective.DeliveryID && approval.PlanFingerprint == manifest.PlanFingerprint && approval.PackageFingerprint == manifest.Fingerprint && + approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero() && + state.ApprovalFingerprint == hashBytes(append(append([]byte(nil), manifestRaw...), approvalRaw...)) + } + return evidence, valid, nil +} + type pendingJournalHeader struct { SchemaVersion int `json:"schema_version"` TransitionID string `json:"transition_id"` diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index f99fda8..4c43796 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -21,6 +21,55 @@ type observerClock struct{ now time.Time } func (c observerClock) Now() time.Time { return c.now } +func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t *testing.T) { + // control-law: an admitted package is verified from its exact manifest while the canonical approved plan remains absent + repository := t.TempDir() + deliveryID := "delivery" + root := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + plan := []byte("# Proposed plan\n") + planFingerprint := hashBytes(plan) + manifest := observedPlanningPackageManifest{ + SchemaVersion: 1, DeliveryID: deliveryID, WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), + PlanFingerprint: planFingerprint, Outputs: []observedPlanningPackageOutput{{ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: planFingerprint, Size: int64(len(plan))}}, + } + identityRaw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + manifest.Fingerprint = hashBytes(append(identityRaw, '\n')) + manifestRaw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + manifestRaw = append(manifestRaw, '\n') + if err := os.WriteFile(filepath.Join(root, "plan.md"), plan, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "manifest.json"), manifestRaw, 0o600); err != nil { + t.Fatal(err) + } + state := durable.State{ + Plan: model.PlanValid, PlanFingerprint: planFingerprint, PlanningPackageFingerprint: manifest.Fingerprint, + Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: deliveryID}, + } + evidence, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(100, 0).UTC()) + if err != nil || !valid || len(evidence) != 2 { + t.Fatalf("planning package observation valid=%t evidence=%#v err=%v", valid, evidence, err) + } + if _, err := os.Stat(filepath.Join(repository, ".boatstack", "plans", deliveryID+".source")); !os.IsNotExist(err) { + t.Fatalf("admission prematurely created a canonical plan: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "plan.md"), []byte("tampered"), 0o600); err != nil { + t.Fatal(err) + } + if _, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(101, 0).UTC()); err != nil || valid { + t.Fatalf("tampered planning package valid=%t err=%v", valid, err) + } +} + func runGit(t *testing.T, directory string, arguments ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", directory}, arguments...)...) @@ -441,3 +490,33 @@ func mustRead(t *testing.T, path string) []byte { } return value } + +func TestPublicationCandidateRequiresCurrentCommittedPreviewIdentity(t *testing.T) { + repository := t.TempDir() + layout := ports.ControllerLayout{RepositoryRoot: repository} + state := durable.State{ + Publication: model.PublicationCandidate, + Objective: model.Objective{DeliveryID: "delivery"}, + } + path := filepath.Join(repository, ".boatstack", "publication", "delivery.preview.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + oldPreview := []byte(`{"schema_version":1,"delivery_id":"delivery","source_revision":"head","worktree_fingerprint":"worktree"}`) + if err := os.WriteFile(path, oldPreview, 0o600); err != nil { + t.Fatal(err) + } + if got := currentPublicationState(layout, state, "head", "worktree"); got != model.PublicationNone { + t.Fatalf("old preview projected as %s", got) + } + currentPreview := []byte(`{"schema_version":2,"delivery_id":"delivery","source_revision":"head","worktree_fingerprint":"worktree"}`) + if err := os.WriteFile(path, currentPreview, 0o600); err != nil { + t.Fatal(err) + } + if got := currentPublicationState(layout, state, "head", "worktree"); got != model.PublicationCandidate { + t.Fatalf("current preview projected as %s", got) + } + if got := currentPublicationState(layout, state, "new-head", "worktree"); got != model.PublicationNone { + t.Fatalf("stale source preview projected as %s", got) + } +} diff --git a/boatstack/internal/softwaredelivery/ports/ports.go b/boatstack/internal/softwaredelivery/ports/ports.go index 4763b63..b32fd68 100644 --- a/boatstack/internal/softwaredelivery/ports/ports.go +++ b/boatstack/internal/softwaredelivery/ports/ports.go @@ -52,6 +52,14 @@ type Locker interface { Acquire(context.Context, model.InvocationContext, []string) (Lock, error) } +// RuntimeStore is the effects-owned mutation boundary for runtime records that +// are not product effects. Callers decide record content; only this port may +// create directories or atomically replace bytes. +type RuntimeStore interface { + EnsureDirectory(string, uint32) error + WriteAtomic(string, []byte, uint32) error +} + type Journal interface { Begin(context.Context, protocol.Admission, catalog.Transition) error Stage(context.Context, string, []ResourceMutation) error diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 31463f8..4e2e24c 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -9,7 +9,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const AdmissionSchemaVersion = 6 +const AdmissionSchemaVersion = 7 type Admission struct { SchemaVersion int `json:"schema_version"` @@ -40,9 +40,14 @@ type Admission struct { IdempotencyKey string `json:"idempotency_key"` IssuedAt time.Time `json:"issued_at"` ExpiresAt time.Time `json:"expires_at"` + Work *WorkEvidence `json:"work,omitempty"` } func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, now time.Time, lifetime time.Duration) (Admission, error) { + return NewAdmissionWithWork(snapshot, objective, transition, prescription, authority, parameters, nil, now, lifetime) +} + +func NewAdmissionWithWork(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, work *WorkEvidence, now time.Time, lifetime time.Duration) (Admission, error) { var err error objective, err = ObjectiveForTransition(snapshot, objective, transition) if err != nil { @@ -61,6 +66,19 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition if err := prescription.ValidateCurrent(snapshot, transition, capabilities); err != nil { return Admission{}, err } + if err := prescription.ValidateWork(work); err != nil { + return Admission{}, err + } + if transition.Work != nil { + if work == nil { + return Admission{}, fmt.Errorf("transition %q requires foreground work evidence", transition.ID) + } + if err := work.ValidateCurrent(snapshot, transition); err != nil { + return Admission{}, err + } + } else if work != nil { + return Admission{}, fmt.Errorf("transition %q does not accept foreground work evidence", transition.ID) + } sourceRevision, worktreeFingerprint := gitBinding(snapshot) a := Admission{ SchemaVersion: AdmissionSchemaVersion, PrescriptionID: prescription.ID, TransitionID: transition.ID, TransitionVersion: transition.Version, @@ -72,6 +90,11 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition GrantedCapabilities: capabilities.Granted, EffectiveCapabilities: capabilities.Effective, Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(), } + if work != nil { + copy := *work + copy.Outputs = append([]WorkOutputEvidence(nil), work.Outputs...) + a.Work = © + } if transition.Policy.ObjectiveScope == catalog.ObjectiveScopeOptionalPreserve { a.ObjectiveStatus = snapshot.Objective.Status } @@ -89,7 +112,8 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition Invocation model.InvocationContext `json:"invocation"` Objective model.Objective `json:"objective"` Parameters Parameters `json:"parameters"` - }{transition.ID, snapshot.Fingerprint, snapshot.Invocation, objective, parameters.Canonical()}) + Work string `json:"work,omitempty"` + }{transition.ID, snapshot.Fingerprint, snapshot.Invocation, objective, parameters.Canonical(), prescription.WorkResultFingerprint}) if err != nil { return Admission{}, err } @@ -206,6 +230,16 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, objective model.Obje if a.ExpectedProgramFingerprint != snapshot.ProgramFingerprint { return fmt.Errorf("admission %q is bound to a different control program", a.ID) } + if transition.Work != nil { + if a.Work == nil { + return fmt.Errorf("admission %q is missing foreground work evidence", a.ID) + } + if err := a.Work.ValidateCurrent(snapshot, transition); err != nil { + return fmt.Errorf("admission %q foreground work changed: %w", a.ID, err) + } + } else if a.Work != nil { + return fmt.Errorf("admission %q carries unexpected foreground work evidence", a.ID) + } expectedPrior := "" if snapshot.RecordedProgramFingerprint != "" && snapshot.RecordedProgramFingerprint != snapshot.ProgramFingerprint { expectedPrior = snapshot.RecordedProgramFingerprint @@ -413,6 +447,11 @@ func (a Admission) ValidateIdentity() error { if err := a.Invocation.Validate(true); err != nil { return err } + if a.Work != nil { + if err := a.Work.Validate(); err != nil { + return err + } + } if !a.ObjectiveScope.Valid() { return fmt.Errorf("admission has invalid objective scope %q", a.ObjectiveScope) } diff --git a/boatstack/internal/softwaredelivery/protocol/authority.go b/boatstack/internal/softwaredelivery/protocol/authority.go index 11fb160..1c768f0 100644 --- a/boatstack/internal/softwaredelivery/protocol/authority.go +++ b/boatstack/internal/softwaredelivery/protocol/authority.go @@ -83,6 +83,19 @@ func (b AuthorityBundle) GrantedCapabilities(now time.Time) []catalog.Capability return catalog.AuthorityCapabilities(b.Set(now)).Sorted() } +// DeriveRepositoryAuthorityWhenAvailable treats repository authority as a +// requested continuation capability, never as caller-supplied evidence. A +// fresh or drifted repository preserves the existing authority bundle so +// trusted bootstrap and repair transitions can establish verified +// configuration. Transitions that require repository authority remain +// inadmissible until a later continuation step can derive it exactly. +func DeriveRepositoryAuthorityWhenAvailable(snapshot model.Snapshot, bundle AuthorityBundle, now time.Time) (AuthorityBundle, error) { + if snapshot.Configuration.Status != model.FactKnown || snapshot.Configuration.Value != model.ConfigurationVerified { + return bundle, nil + } + return DeriveRepositoryAuthority(snapshot, bundle, now) +} + func DeriveRepositoryAuthority(snapshot model.Snapshot, bundle AuthorityBundle, now time.Time) (AuthorityBundle, error) { for _, receipt := range bundle.Receipts { if receipt.Class == catalog.AuthorityRepository { diff --git a/boatstack/internal/softwaredelivery/protocol/authority_test.go b/boatstack/internal/softwaredelivery/protocol/authority_test.go new file mode 100644 index 0000000..4edf4f0 --- /dev/null +++ b/boatstack/internal/softwaredelivery/protocol/authority_test.go @@ -0,0 +1,41 @@ +package protocol + +import ( + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func TestRepositoryAuthorityWaitsForVerifiedConfiguration(t *testing.T) { + // control-law: an authority request never fabricates repository evidence + now := time.Unix(100, 0).UTC() + autonomy := AuthorityBundle{Receipts: []AuthorityReceipt{{ + ID: "delegated-autonomy", Class: catalog.AuthorityAutonomy, Subject: "operator", Fingerprint: "delegation", + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} + snapshot := model.Snapshot{Observation: model.Observation{ + Invocation: model.InvocationContext{RepositoryID: "repository", GitCommonID: "git-common"}, + Configuration: model.Known(model.ConfigurationStale, model.Evidence{Source: "configuration:project.json", Fingerprint: "stale"}), + }} + + result, err := DeriveRepositoryAuthorityWhenAvailable(snapshot, autonomy, now) + if err != nil { + t.Fatal(err) + } + set := result.Set(now) + if !set[catalog.AuthorityAutonomy] || set[catalog.AuthorityRepository] { + t.Fatalf("fresh authority projection = %v", set) + } + + snapshot.Configuration = model.Known(model.ConfigurationVerified, model.Evidence{Source: "configuration:project.json", Fingerprint: "verified"}) + result, err = DeriveRepositoryAuthorityWhenAvailable(snapshot, autonomy, now) + if err != nil { + t.Fatal(err) + } + set = result.Set(now) + if !set[catalog.AuthorityAutonomy] || !set[catalog.AuthorityRepository] { + t.Fatalf("verified authority projection = %v", set) + } +} diff --git a/boatstack/internal/softwaredelivery/protocol/prescription.go b/boatstack/internal/softwaredelivery/protocol/prescription.go index 4967b62..1562888 100644 --- a/boatstack/internal/softwaredelivery/protocol/prescription.go +++ b/boatstack/internal/softwaredelivery/protocol/prescription.go @@ -8,7 +8,7 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const PrescriptionSchemaVersion = 4 +const PrescriptionSchemaVersion = 5 // Prescription is the immutable compare-and-swap binding emitted by // resolution and required by apply. It carries no reusable authority or @@ -20,9 +20,14 @@ type Prescription struct { general.Freshness RequiredCapabilities []catalog.Capability `json:"required_capabilities"` EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` + WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` } func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection) (Prescription, error) { + return NewPrescriptionWithWork(snapshot, transition, capabilities, nil) +} + +func NewPrescriptionWithWork(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection, work *WorkEvidence) (Prescription, error) { objectiveBindingFingerprint, err := ObjectiveBindingFingerprint(snapshot) if err != nil { return Prescription{}, err @@ -38,6 +43,12 @@ func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, cap RequiredCapabilities: append([]catalog.Capability(nil), capabilities.Required...), EffectiveCapabilities: append([]catalog.Capability(nil), capabilities.Effective...), } + if work != nil { + if err := work.ValidateCurrent(snapshot, transition); err != nil { + return Prescription{}, err + } + prescription.WorkResultFingerprint = work.ResultFingerprint + } if err := prescription.validateFields(); err != nil { return Prescription{}, err } @@ -101,6 +112,19 @@ func (p Prescription) ValidateCurrent(snapshot model.Snapshot, transition catalo return nil } +func (p Prescription) ValidateWork(work *WorkEvidence) error { + if p.WorkResultFingerprint == "" { + if work != nil { + return fmt.Errorf("prescription is not bound to foreground work") + } + return nil + } + if work == nil || work.ResultFingerprint != p.WorkResultFingerprint { + return fmt.Errorf("prescription is bound to a different foreground work result") + } + return nil +} + // ObjectiveBindingFingerprint projects only the durable binding status and // value. Observation evidence may be refreshed without changing the binding. func ObjectiveBindingFingerprint(snapshot model.Snapshot) (string, error) { diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index 4f81602..d88cb56 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -12,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ReceiptSchemaVersion = 10 +const ReceiptSchemaVersion = 11 type TransitionFactKind string @@ -134,6 +134,7 @@ type TransitionReceipt struct { ExecutionContext string `json:"execution_context,omitempty"` PriorInvocation *model.InvocationContext `json:"prior_invocation,omitempty"` ResultingInvocation *model.InvocationContext `json:"resulting_invocation,omitempty"` + WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` } type AuthoritySource struct { @@ -199,6 +200,9 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi IdempotencyKey: admission.IdempotencyKey, Recovery: transition.Interruption.Recovery, Terminal: terminal, StartedAt: startedAt.UTC(), CommittedAt: committedAt.UTC(), DurationNanoseconds: committedAt.Sub(startedAt).Nanoseconds(), } + if admission.Work != nil { + receipt.WorkResultFingerprint = admission.Work.ResultFingerprint + } if transition.ExecutionContext == "advance" { prior, resulting := admission.Invocation, target.Invocation if err := prior.Validate(true); err != nil { @@ -248,6 +252,9 @@ func (r TransitionReceipt) Validate() error { } else if r.PriorInvocation != nil || r.ResultingInvocation != nil { return fmt.Errorf("receipt has invocation lineage without an execution context advance") } + if r.WorkResultFingerprint != "" && !validSHA256(r.WorkResultFingerprint) { + return fmt.Errorf("receipt has invalid foreground work identity") + } canonicalFacets, err := model.NormalizeStateFacets("receipt.changed_state_facets", r.ChangedStateFacets) if err != nil || !slices.Equal(canonicalFacets, r.ChangedStateFacets) { return fmt.Errorf("receipt changed state facets are invalid or non-canonical: %v", err) diff --git a/boatstack/internal/softwaredelivery/protocol/work.go b/boatstack/internal/softwaredelivery/protocol/work.go new file mode 100644 index 0000000..0ce2755 --- /dev/null +++ b/boatstack/internal/softwaredelivery/protocol/work.go @@ -0,0 +1,116 @@ +package protocol + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + general "github.com/operatorstack/boatstack/boatstack/kernel" +) + +const WorkEvidenceSchemaVersion = 2 + +// WorkOutputEvidence is a verified, bounded foreground-work output. Content is +// carried into admission so effects never trust a mutable staging path. +type WorkOutputEvidence struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Content string `json:"content"` +} + +// WorkEvidence is result evidence, not authority and not a domain mutation. +// It is exact to one program, transition, stable context, repository, and +// worktree. Invocation-local driver IDs do not break restart continuity. +type WorkEvidence struct { + SchemaVersion int `json:"schema_version"` + RequestID string `json:"request_id"` + RequestFingerprint string `json:"request_fingerprint"` + ResultFingerprint string `json:"result_fingerprint"` + ContractID string `json:"contract_id"` + ContractFingerprint string `json:"contract_fingerprint"` + TransitionID catalog.TransitionID `json:"transition_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ContextFingerprint string `json:"context_fingerprint"` + StateRevision uint64 `json:"state_revision"` + RepositoryID string `json:"repository_id"` + WorktreeID string `json:"worktree_id"` + Outputs []WorkOutputEvidence `json:"outputs"` +} + +func (e WorkEvidence) Validate() error { + if e.SchemaVersion != WorkEvidenceSchemaVersion || e.RequestID == "" || !validSHA256(e.RequestFingerprint) || + !validSHA256(e.ResultFingerprint) || e.ContractID == "" || !validSHA256(e.ContractFingerprint) || e.TransitionID == "" || + !validSHA256(e.ProgramFingerprint) || !validSHA256(e.ContextFingerprint) || e.StateRevision == 0 || e.RepositoryID == "" || e.WorktreeID == "" || len(e.Outputs) == 0 { + return fmt.Errorf("foreground work evidence has incomplete identity") + } + seen := map[string]bool{} + for _, output := range e.Outputs { + contentDigest := sha256.Sum256([]byte(output.Content)) + if output.ID == "" || output.Path == "" || output.MediaType == "" || !validSHA256(output.SHA256) || hex.EncodeToString(contentDigest[:]) != output.SHA256 || output.Size < 0 || int64(len(output.Content)) != output.Size || seen[output.ID] { + return fmt.Errorf("foreground work output evidence is incomplete or duplicated") + } + seen[output.ID] = true + } + canonical := e + canonical.ResultFingerprint = "" + want, err := general.Fingerprint(canonical) + if err != nil || want != e.ResultFingerprint { + return fmt.Errorf("foreground work result fingerprint is invalid") + } + return nil +} + +func (e WorkEvidence) ValidateCurrent(snapshot model.Snapshot, transition catalog.Transition) error { + if err := e.Validate(); err != nil { + return err + } + contextFingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) + if err != nil { + return fmt.Errorf("foreground work context fingerprint: %w", err) + } + if transition.Work == nil || e.ContractID != transition.Work.ID || e.ContractFingerprint != transition.Work.Fingerprint || + e.TransitionID != transition.ID || e.ProgramFingerprint != snapshot.ProgramFingerprint || e.ContextFingerprint != contextFingerprint || + e.StateRevision != snapshot.StateRevision || e.RepositoryID != snapshot.Invocation.RepositoryID || e.WorktreeID != snapshot.Invocation.WorktreeID { + return fmt.Errorf("foreground work evidence is stale or belongs to a different transition context") + } + declared := map[string]catalog.WorkOutput{} + for _, output := range transition.Work.Outputs { + declared[output.ID] = output + } + for _, output := range e.Outputs { + contract, ok := declared[output.ID] + if !ok || contract.Path != output.Path || contract.MediaType != output.MediaType || output.Size > contract.MaxBytes { + return fmt.Errorf("foreground work output %q does not match the trusted contract", output.ID) + } + delete(declared, output.ID) + } + for _, output := range declared { + if output.Required { + return fmt.Errorf("foreground work result is missing required output %q", output.ID) + } + } + return nil +} + +func CanonicalWorkOutputs(outputs []WorkOutputEvidence) []WorkOutputEvidence { + result := append([]WorkOutputEvidence(nil), outputs...) + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func SealWorkEvidence(e WorkEvidence) (WorkEvidence, error) { + e.Outputs = CanonicalWorkOutputs(e.Outputs) + e.ResultFingerprint = "" + fingerprint, err := general.Fingerprint(e) + if err != nil { + return WorkEvidence{}, err + } + e.ResultFingerprint = fingerprint + return e, e.Validate() +} diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 50fc15b..255abcf 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -3,38 +3,46 @@ package surfaces import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "regexp" "strings" "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 8 +const SchemaVersion = 9 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) type Operation string const ( - OperationResolve Operation = "resolve" - OperationExplain Operation = "explain" - OperationApply Operation = "apply" - OperationRecover Operation = "recover" - OperationDoctor Operation = "doctor" - OperationCatalog Operation = "catalog" - OperationEvents Operation = "events" - OperationGuard Operation = "guard" + OperationResolve Operation = "resolve" + OperationExplain Operation = "explain" + OperationApply Operation = "apply" + OperationRecover Operation = "recover" + OperationDoctor Operation = "doctor" + OperationCatalog Operation = "catalog" + OperationEvents Operation = "events" + OperationGuard Operation = "guard" + OperationWorkShow Operation = "work-show" + OperationWorkInputRequired Operation = "work-input-required" + OperationWorkAnswer Operation = "work-answer" + OperationWorkComplete Operation = "work-complete" + OperationWorkBlock Operation = "work-block" ) func (o Operation) Valid() bool { switch o { - case OperationResolve, OperationExplain, OperationApply, OperationRecover, OperationDoctor, OperationCatalog, OperationEvents, OperationGuard: + case OperationResolve, OperationExplain, OperationApply, OperationRecover, OperationDoctor, OperationCatalog, OperationEvents, OperationGuard, + OperationWorkShow, OperationWorkInputRequired, OperationWorkAnswer, OperationWorkComplete, OperationWorkBlock: return true default: return false @@ -62,6 +70,13 @@ type Request struct { DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` + WorkInputs map[string]string `json:"work_inputs,omitempty"` + WorkID string `json:"work_id,omitempty"` + WorkQuestionPrompt string `json:"work_question_prompt,omitempty"` + WorkQuestionSchema []byte `json:"work_question_schema,omitempty"` + WorkQuestionID string `json:"work_question_id,omitempty"` + WorkAnswer []byte `json:"work_answer,omitempty"` + WorkBlockReason string `json:"work_block_reason,omitempty"` } func (r Request) Validate(now time.Time) error { @@ -117,6 +132,29 @@ func (r Request) Validate(now time.Time) error { if r.Operation == OperationGuard && (strings.TrimSpace(r.Command) == "" || len(r.Command) > 1<<20) { return fmt.Errorf("guard operation requires a bounded command") } + if strings.HasPrefix(string(r.Operation), "work-") { + if r.FlowID == "" || !flowContextIdentity.MatchString(r.WorkID) { + return fmt.Errorf("foreground work operation requires semantic run and work identity") + } + switch r.Operation { + case OperationWorkShow, OperationWorkComplete: + if r.WorkQuestionPrompt != "" || len(r.WorkQuestionSchema) != 0 || r.WorkQuestionID != "" || len(r.WorkAnswer) != 0 || r.WorkBlockReason != "" { + return fmt.Errorf("foreground work %s cannot carry mutation payload", r.Operation) + } + case OperationWorkInputRequired: + if strings.TrimSpace(r.WorkQuestionPrompt) == "" || r.WorkQuestionID != "" || len(r.WorkAnswer) != 0 || r.WorkBlockReason != "" { + return fmt.Errorf("foreground work input-required requires only a question prompt and optional schema") + } + case OperationWorkAnswer: + if !flowContextIdentity.MatchString(r.WorkQuestionID) || len(r.WorkAnswer) == 0 || !json.Valid(r.WorkAnswer) || r.WorkQuestionPrompt != "" || len(r.WorkQuestionSchema) != 0 || r.WorkBlockReason != "" { + return fmt.Errorf("foreground work answer requires the exact question and bounded JSON answer") + } + case OperationWorkBlock: + if strings.TrimSpace(r.WorkBlockReason) == "" || r.WorkQuestionPrompt != "" || len(r.WorkQuestionSchema) != 0 || r.WorkQuestionID != "" || len(r.WorkAnswer) != 0 { + return fmt.Errorf("foreground work block requires only a reason") + } + } + } if r.IdempotencyKey != "" && !strings.HasPrefix(r.IdempotencyKey, "idem-") { return fmt.Errorf("surface idempotency key has invalid identity") } @@ -177,6 +215,7 @@ type Response struct { Guard *supervisor.GuardDecision `json:"guard,omitempty"` Error string `json:"error,omitempty"` Delegation *DelegationRequired `json:"delegation,omitempty"` + Work *foregroundwork.Record `json:"work,omitempty"` } type DelegationRequired struct { diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go index 338a46e..db79106 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go @@ -1,6 +1,7 @@ package surfaces import ( + "encoding/json" "testing" "time" @@ -31,6 +32,55 @@ func TestSurfaceSchemaIsFlagDayAndApplyRequiresPrescription(t *testing.T) { } } +func TestForegroundWorkSurfaceRejectsAmbiguousMutationPayloads(t *testing.T) { + // control-law: each foreground-work mutation crosses one typed operation boundary + base := Request{ + SchemaVersion: SchemaVersion, Repository: "/repository", Host: "cli", CorrelationID: "work", + ProgramID: "incident-response", ProgramFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + EntryID: "respond", FlowID: "run-1", WorkID: "diagnose", + } + valid := []Request{ + func() Request { value := base; value.Operation = OperationWorkShow; return value }(), + func() Request { value := base; value.Operation = OperationWorkComplete; return value }(), + func() Request { + value := base + value.Operation = OperationWorkInputRequired + value.WorkQuestionPrompt = "Which service?" + value.WorkQuestionSchema = []byte(`{"type":"string"}`) + return value + }(), + func() Request { + value := base + value.Operation = OperationWorkAnswer + value.WorkQuestionID = "question-1" + value.WorkAnswer = json.RawMessage(`"api"`) + return value + }(), + func() Request { + value := base + value.Operation = OperationWorkBlock + value.WorkBlockReason = "input unavailable" + return value + }(), + } + for _, request := range valid { + if err := request.Validate(time.Now()); err != nil { + t.Fatalf("valid %s request: %v", request.Operation, err) + } + } + invalid := []Request{valid[0], valid[1], valid[2], valid[3], valid[4]} + invalid[0].WorkBlockReason = "hidden mutation" + invalid[1].WorkAnswer = []byte(`true`) + invalid[2].WorkQuestionPrompt = "" + invalid[3].WorkQuestionID = "" + invalid[4].WorkQuestionID = "question-1" + for _, request := range invalid { + if err := request.Validate(time.Now()); err == nil { + t.Fatalf("ambiguous %s request was accepted", request.Operation) + } + } +} + func TestExplainRequestRejectsMutationArtifacts(t *testing.T) { now := time.Now().UTC() request := Request{SchemaVersion: SchemaVersion, Operation: OperationExplain, Repository: "/repo", Host: "cli", CorrelationID: "explain"} diff --git a/boatstack/internal/softwaredelivery/surfaces/render.go b/boatstack/internal/softwaredelivery/surfaces/render.go index 898f776..2d19e4d 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render.go +++ b/boatstack/internal/softwaredelivery/surfaces/render.go @@ -37,6 +37,9 @@ func PrescriptionCommand(transition catalog.Transition, prescription protocol.Pr if programID != "" && entryID != "" { arguments = append(arguments, "--flow", programID, "--entry", entryID) } + if prescription.WorkResultFingerprint != "" { + arguments = append(arguments, "--work-result-fingerprint", prescription.WorkResultFingerprint) + } for _, capability := range prescription.RequiredCapabilities { arguments = append(arguments, "--required-capability", string(capability)) } diff --git a/boatstack/internal/softwaredelivery/surfaces/render_test.go b/boatstack/internal/softwaredelivery/surfaces/render_test.go index ce1fe85..d6761c8 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/render_test.go @@ -26,10 +26,11 @@ func TestShellRenderersConsumeOneCommandAST(t *testing.T) { ID: "prx-fixture", Freshness: general.Freshness{ExpectedInstanceID: "repo-fixture", ExpectedStateRevision: 41, ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: strings.Repeat("b", 64), ExpectedObjectiveBindingFingerprint: strings.Repeat("c", 64), AuthorityFingerprint: "auth-fixture"}, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, + WorkResultFingerprint: strings.Repeat("d", 64), } 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"} { + 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", "--work-result-fingerprint " + strings.Repeat("d", 64)} { if !strings.Contains(joined, binding) { t.Fatalf("prescription command omitted CAS binding %q: %s", binding, joined) } diff --git a/boatstack/testdata/control-programs/assets/diagnose.md b/boatstack/testdata/control-programs/assets/diagnose.md new file mode 100644 index 0000000..1d397e5 --- /dev/null +++ b/boatstack/testdata/control-programs/assets/diagnose.md @@ -0,0 +1,4 @@ +# Diagnose the incident + +Inspect the bound incident input. Produce only the declared diagnosis artifact. +If required information is missing, ask one typed question and wait for its answer. diff --git a/boatstack/testdata/control-programs/assets/diagnosis.schema.json b/boatstack/testdata/control-programs/assets/diagnosis.schema.json new file mode 100644 index 0000000..089d2a0 --- /dev/null +++ b/boatstack/testdata/control-programs/assets/diagnosis.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "cause": { "type": "string", "minLength": 1 }, + "safe_to_restart": { "type": "boolean" } + }, + "required": ["cause", "safe_to_restart"], + "additionalProperties": false +} diff --git a/boatstack/testdata/control-programs/assets/planning-list.schema.json b/boatstack/testdata/control-programs/assets/planning-list.schema.json new file mode 100644 index 0000000..5408a2b --- /dev/null +++ b/boatstack/testdata/control-programs/assets/planning-list.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "type": "object" } + } + }, + "required": ["items"], + "additionalProperties": false +} diff --git a/boatstack/testdata/control-programs/assets/planning-package.md b/boatstack/testdata/control-programs/assets/planning-package.md new file mode 100644 index 0000000..ab25d76 --- /dev/null +++ b/boatstack/testdata/control-programs/assets/planning-package.md @@ -0,0 +1,8 @@ +# Compile a delivery planning package + +Read the bound repository plan and inspect only the code needed to resolve its unknowns. +Ask the user focused questions when a consequential product or implementation choice is missing. +After every required answer is available, produce the exact declared artifacts. + +The package must make scope, behavior, tasks, tests, user journeys, gaps, evidence, and allowed autonomy explicit. +Do not implement the feature during this work requirement. diff --git a/boatstack/testdata/control-programs/incident-response-work.flow.ts b/boatstack/testdata/control-programs/incident-response-work.flow.ts new file mode 100644 index 0000000..fe1060d --- /dev/null +++ b/boatstack/testdata/control-programs/incident-response-work.flow.ts @@ -0,0 +1,82 @@ +import { + defineFlow, + entry, + entryInput, + fact, + facet, + foregroundWork, + instructionAsset, + marked, + operator, + schemaAsset, + transition, + workArtifact, +} from "@operatorstack/boatstack"; + +const diagnosis = foregroundWork({ + id: "diagnose", + instructions: instructionAsset("boatstack/testdata/control-programs/assets/diagnose.md"), + inputs: [entryInput("incident")], + outputs: [ + workArtifact({ + id: "diagnosis", + path: "diagnosis.json", + media_type: "application/json", + required: true, + max_bytes: 65536, + schema: schemaAsset("boatstack/testdata/control-programs/assets/diagnosis.schema.json"), + }), + ], +}); + +export default defineFlow({ + id: "incident-response-work", + version: "1", + declarations: { + capabilities: ["service.restart"], + authorities: ["incident-commander"], + effects: ["service.restart"], + verifiers: ["healthcheck"], + input_resolvers: ["incident.input"], + }, + facets: [facet("incident", "enum", ["open", "mitigated"])], + work: [diagnosis], + operators: [ + operator("restart", { + capabilities: ["service.restart"], + authority: { any_of: ["incident-commander"] }, + effects: ["service.restart"], + verifier: "healthcheck", + recovery: "restart", + execution_context: "preserve", + state_effect: { + kind: "assignments", + assignments: [{ facet: "incident", value: "mitigated" }], + }, + }), + ], + transitions: [ + transition("restart", "restart", { + guard: fact("incident", ["open"]), + target: fact("incident", ["mitigated"]), + priority: 10, + work: "diagnose", + }), + ], + targets: [marked("mitigated", fact("incident", ["mitigated"]))], + entries: [ + entry({ + id: "respond", + target: "mitigated", + inputs: [ + { + id: "incident", + type: "json", + required: true, + resolver: "incident.input", + config: {}, + }, + ], + }), + ], +}); diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 8d00baa..0827b3a 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,6 +1,6 @@ { "schema": "control-program", - "schema_revision": 2, + "schema_revision": 3, "program": { "id": "incident-response", "version": "1" diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts new file mode 100644 index 0000000..5864148 --- /dev/null +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -0,0 +1,111 @@ +import { + all, + defineFlow, + entry, + entryInput, + fact, + foregroundWork, + instructionAsset, + marked, + schemaAsset, + workArtifact, +} from "@operatorstack/boatstack"; +import { + inbox, + planInboxResolver, + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, + softwareDeliveryEvidence, + softwareDeliveryFacets, + trustedDelegation, + trustedOperators, + trustedTransition, +} from "@operatorstack/boatstack-software-delivery"; + +const planning = foregroundWork({ + id: "planning-package", + instructions: instructionAsset("boatstack/testdata/control-programs/assets/planning-package.md"), + inputs: [entryInput("plan")], + outputs: [ + workArtifact({ id: "plan", path: "plan.md", media_type: "text/markdown", required: true, max_bytes: 262144 }), + workArtifact({ id: "feature-spec", path: "feature-spec.md", media_type: "text/markdown", required: true, max_bytes: 262144 }), + workArtifact({ id: "questions", path: "questions.md", media_type: "text/markdown", required: true, max_bytes: 131072 }), + workArtifact({ id: "test-plan", path: "test-plan.md", media_type: "text/markdown", required: true, max_bytes: 262144 }), + workArtifact({ id: "gaps", path: "gaps.md", media_type: "text/markdown", required: false, max_bytes: 131072 }), + workArtifact({ id: "autonomy", path: "autonomy.md", media_type: "text/markdown", required: true, max_bytes: 131072 }), + workArtifact({ id: "tasks", path: "compiled/tasks.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }), + workArtifact({ id: "test-matrix", path: "compiled/test-matrix.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }), + workArtifact({ id: "journey-oracles", path: "compiled/journey-oracles.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }), + workArtifact({ id: "evidence", path: "compiled/evidence.md", media_type: "text/markdown", required: true, max_bytes: 131072 }), + ], +}); + +const lifecycle = [ + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, + { 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 default defineFlow({ + id: "product-delivery-planning-package", + version: "1", + declarations: { input_resolvers: [planInboxResolver] }, + facets: softwareDeliveryFacets, + evidence: softwareDeliveryEvidence, + work: [planning], + operators: trustedOperators(lifecycle), + transitions: [ + trustedTransition(planningPackageAdmit, { work: planning }), + trustedTransition(planningPackageApprove), + trustedTransition(planningPackagePromote), + trustedTransition({ id: "plan.activate", priority: 50 }), + trustedTransition({ id: "workspace.cut", priority: 52 }), + trustedTransition({ id: "workspace.activate", priority: 53 }), + trustedTransition({ id: "workspace.sync", priority: 58 }), + trustedTransition({ id: "gate.build.record", priority: 61 }), + trustedTransition({ id: "gate.test.record", priority: 62 }), + trustedTransition({ id: "gate.review.record", priority: 63 }), + trustedTransition({ id: "gate.change.record", priority: 64 }), + trustedTransition({ id: "gate.journey.record", priority: 64 }), + trustedTransition({ id: "evidence.visual.attach", priority: 66 }), + trustedTransition({ id: "delivery.slice.advance", priority: 68 }), + trustedTransition({ id: "publication.preview", priority: 72 }), + trustedTransition({ id: "workspace.publish", priority: 75 }), + trustedTransition({ id: "publication.execute", priority: 76 }), + trustedTransition({ id: "publication.observe", priority: 77 }), + trustedTransition({ id: "publication.correct", priority: 80 }), + trustedTransition({ id: "workspace.reconcile", priority: 2 }), + trustedTransition({ id: "publication.reconcile", priority: 1 }), + ], + targets: [marked("published-pr", all( + fact("verification", ["current"]), + fact("configuration", ["verified"]), + fact("runtime", ["verified"]), + fact("publication", ["open"]), + ))], + entries: [entry({ + id: "run", + target: "published-pr", + inputs: [inbox(".boatstack/plans/inbox")], + delegation: trustedDelegation("autonomy"), + })], +}); diff --git a/docs/architecture/boatstack-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index aeb8088..7b61b75 100644 --- a/docs/architecture/boatstack-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -7,39 +7,39 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | -| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | +| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | -| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | -| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | -| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | -| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | -| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | -| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | -| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | -| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | +| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | +| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | +| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | +| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | +| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | +| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | +| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | +| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | | `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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` | @@ -54,14 +54,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `publication.observe` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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` | -| `repository.attach` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | -| `repository.detach` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | -| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `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` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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` | +| `repository.attach` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | +| `repository.detach` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `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` | diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index a2bb784..b9091c1 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -6,9 +6,10 @@ Boatstack separates authoring languages from executable semantics: TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel ``` -The `control-program` schema at revision `2` is domain-neutral. It declares typed facets, -evidence relations, predicate ASTs, operators, capabilities, authority, -effects, verification, recovery, transitions, marked targets, and entries. +The `control-program` schema at revision `3` is domain-neutral. It declares +typed facets, evidence relations, predicate ASTs, operators, capabilities, +authority, effects, verification, recovery, bounded foreground work, +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. @@ -70,10 +71,43 @@ execute `flow.ts`. The artifact filename comes from the declared program ID, not the source filename. The artifact binds the source hash, compiler version, dependency-lock hash, -trusted operator fingerprints, canonical program fingerprint, and generated -skill hashes. Unknown fields, duplicate declarations, invalid references, +foreground-work instruction and schema assets, trusted operator fingerprints, +canonical program fingerprint, and generated skill hashes. Unknown fields, +duplicate declarations, invalid references, undeclared inline effects, missing recovery, binding drift, and generated-file -drift fail closed. A source or lock change during compilation also fails closed. +drift fail closed. A source, lock, instruction, or schema change during +compilation also fails closed. + +## Foreground work + +A Flow may require bounded human or agent work before a trusted transition can +be prescribed. The repository declares an instruction asset, exact entry +inputs, and an output manifest. Boatstack resolves the assets during compile, +creates a runtime-owned work request for the selected transition, and verifies +the staged outputs before it admits the trusted operator. + +Foreground work cannot change Flow state, grant authority, or install an +effect handler. Its result is immutable evidence bound to one run, program, +transition, state revision, repository, and worktree. Questions suspend the +same run; answers are evidence rather than authority. A program or state change +invalidates the result before any trusted effect. + +The foreground-work commands are explicit and foreground-only: + +```sh +boatstack flow work show --repo . --flow --entry \ + --run-id --work-id --format json +boatstack flow work input-required ... --prompt "" +boatstack flow work answer ... --question-id --answer +boatstack flow work complete ... +boatstack flow work block ... --reason "" +``` + +`complete` reads only the declared regular files below the request's staging +root. It checks paths, media types, size limits, JSON syntax and declared JSON +Schemas, then seals the exact bytes into the work result. The following +`next` call can prescribe the transition only with that exact result +fingerprint. Trusted software-delivery bindings fix capabilities, authority, effects, verifiers, recovery, and state effects. A repository may select and order those diff --git a/docs/getting-started.md b/docs/getting-started.md index e126650..479f517 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -154,13 +154,15 @@ Publication uses `publication.preview`, `publication.execute`, and `publication.observe`. The external execute step requires both: - human or autonomy authority; and -- an unexpired `external-provider` authority receipt supplied with - `--authority-receipt`. - -Boatstack does not infer provider authority from `gh` being installed or -authenticated. The provider receipt fingerprint must equal the exact -`preview_fingerprint` returned by `publication.preview`; correction receipts -likewise bind the admitted body SHA-256. It never merges a pull request. +- a current GitHub identity with write, maintain, or admin permission. + +For a repository Flow continuation, Boatstack derives a short-lived provider +receipt through its trusted `gh repo view` boundary. Callers cannot supply an +`external-provider` receipt as JSON. The receipt and publication preview bind +the exact committed HEAD, clean product worktree, and preview fingerprint. +Boatstack stops with `WORKSPACE_COMMIT_REQUIRED` before preview when intended +delivery changes are uncommitted. Correction receipts likewise bind the +admitted body SHA-256. Boatstack never merges a pull request. ## Inspect receipts diff --git a/docs/product-delivery/authority-and-delegation.md b/docs/product-delivery/authority-and-delegation.md index 2f930c0..c83d669 100644 --- a/docs/product-delivery/authority-and-delegation.md +++ b/docs/product-delivery/authority-and-delegation.md @@ -24,3 +24,11 @@ entry. It does not authorize the run. Boatstack presents an exact run-bound request at runtime, and only a trusted human/host boundary can authorize it. Revocation, expiry, incompatible drift, or an unauthorized execution context ends or suspends that delegation. External-provider authority remains separate. +For repository Flow continuation, the trusted GitHub boundary derives that +provider capability from the current repository identity and authenticated +write permission. This is capability evidence, not another human approval. +Repository files and `--authority-receipt` cannot create provider authority. + +Publication is admitted only after the product worktree is clean and the +preview binds its exact committed HEAD. A changed HEAD or worktree invalidates +the preview before the external effect. diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 06553c1..0dc3f55 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -4,30 +4,68 @@ A repository Flow chooses trusted operations, their priorities, terminal targets, and named entries. It does not provide executable operator handlers. ```ts -import { all, defineFlow, entry, fact, marked } from "@operatorstack/boatstack"; +import { + all, + defineFlow, + entry, + entryInput, + fact, + foregroundWork, + instructionAsset, + marked, + workArtifact, +} from "@operatorstack/boatstack"; import { inbox, planInboxResolver, + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, softwareDeliveryEvidence, softwareDeliveryFacets, trustedDelegation, trustedOperators, + trustedTransition, trustedTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; const lifecycle = [ - { id: "publication.observe", priority: 77 }, + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, + // Add the repository's trusted execution, gate, and publication steps here. ] satisfies TrustedStep[]; +const planning = foregroundWork({ + id: "planning-package", + instructions: instructionAsset(".boatstack/flows/assets/planning.md"), + inputs: [entryInput("plan")], + outputs: [ + workArtifact({ + id: "plan", + path: "plan.md", + media_type: "text/markdown", + required: true, + max_bytes: 262144, + }), + ], +}); + export default defineFlow({ id: "product-delivery", version: "1", declarations: { input_resolvers: [planInboxResolver] }, facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, + work: [planning], operators: trustedOperators(lifecycle), - transitions: trustedTransitions(lifecycle), + transitions: [ + trustedTransition(planningPackageAdmit, { work: planning }), + trustedTransition(planningPackageApprove), + trustedTransition(planningPackagePromote), + // Add trustedTransitions(...) for the remaining lifecycle here. + ], targets: [ marked( "published-pr", @@ -60,3 +98,11 @@ Compilation lowers this source to raw IR. Boatstack then validates references, resolves trusted bindings, canonicalizes executable semantics, and fingerprints the program. Runtime commands load the committed IR artifact; they do not execute this source file. + +The planning-package operations are optional trusted mechanisms. A repository +that includes them owns the planning instruction, output contract, transition +order, approval policy, target, and entry. `planning.package.admit` atomically +stores the verified package, `planning.package.approve` binds approval to its +exact manifest, and `planning.package.promote` publishes the approved canonical +plan. Repositories that omit these operations retain the existing plan path; +StandardFlow does not add them automatically. diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 6c838e4..a9760bf 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -20,6 +20,7 @@ import { type OperatorDefinition, type TransitionDefinition, type DelegationBindingDefinition, + type WorkContract, } from "@operatorstack/boatstack"; const bindingPrefix = "software-delivery/"; @@ -76,6 +77,22 @@ export interface TrustedStep { priority: number; } +/** Admits a completed planning package into the delivery lifecycle. */ +export const planningPackageAdmit: TrustedStep = { + id: "planning.package.admit", + priority: 43, +}; +/** Records approval of the exact admitted planning package. */ +export const planningPackageApprove: TrustedStep = { + id: "planning.package.approve", + priority: 44, +}; +/** Promotes an approved planning package into the active delivery plan. */ +export const planningPackagePromote: TrustedStep = { + id: "planning.package.promote", + priority: 45, +}; + /** * Repository-owned strengthening applied to a trusted transition. * @@ -85,6 +102,8 @@ export interface TrustedStep { */ export interface TrustedTransitionOptions { requires?: { authorities?: string[] }; + /** Foreground work that must complete before this transition is admitted. */ + work?: WorkContract; } /** @@ -154,6 +173,7 @@ export function trustedTransition( target: always, priority: step.priority, ...(options.requires ? { requires: options.requires } : {}), + ...(options.work ? { work: options.work.id } : {}), }); } diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index ee3f636..24c94f1 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -11,7 +11,7 @@ /** Canonical schema name emitted by {@link defineFlow}. */ export const CONTROL_PROGRAM_SCHEMA = "control-program" as const; /** Current revision of the canonical Control Program schema. */ -export const CONTROL_PROGRAM_SCHEMA_REVISION = 2 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 3 as const; /** * A declarative condition over runtime state facts. @@ -100,6 +100,49 @@ export interface OperatorDefinition { description?: string; } +/** + * A repository asset resolved and fingerprinted by the trusted compiler. + * + * Flow source supplies only {@link path}. Compiled artifacts also contain the + * exact UTF-8 bytes and SHA-256 used by the runtime; live runs never reread a + * mutable instruction or schema file. + */ +export interface WorkAssetDefinition { + path: string; + sha256?: string; + content?: string; +} + +/** Binds one foreground-work input to a declared entry input. */ +export interface WorkInputDefinition { + id: string; + entry_input: string; +} + +/** Declares one bounded staged output of foreground work. */ +export interface WorkArtifactDefinition { + id: string; + path: string; + media_type: string; + required: boolean; + max_bytes?: number; + schema?: WorkAssetDefinition; +} + +/** + * A bounded foreground-work requirement used by one or more transitions. + * + * This contract contains data only. It cannot grant authority, execute code, + * install a handler, or advance Flow state independently. + */ +export interface WorkContract { + id: string; + instructions: WorkAssetDefinition; + inputs: WorkInputDefinition[]; + outputs: WorkArtifactDefinition[]; + description?: string; +} + /** * Selects an operator when its guard is true and records its intended target * relation. Higher priority controls deterministic selection among admissible @@ -112,6 +155,7 @@ export interface TransitionDefinition { target: Predicate; priority: number; requires?: { authorities?: string[] }; + work?: string; description?: string; } @@ -167,6 +211,7 @@ export interface FlowDefinition { }; facets: FacetDefinition[]; evidence?: EvidenceDefinition[]; + work?: WorkContract[]; operators: OperatorDefinition[]; transitions: TransitionDefinition[]; targets: TargetDefinition[]; @@ -181,6 +226,7 @@ export interface ControlProgramIR { declarations: NonNullable; facets: FacetDefinition[]; evidence: EvidenceDefinition[]; + work: WorkContract[]; operators: OperatorDefinition[]; transitions: TransitionDefinition[]; targets: TargetDefinition[]; @@ -221,6 +267,7 @@ export function defineFlow(definition: FlowDefinition): ControlProgramIR { declarations: definition.declarations ?? {}, facets: definition.facets, evidence: definition.evidence ?? [], + work: definition.work ?? [], operators: definition.operators, transitions: definition.transitions, targets: definition.targets, @@ -229,6 +276,39 @@ export function defineFlow(definition: FlowDefinition): ControlProgramIR { }; } +/** Declares a repository-owned UTF-8 instruction asset. */ +export function instructionAsset(path: string): WorkAssetDefinition { + return { path }; +} + +/** Declares a repository-owned strict JSON Schema asset. */ +export function schemaAsset(path: string): WorkAssetDefinition { + return { path }; +} + +/** Binds a foreground-work input to one entry input ID. */ +export function entryInput(id: string): WorkInputDefinition { + return { id, entry_input: id }; +} + +/** Declares one bounded foreground-work output artifact. */ +export function workArtifact( + definition: WorkArtifactDefinition, +): WorkArtifactDefinition { + return { ...definition }; +} + +/** + * Declares one foreground-work contract inside a Flow. + * + * {@link defineFlow} remains the complete controller. This helper only defines + * bounded work that a selected transition may require before its trusted + * operator can be admitted. + */ +export function foregroundWork(definition: WorkContract): WorkContract { + return { ...definition }; +} + /** * Declares a typed state facet. * diff --git a/release-notes/2026-08-15-foreground-work.md b/release-notes/2026-08-15-foreground-work.md new file mode 100644 index 0000000..33cbf39 --- /dev/null +++ b/release-notes/2026-08-15-foreground-work.md @@ -0,0 +1,17 @@ +### Let repository Flows require bounded foreground work + +Flows can now declare exact instruction assets, entry inputs, and staged output +contracts for human or agent work. Boatstack suspends and resumes the same run, +validates the result as evidence, and still admits effects only through trusted +operators. The software-delivery adapter also provides optional planning-package +admission, approval, and promotion operations. + +Fresh delegated runs now bootstrap verified runtime and configuration state +before deriving repository-policy authority. An exact autonomy delegation may +perform that local initialization, while transitions requiring repository +authority remain unavailable until verified configuration evidence exists. + +Published-PR entries now stop until intended delivery changes are committed, +bind the preview and push to that exact commit, and derive short-lived GitHub +provider capability through the trusted runtime boundary. Caller-provided +provider receipts are rejected. From ba1c2c17d9ad7c59b93c9566e9b8d75b3b2ce47b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 15:25:30 +0100 Subject: [PATCH 2/7] Close foreground work validation gaps --- boatstack/flow/softwaredelivery/definition.go | 25 ++++++++ .../flow/softwaredelivery/definition_test.go | 32 ++++++++++ .../softwaredelivery/durable/state.go | 21 ++++++- .../durable/state_schema_test.go | 47 ++++++++++++++ .../softwaredelivery/effects/artifacts.go | 38 +++++++++++- .../effects/integration_test.go | 61 +++++++++++++++++++ .../effects/planning_package_test.go | 17 +++++- .../softwaredelivery/plant/observer.go | 37 ++++++----- .../softwaredelivery/plant/observer_test.go | 14 ++++- release-notes/2026-08-15-foreground-work.md | 5 ++ 10 files changed, 276 insertions(+), 21 deletions(-) diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index b8f4650..579d72c 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -53,12 +53,14 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim } objectives := map[model.TargetID]EntryObjective{} contracts := map[model.TargetID]delivery.ObjectiveContract{} + entriesByTarget := map[model.TargetID][]controlprogram.Entry{} for _, entry := range d.compiled.Document.Entries { objective, objectiveErr := objectiveContractForEntry(d.compiled, base, entry.ID) if objectiveErr != nil { return delivery.ProgramRuntimeManifest{}, objectiveErr } objectives[objective.TargetID], contracts[objective.TargetID] = objective, objective.Contract + entriesByTarget[objective.TargetID] = append(entriesByTarget[objective.TargetID], entry) } selected := make([]delivery.Transition, 0, len(d.compiled.Document.Transitions)) @@ -123,6 +125,9 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim transition.SelectionClass = delivery.SelectionProgramProgress } sort.Slice(transition.TargetIDs, func(i, j int) bool { return transition.TargetIDs[i] < transition.TargetIDs[j] }) + if err := requireReachableEntryInputs(transition, entriesByTarget); err != nil { + return delivery.ProgramRuntimeManifest{}, err + } selected = append(selected, transition) } if len(selected) == 0 { @@ -151,6 +156,26 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim return base, nil } +func requireReachableEntryInputs(transition delivery.Transition, entriesByTarget map[model.TargetID][]controlprogram.Entry) error { + if transition.Work == nil || len(transition.Work.Inputs) == 0 { + return nil + } + for _, targetID := range transition.TargetIDs { + for _, entry := range entriesByTarget[targetID] { + declared := map[string]bool{} + for _, input := range entry.Inputs { + declared[input.ID] = true + } + for _, input := range transition.Work.Inputs { + if !declared[input.EntryInput] { + return fmt.Errorf("transition %q foreground work %q requires entry input %q, but reachable entry %q does not declare it", transition.ID, transition.Work.ID, input.EntryInput, entry.ID) + } + } + } + } + return nil +} + func runtimeWorkContract(declaration controlprogram.WorkContract) (*delivery.WorkContract, error) { work := &delivery.WorkContract{ ID: declaration.ID, InstructionPath: declaration.Instructions.Path, diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 11f8dda..ca9fbf7 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -2,6 +2,8 @@ package softwaredelivery_test import ( "context" + "crypto/sha256" + "encoding/hex" "strings" "testing" @@ -237,6 +239,36 @@ func TestRepositoryTransitionMustMatchTrustedBindingIdentity(t *testing.T) { } } +func TestForegroundWorkInputsMustExistOnEveryReachableEntry(t *testing.T) { + // control-law: a selected entry cannot reach work whose inputs it cannot bind + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + document := compiled.Document + document.Entries = []controlprogram.Entry{ + {ID: "run", Target: "published-pr", Inputs: []controlprogram.EntryInput{{ID: "plan", Type: "markdown-file"}}}, + {ID: "retry", Target: "published-pr"}, + } + instructions := "Inspect the exact repository plan." + digest := sha256.Sum256([]byte(instructions)) + document.Work = []controlprogram.WorkContract{{ + ID: "planning", Instructions: controlprogram.WorkAsset{Path: "planning.md", SHA256: hex.EncodeToString(digest[:]), Content: instructions}, + Inputs: []controlprogram.WorkInput{{ID: "plan", EntryInput: "plan"}}, + Outputs: []controlprogram.WorkOutput{{ID: "result", Path: "result.md", MediaType: "text/markdown", Required: true}}, + }} + document.Transitions[0].Work = "planning" + unsafe, err := controlprogram.Compile(document, 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(), `reachable entry "retry" does not declare it`) { + t.Fatalf("reachable missing input result = %v", err) + } +} + func TestRepositoryTransitionCannotWidenTrustedTargetIDs(t *testing.T) { truth := true compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index dde8ffc..2280351 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -12,7 +12,16 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const StateSchemaVersion = 5 +const ( + StateSchemaVersion = 5 + priorStateSchemaVersion = 4 +) + +// CanReadStateSchema reports the current schema and its single supported +// forward-migration predecessor. Older or future schemas remain fail-closed. +func CanReadStateSchema(version int) bool { + return version == StateSchemaVersion || version == priorStateSchemaVersion +} type GateEvidence struct { Gate string `json:"gate"` @@ -198,6 +207,16 @@ func DecodeState(value []byte) (State, error) { if err := decoder.Decode(&trailing); err != io.EOF { return State{}, fmt.Errorf("durable state contains trailing JSON") } + if state.SchemaVersion == priorStateSchemaVersion { + // Schema 5 adds only planning-package state. Promote a genuine schema-4 + // value in memory so observation can select the transactional runtime + // update. The prior bytes remain the journal rollback source until that + // update commits. + if state.PlanningPackageFingerprint != "" || state.Plan == model.PlanPackageApproved { + return State{}, fmt.Errorf("durable state schema %d contains schema %d planning-package fields", priorStateSchemaVersion, StateSchemaVersion) + } + state.SchemaVersion = StateSchemaVersion + } if err := state.Validate(); err != nil { return State{}, err } diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index bcb820b..84c00b8 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -1,6 +1,8 @@ package durable import ( + "bytes" + "encoding/json" "testing" "time" @@ -34,3 +36,48 @@ func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *test t.Fatalf("legacy approval fingerprint = %q", decoded.ApprovalFingerprint) } } + +func TestDecodeStatePromotesSchemaFourWithoutChangingPriorBytes(t *testing.T) { + // control-law: forward state migration is read-only until a transaction commits + state := State{ + SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 7, + Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, + Plan: model.PlanApproved, Configuration: model.ConfigurationUnsupported, Runtime: model.RuntimeAbsent, Publication: model.PublicationNone, + Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, + Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"}, PlanFingerprint: "legacy-plan", UpdatedAt: time.Unix(1, 0).UTC(), + } + current, err := EncodeState(state) + if err != nil { + t.Fatal(err) + } + var legacy map[string]any + if err := json.Unmarshal(current, &legacy); err != nil { + t.Fatal(err) + } + legacy["schema_version"] = float64(priorStateSchemaVersion) + delete(legacy, "planning_package_fingerprint") + prior, err := json.MarshalIndent(legacy, "", " ") + if err != nil { + t.Fatal(err) + } + prior = append(prior, '\n') + rollback := append([]byte(nil), prior...) + + decoded, err := DecodeState(prior) + if err != nil { + t.Fatal(err) + } + if decoded.SchemaVersion != StateSchemaVersion || decoded.Revision != state.Revision || decoded.Objective != state.Objective || decoded.PlanFingerprint != state.PlanFingerprint { + t.Fatalf("promoted state = %#v", decoded) + } + if !bytes.Equal(prior, rollback) { + t.Fatal("schema promotion changed the prior rollback bytes") + } +} + +func TestDecodeStateRejectsSchemaFourWithSchemaFivePlanningState(t *testing.T) { + raw := []byte(`{"schema_version":4,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"package-approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"planning_package_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","updated_at":"1970-01-01T00:00:01Z"}`) + if _, err := DecodeState(raw); err == nil { + t.Fatal("schema-4 state smuggled schema-5 planning fields") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index d464b14..9799a4b 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -522,7 +522,8 @@ func planningPackageOutputPath(root, relative string) (string, error) { } func loadPlanningPackageManifest(artifactRoot, deliveryID string) (planningPackageManifest, []byte, error) { - path := filepath.Join(artifactRoot, "planning-packages", deliveryID, "manifest.json") + packageRoot := filepath.Join(artifactRoot, "planning-packages", deliveryID) + path := filepath.Join(packageRoot, "manifest.json") raw, err := os.ReadFile(path) if err != nil { return planningPackageManifest{}, nil, fmt.Errorf("read planning package manifest: %w", err) @@ -537,9 +538,44 @@ func loadPlanningPackageManifest(artifactRoot, deliveryID string) (planningPacka if err != nil || sha256Bytes(identityRaw) != manifest.Fingerprint { return planningPackageManifest{}, nil, fmt.Errorf("planning package manifest fingerprint is invalid") } + if err := validatePlanningPackageOutputs(packageRoot, manifest); err != nil { + return planningPackageManifest{}, nil, err + } return manifest, raw, nil } +func validatePlanningPackageOutputs(packageRoot string, manifest planningPackageManifest) error { + seenIDs, seenPaths := map[string]bool{}, map[string]bool{} + planFound := false + for _, output := range manifest.Outputs { + if output.ID == "" || output.MediaType == "" || len(output.SHA256) != 64 || output.Size < 0 || seenIDs[output.ID] || seenPaths[output.Path] { + return fmt.Errorf("planning package manifest output is invalid") + } + seenIDs[output.ID], seenPaths[output.Path] = true, true + path, err := planningPackageOutputPath(packageRoot, output.Path) + if err != nil { + return err + } + raw, err := readRegularWorkspacePlanArtifact(path) + if err != nil { + return fmt.Errorf("planning package output %q: %w", output.ID, err) + } + if int64(len(raw)) != output.Size || sha256Bytes(raw) != output.SHA256 { + return fmt.Errorf("planning package output %q changed after admission", output.ID) + } + if output.ID == "plan" { + planFound = true + if output.SHA256 != manifest.PlanFingerprint { + return fmt.Errorf("planning package plan does not match the manifest") + } + } + } + if !planFound { + return fmt.Errorf("planning package manifest has no plan output") + } + return nil +} + func authorityActor(admission protocol.Admission) string { for _, receipt := range admission.Authority.Receipts { if receipt.Subject != "" { diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 3e29478..a4adb8d 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -20,6 +20,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/flow/standard" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/engine" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -511,6 +512,41 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if !bytes.Equal(afterSuccess, afterReplay) { t.Fatal("rejected repeated reconciliation mutated durable state") } + priorState, err := durable.DecodeState(afterSuccess) + if err != nil { + t.Fatal(err) + } + var legacyState map[string]any + if err := json.Unmarshal(afterSuccess, &legacyState); err != nil { + t.Fatal(err) + } + legacyState["schema_version"] = float64(durable.StateSchemaVersion - 1) + delete(legacyState, "planning_package_fingerprint") + legacyRaw, err := json.MarshalIndent(legacyState, "", " ") + if err != nil { + t.Fatal(err) + } + legacyRaw = append(legacyRaw, '\n') + if err := os.WriteFile(layout.StatePath, legacyRaw, 0o600); err != nil { + t.Fatal(err) + } + legacyPinPath := boatstackruntime.PinPath(repository) + legacyPinRaw, err := os.ReadFile(legacyPinPath) + if err != nil { + t.Fatal(err) + } + legacyPin, err := boatstackruntime.DecodePin(legacyPinRaw) + if err != nil { + t.Fatal(err) + } + legacyPin.StateSchemaVersion = durable.StateSchemaVersion - 1 + legacyPinRaw, err = boatstackruntime.EncodePin(legacyPin) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacyPinPath, legacyPinRaw, 0o600); err != nil { + t.Fatal(err) + } updateRequest := surfaces.Request{ SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-current-update", FlowID: "flow-program-drift", Objective: model.Objective{ID: "ignored-command-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "ignored"}, @@ -526,6 +562,31 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if updated.Snapshot == nil || updated.Snapshot.Objective.Status != model.FactAbsent || updated.Receipt == nil || updated.Receipt.ObjectiveStatus != model.FactAbsent || updated.Receipt.ObjectiveID != "" { t.Fatalf("reconcile to update composition invented product intent: %#v", updated) } + updatedRaw, err := os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + updatedState, err := durable.DecodeState(updatedRaw) + if err != nil { + t.Fatal(err) + } + if updatedState.SchemaVersion != durable.StateSchemaVersion || updatedState.Objective != priorState.Objective || updatedState.Engagement != priorState.Engagement || + updatedState.Delivery != priorState.Delivery || updatedState.Workspace != priorState.Workspace || updatedState.Plan != priorState.Plan || + updatedState.Configuration != priorState.Configuration || updatedState.Publication != priorState.Publication || updatedState.Verification != priorState.Verification || + updatedState.Terminal != priorState.Terminal || updatedState.PlanFingerprint != priorState.PlanFingerprint || updatedState.ApprovalFingerprint != priorState.ApprovalFingerprint { + t.Fatalf("schema-4 update changed existing product facets: before=%#v after=%#v", priorState, updatedState) + } + updatedPinRaw, err := os.ReadFile(legacyPinPath) + if err != nil { + t.Fatal(err) + } + updatedPin, err := boatstackruntime.DecodePin(updatedPinRaw) + if err != nil { + t.Fatal(err) + } + if updatedPin.StateSchemaVersion != durable.StateSchemaVersion { + t.Fatalf("runtime update left prior state schema in the pin: %#v", updatedPin) + } } func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *testing.T) { diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index 2f0e40e..f724ac2 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -53,6 +53,16 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactWorkEvidence(t *testing.T) { ID: "adm-approve", Objective: objective, IssuedAt: now.Add(time.Minute), Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: manifest.Fingerprint}}, Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer"}}}, } + featurePath := filepath.Join(repository, ".boatstack", "planning-packages", "delivery", "feature-spec.md") + if err := os.WriteFile(featurePath, []byte("tampered"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := prepareArtifacts(layout, admission, approve, &state); err == nil || !strings.Contains(err.Error(), `output "feature-spec" changed after admission`) { + t.Fatalf("changed non-plan output approval result = %v", err) + } + if err := os.WriteFile(featurePath, []byte(feature), 0o600); err != nil { + t.Fatal(err) + } if err := applyStateTransition(&state, admission, approve); err != nil { t.Fatal(err) } @@ -91,13 +101,18 @@ func TestPlanningPackageApprovalRejectsFingerprintDrift(t *testing.T) { if err := os.MkdirAll(manifestRoot, 0o700); err != nil { t.Fatal(err) } - manifest := planningPackageManifest{SchemaVersion: 1, DeliveryID: "delivery", WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), PlanFingerprint: strings.Repeat("c", 64), Outputs: []planningPackageOutput{{ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: strings.Repeat("c", 64), Size: 1}}} + plan := []byte("p") + planFingerprint := sha256Bytes(plan) + manifest := planningPackageManifest{SchemaVersion: 1, DeliveryID: "delivery", WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), PlanFingerprint: planFingerprint, Outputs: []planningPackageOutput{{ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: planFingerprint, Size: int64(len(plan))}}} identityRaw, _ := encodeJSON(manifest) manifest.Fingerprint = sha256Bytes(identityRaw) raw, _ := encodeJSON(manifest) if err := os.WriteFile(filepath.Join(manifestRoot, "manifest.json"), raw, 0o600); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(manifestRoot, "plan.md"), plan, 0o600); err != nil { + t.Fatal(err) + } state := durable.State{Plan: model.PlanPackageApproved} _, err := prepareArtifacts(ports.ControllerLayout{RepositoryRoot: repository}, protocol.Admission{ ID: "adm", Objective: model.Objective{DeliveryID: "delivery"}, Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: strings.Repeat("d", 64)}}, diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index fd15c19..14aaf52 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -110,7 +110,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) return model.Observation{}, readPinErr } pin, decodePinErr := boatstackruntime.DecodePin(pinRaw) - if decodePinErr == nil && pin.StateSchemaVersion == durable.StateSchemaVersion && + if decodePinErr == nil && durable.CanReadStateSchema(pin.StateSchemaVersion) && pin.Version == current.RuntimeVersion && pin.SHA256 == current.RuntimeFingerprint { home, homeErr := boatstackruntime.Home("") if homeErr != nil { @@ -144,7 +144,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) } pin, decodePinErr := boatstackruntime.DecodePin(pinRaw) identity := boatstackruntime.Identity{Version: state.RuntimeVersion, SHA256: state.RuntimeFingerprint, SourceRevision: state.RuntimeSource} - if decodePinErr != nil || pin.Identity() != identity || pin.ProgramFingerprint != state.ProgramFingerprint || pin.StateSchemaVersion != durable.StateSchemaVersion { + if decodePinErr != nil || pin.Identity() != identity || pin.ProgramFingerprint != state.ProgramFingerprint || !durable.CanReadStateSchema(pin.StateSchemaVersion) { runtimeState = model.RuntimeConflicting } else { home, homeErr := boatstackruntime.Home("") @@ -696,29 +696,36 @@ func observePlanningPackage(layout ports.ControllerLayout, state durable.State, identityRaw = append(identityRaw, '\n') valid = hashBytes(identityRaw) == manifest.Fingerprint && manifestFileFingerprint == hashBytes(manifestRaw) } - planPath := "" - seen := map[string]bool{} + planFound := false + seen, seenPaths := map[string]bool{}, map[string]bool{} for _, output := range manifest.Outputs { clean := filepath.Clean(filepath.FromSlash(output.Path)) if output.ID == "" || output.Path == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || - output.MediaType == "" || len(output.SHA256) != 64 || output.Size < 0 || seen[output.ID] { + filepath.ToSlash(clean) != output.Path || output.MediaType == "" || len(output.SHA256) != 64 || output.Size < 0 || seen[output.ID] || seenPaths[clean] { valid = false continue } - seen[output.ID] = true + seen[output.ID], seenPaths[clean] = true, true + outputPath := filepath.Join(root, clean) + outputEvidence, fingerprint, exists, outputErr := fileEvidence(outputPath, "planning-package-output-"+output.ID, now) + evidence = append(evidence, outputEvidence) + if outputErr != nil { + return evidence, false, outputErr + } + info, statErr := os.Lstat(outputPath) + if statErr != nil && !os.IsNotExist(statErr) { + return evidence, false, statErr + } + regular := statErr == nil && info.Mode().IsRegular() + sizeMatches := regular && info.Size() == output.Size + valid = valid && exists && regular && sizeMatches && fingerprint == output.SHA256 if output.ID == "plan" { - planPath = clean + planFound = true + valid = valid && output.SHA256 == manifest.PlanFingerprint } } - if planPath == "" { + if !planFound { valid = false - } else { - planEvidence, fingerprint, planExists, planErr := fileEvidence(filepath.Join(root, planPath), "planning-package-plan", now) - evidence = append(evidence, planEvidence) - if planErr != nil { - return evidence, false, planErr - } - valid = valid && planExists && fingerprint == manifest.PlanFingerprint } if state.Plan == model.PlanPackageApproved { approvalPath := filepath.Join(root, "approval.json") diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index 4c43796..6767f52 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -30,10 +30,15 @@ func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t t.Fatal(err) } plan := []byte("# Proposed plan\n") + feature := []byte("# Feature specification\n") planFingerprint := hashBytes(plan) + featureFingerprint := hashBytes(feature) manifest := observedPlanningPackageManifest{ SchemaVersion: 1, DeliveryID: deliveryID, WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), - PlanFingerprint: planFingerprint, Outputs: []observedPlanningPackageOutput{{ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: planFingerprint, Size: int64(len(plan))}}, + PlanFingerprint: planFingerprint, Outputs: []observedPlanningPackageOutput{ + {ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: planFingerprint, Size: int64(len(plan))}, + {ID: "feature-spec", Path: "feature-spec.md", MediaType: "text/markdown", SHA256: featureFingerprint, Size: int64(len(feature))}, + }, } identityRaw, err := json.MarshalIndent(manifest, "", " ") if err != nil { @@ -48,6 +53,9 @@ func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t if err := os.WriteFile(filepath.Join(root, "plan.md"), plan, 0o600); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(root, "feature-spec.md"), feature, 0o600); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(root, "manifest.json"), manifestRaw, 0o600); err != nil { t.Fatal(err) } @@ -56,13 +64,13 @@ func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: deliveryID}, } evidence, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(100, 0).UTC()) - if err != nil || !valid || len(evidence) != 2 { + if err != nil || !valid || len(evidence) != 3 { t.Fatalf("planning package observation valid=%t evidence=%#v err=%v", valid, evidence, err) } if _, err := os.Stat(filepath.Join(repository, ".boatstack", "plans", deliveryID+".source")); !os.IsNotExist(err) { t.Fatalf("admission prematurely created a canonical plan: %v", err) } - if err := os.WriteFile(filepath.Join(root, "plan.md"), []byte("tampered"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(root, "feature-spec.md"), []byte("tampered"), 0o600); err != nil { t.Fatal(err) } if _, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(101, 0).UTC()); err != nil || valid { diff --git a/release-notes/2026-08-15-foreground-work.md b/release-notes/2026-08-15-foreground-work.md index 33cbf39..deedb10 100644 --- a/release-notes/2026-08-15-foreground-work.md +++ b/release-notes/2026-08-15-foreground-work.md @@ -6,6 +6,11 @@ validates the result as evidence, and still admits effects only through trusted operators. The software-delivery adapter also provides optional planning-package admission, approval, and promotion operations. +The runtime performs a one-step, transactional schema-4 state upgrade and +verifies every declared planning-package output before approval. Trusted +software-delivery lowering also rejects work inputs that any reachable entry +cannot bind. + Fresh delegated runs now bootstrap verified runtime and configuration state before deriving repository-policy authority. An exact autonomy delegation may perform that local initialization, while transitions requiring repository From 34589c42bd796b3477a4e08703e52bfa8d5cf988 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 15:47:32 +0100 Subject: [PATCH 3/7] Bind foreground work and provider authority exactly --- .../boatstack-helper/delegation_command.go | 38 +++++++++++- .../cmd/boatstack-helper/flow_runtime.go | 39 +++++++----- .../cmd/boatstack-helper/flow_runtime_test.go | 18 +++--- boatstack/cmd/boatstack-helper/main.go | 14 +++-- boatstack/cmd/boatstack-helper/main_test.go | 42 ++++++++----- .../foregroundwork/manager.go | 20 +++--- .../foregroundwork/manager_test.go | 48 +++++++++++--- .../softwaredelivery/protocol/work.go | 15 +++++ .../softwaredelivery/surfaces/protocol.go | 62 +++++++++++-------- release-notes/2026-08-15-foreground-work.md | 10 +-- 10 files changed, 212 insertions(+), 94 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index aec6bdd..06adf31 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -10,6 +10,7 @@ import ( "os" "time" + "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" @@ -23,6 +24,22 @@ var resolveGitHubProviderAuthority = func(ctx context.Context, repository, previ return effects.NewNativeBoundary().ResolveGitHubProviderAuthority(ctx, repository, previewFingerprint, now) } +func trustedProviderAuthorityParameter(ctx context.Context, transitionID string) (string, error) { + if transitionID == "" { + return "", nil + } + manifest, err := standard.Definition().RuntimeManifest(ctx) + if err != nil { + return "", err + } + for _, transition := range manifest.Transitions { + if string(transition.ID) == transitionID { + return transition.AuthorityFingerprintParameter, nil + } + } + return "", nil +} + func runFlowAuthorize(arguments []string) error { flags := flag.NewFlagSet("flow authorize", flag.ContinueOnError) flags.SetOutput(os.Stderr) @@ -348,15 +365,30 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa func bindTrustedProviderCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) { if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || (response.Decision.Kind != supervisor.DecisionFrontier && response.Decision.Kind != supervisor.DecisionCandidate) || - len(response.Decision.Candidates) != 1 || response.Decision.Candidates[0] != "publication.execute" { + len(response.Decision.Candidates) != 1 { + return bound, false, nil + } + candidate := string(response.Decision.Candidates[0]) + authorityParameter, err := trustedProviderAuthorityParameter(ctx, candidate) + if err != nil { + return commandOptions{}, false, err + } + if authorityParameter == "" { return bound, false, nil } rebound := bound - rebound.transitionID = "publication.execute" - rebound, err := bindFlowEntry(ctx, rebound) + rebound.transitionID = candidate + rebound, err = bindFlowEntry(ctx, rebound) if err != nil { return commandOptions{}, false, err } + parameters, err := parseParameters(rebound.parameters) + if err != nil { + return commandOptions{}, false, err + } + if fingerprint, ok := parameters.Get(authorityParameter); !ok || fingerprint == "" { + return bound, false, nil + } return rebound, true, nil } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index ca00d11..eb51347 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -81,12 +81,6 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err != nil { return commandOptions{}, err } - options.workInputs = map[string]string{} - for _, input := range entry.Inputs { - if plan != "" { - options.workInputs[input.ID] = plan - } - } planFingerprint := "" if plan != "" { planRaw, readErr := os.ReadFile(plan) @@ -102,6 +96,12 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } else if options.runID == "" { return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active abandonment has no committed run identity") } + options.workInputs = map[string]protocol.WorkInputValue{} + for _, input := range entry.Inputs { + if plan != "" { + options.workInputs[input.ID] = protocol.WorkInputValue{Value: plan, Fingerprint: planFingerprint} + } + } options.repository = repository if options.targetID == "" { options.targetID = string(objective.TargetID) @@ -430,20 +430,27 @@ func bindStateOwnedTransitionParameters(options commandOptions, state durable.St if err != nil { return commandOptions{}, err } - var name, value string + bindings := [][2]string{} switch options.transitionID { case "workspace.activate", "workspace.sync", "workspace.publish": - name, value = "branch", state.WorkspaceBranch + bindings = append(bindings, [2]string{"branch", state.WorkspaceBranch}) case "publication.execute": - name, value = "preview_fingerprint", state.PreviewFingerprint + bindings = append(bindings, [2]string{"preview_fingerprint", state.PreviewFingerprint}) case "publication.observe": - name, value = "publication_id", state.PublicationID - } - if name == "" || value == "" { - return options, nil - } - if err := bindResolvedParameter(&options, parameters, name, value); err != nil { - return commandOptions{}, err + bindings = append(bindings, [2]string{"publication_id", state.PublicationID}) + case "publication.reconcile": + bindings = append(bindings, + [2]string{"publication_id", state.PublicationID}, + [2]string{"transaction_id", state.TransactionID}, + ) + } + for _, binding := range bindings { + if binding[1] == "" { + continue + } + if err := bindResolvedParameter(&options, parameters, binding[0], binding[1]); err != nil { + return commandOptions{}, err + } } return options, nil } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 63b0bd8..62a589e 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1316,17 +1316,19 @@ func TestStateOwnedTransitionParametersDoNotRequireHumanAnswers(t *testing.T) { WorkspaceBranch: "feat/exact-branch", PreviewFingerprint: strings.Repeat("a", 64), PublicationID: "123", - } - for transition, expected := range map[string]string{ - "workspace.activate": "branch=feat/exact-branch", - "workspace.sync": "branch=feat/exact-branch", - "workspace.publish": "branch=feat/exact-branch", - "publication.execute": "preview_fingerprint=" + strings.Repeat("a", 64), - "publication.observe": "publication_id=123", + TransactionID: "adm-123", + } + for transition, expected := range map[string][]string{ + "workspace.activate": {"branch=feat/exact-branch"}, + "workspace.sync": {"branch=feat/exact-branch"}, + "workspace.publish": {"branch=feat/exact-branch"}, + "publication.execute": {"preview_fingerprint=" + strings.Repeat("a", 64)}, + "publication.observe": {"publication_id=123"}, + "publication.reconcile": {"publication_id=123", "transaction_id=adm-123"}, } { t.Run(transition, func(t *testing.T) { bound, err := bindStateOwnedTransitionParameters(commandOptions{transitionID: transition}, state) - if err != nil || len(bound.parameters) != 1 || bound.parameters[0] != expected { + if err != nil || strings.Join(bound.parameters, "\x00") != strings.Join(expected, "\x00") { t.Fatalf("state-owned binding = %#v, %v", bound.parameters, err) } }) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index ed89e7e..7ae5d4e 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -77,7 +77,7 @@ type commandOptions struct { delegationAuthorities stringList delegationDescription string delegationRequest delegation.Request - workInputs map[string]string + workInputs map[string]protocol.WorkInputValue workID string workQuestionPrompt string workQuestionSchemaPath string @@ -572,10 +572,14 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface if err != nil { return surfaces.Request{}, err } - if options.transitionID == "publication.execute" { - previewFingerprint, ok := parameters.Get("preview_fingerprint") - if ok && previewFingerprint != "" { - receipt, resolveErr := resolveGitHubProviderAuthority(context.Background(), options.repository, previewFingerprint, now) + authorityParameter, resolveErr := trustedProviderAuthorityParameter(context.Background(), options.transitionID) + if resolveErr != nil { + return surfaces.Request{}, resolveErr + } + if authorityParameter != "" { + fingerprint, ok := parameters.Get(authorityParameter) + if ok && fingerprint != "" { + receipt, resolveErr := resolveGitHubProviderAuthority(context.Background(), options.repository, fingerprint, now) if resolveErr != nil { return surfaces.Request{}, resolveErr } diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index 0617d6b..268c986 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -105,7 +105,7 @@ func TestCallerCannotSupplyExternalProviderAuthority(t *testing.T) { } } -func TestPublicationRequestDerivesStableTrustedProviderAuthority(t *testing.T) { +func TestPublicationRequestsDeriveTrustedProviderAuthorityFromCatalogBinding(t *testing.T) { prior := resolveGitHubProviderAuthority resolveGitHubProviderAuthority = func(_ context.Context, _ string, fingerprint string, now time.Time) (protocol.AuthorityReceipt, error) { return protocol.AuthorityReceipt{ @@ -114,22 +114,36 @@ func TestPublicationRequestDerivesStableTrustedProviderAuthority(t *testing.T) { }, nil } t.Cleanup(func() { resolveGitHubProviderAuthority = prior }) - options := commandOptions{ - repository: ".", transitionID: "publication.execute", - parameters: stringList{"preview_fingerprint=" + strings.Repeat("a", 64)}, - } - one, err := buildRequest(surfaces.OperationResolve, options) - if err != nil { - t.Fatal(err) - } - two, err := buildRequest(surfaces.OperationApply, options) + for transition, parameter := range map[string]string{ + "publication.execute": "preview_fingerprint=" + strings.Repeat("a", 64), + "publication.correct": "body_sha256=" + strings.Repeat("b", 64), + "publication.reconcile": "publication_id=123", + } { + t.Run(transition, func(t *testing.T) { + options := commandOptions{repository: ".", transitionID: transition, parameters: stringList{parameter}} + one, err := buildRequest(surfaces.OperationResolve, options) + if err != nil { + t.Fatal(err) + } + two, err := buildRequest(surfaces.OperationApply, options) + if err != nil { + t.Fatal(err) + } + oneFingerprint, _ := one.Authority.Fingerprint() + twoFingerprint, _ := two.Authority.Fingerprint() + if len(one.Authority.Receipts) != 1 || one.Authority.Receipts[0].Class != catalog.AuthorityProvider || one.Authority.Receipts[0].Fingerprint != strings.SplitN(parameter, "=", 2)[1] || oneFingerprint != twoFingerprint { + t.Fatalf("provider authority was not catalog-bound and stable: one=%#v two=%#v", one.Authority, two.Authority) + } + }) + } + request, err := buildRequest(surfaces.OperationResolve, commandOptions{ + repository: ".", transitionID: "publication.observe", parameters: stringList{"publication_id=123"}, + }) if err != nil { t.Fatal(err) } - oneFingerprint, _ := one.Authority.Fingerprint() - twoFingerprint, _ := two.Authority.Fingerprint() - if len(one.Authority.Receipts) != 1 || one.Authority.Receipts[0].Class != catalog.AuthorityProvider || oneFingerprint != twoFingerprint { - t.Fatalf("provider authority was not stable: one=%#v two=%#v", one.Authority, two.Authority) + if len(request.Authority.Receipts) != 0 { + t.Fatalf("transition without a trusted provider binding derived authority: %#v", request.Authority) } } diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager.go b/boatstack/internal/softwaredelivery/foregroundwork/manager.go index d604ef3..e193274 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager.go @@ -111,7 +111,7 @@ func NewManager(resolver ports.InvocationResolver, locker ports.Locker, clock po return Manager{resolver: resolver, locker: locker, clock: clock, store: store}, nil } -func (m Manager) Ensure(ctx context.Context, invocation model.InvocationContext, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]string) (Record, error) { +func (m Manager) Ensure(ctx context.Context, invocation model.InvocationContext, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]protocol.WorkInputValue) (Record, error) { if transition.Work == nil || runID == "" || programID == "" || entryID == "" { return Record{}, fmt.Errorf("foreground work request requires run, program, entry, and contract") } @@ -285,18 +285,17 @@ func (m Manager) mutate(ctx context.Context, invocation model.InvocationContext, return next, nil } -func newRequest(store ports.RuntimeStore, layout ports.ControllerLayout, now time.Time, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]string) (Request, error) { +func newRequest(store ports.RuntimeStore, layout ports.ControllerLayout, now time.Time, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]protocol.WorkInputValue) (Request, error) { work := *transition.Work bindings := make([]InputBinding, 0, len(work.Inputs)) for _, input := range work.Inputs { value, ok := values[input.EntryInput] - if !ok || strings.TrimSpace(value) == "" { + if !ok || value.Validate() != nil { return Request{}, fmt.Errorf("foreground work input %q is not bound by entry input %q", input.ID, input.EntryInput) } - bindings = append(bindings, InputBinding{ID: input.ID, EntryInput: input.EntryInput, Value: value, Fingerprint: digest([]byte(value))}) + bindings = append(bindings, InputBinding{ID: input.ID, EntryInput: input.EntryInput, Value: value.Value, Fingerprint: value.Fingerprint}) } sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) - staging := filepath.Join(layout.FlowRoot, "work", runID, work.ID, "staging") contextFingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) if err != nil { return Request{}, fmt.Errorf("foreground work context fingerprint: %w", err) @@ -305,15 +304,17 @@ func newRequest(store ports.RuntimeStore, layout ports.ControllerLayout, now tim RunID: runID, ProgramID: programID, EntryID: entryID, Objective: objective, TransitionID: transition.ID, Contract: work, Inputs: bindings, RepositoryID: snapshot.Invocation.RepositoryID, GitCommonID: snapshot.Invocation.GitCommonID, WorktreeID: snapshot.Invocation.WorktreeID, Ref: snapshot.Invocation.Ref, ProgramFingerprint: snapshot.ProgramFingerprint, ContextFingerprint: contextFingerprint, StateRevision: snapshot.StateRevision, - InstructionContent: work.InstructionContent, StagingRoot: staging, CreatedAt: now.UTC(), + InstructionContent: work.InstructionContent, CreatedAt: now.UTC(), } identity := request - identity.ID, identity.Fingerprint, identity.CreatedAt = "", "", time.Time{} + identity.ID, identity.Fingerprint, identity.StagingRoot, identity.CreatedAt = "", "", "", time.Time{} fingerprint, err := general.Fingerprint(identity) if err != nil { return Request{}, err } request.Fingerprint, request.ID = fingerprint, "work-"+fingerprint[:24] + staging := filepath.Join(layout.FlowRoot, "work", runID, work.ID, "requests", fingerprint, "staging") + request.StagingRoot = staging if err := store.EnsureDirectory(staging, 0o700); err != nil { return Request{}, err } @@ -444,9 +445,10 @@ func load(path string) (Record, error) { } identity := record.Request wantID, wantFingerprint := identity.ID, identity.Fingerprint - identity.ID, identity.Fingerprint, identity.CreatedAt = "", "", time.Time{} + identity.ID, identity.Fingerprint, identity.StagingRoot, identity.CreatedAt = "", "", "", time.Time{} fingerprint, err := general.Fingerprint(identity) - if err != nil || fingerprint != wantFingerprint || wantID != "work-"+fingerprint[:24] { + expectedStaging := filepath.Join(filepath.Dir(path), "requests", fingerprint, "staging") + if err != nil || fingerprint != wantFingerprint || wantID != "work-"+fingerprint[:24] || record.Request.StagingRoot != expectedStaging { return Record{}, fmt.Errorf("foreground work request fingerprint is invalid") } if record.Result != nil { diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go index 9953da0..2e15fb9 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go @@ -14,6 +14,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" general "github.com/operatorstack/boatstack/boatstack/kernel" ) @@ -34,6 +35,10 @@ func invocation() model.InvocationContext { return model.InvocationContext{RepositoryID: "repository", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main"} } +func workInputs(value, fingerprint string) map[string]protocol.WorkInputValue { + return map[string]protocol.WorkInputValue{"incident": {Value: value, Fingerprint: fingerprint}} +} + func fixture(t *testing.T) (foregroundwork.Manager, model.Snapshot, catalog.Transition, string) { t.Helper() root := t.TempDir() @@ -74,7 +79,7 @@ func TestForegroundWorkQuestionCompletionAndDrift(t *testing.T) { manager, snapshot, transition, _ := fixture(t) ctx := context.Background() objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} - record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))) if err != nil { t.Fatal(err) } @@ -104,7 +109,7 @@ func TestForegroundWorkQuestionCompletionAndDrift(t *testing.T) { t.Fatalf("completion = %#v err=%v", record, err) } snapshot.StateRevision++ - record, err = manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + record, err = manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))) if err != nil || record.Status != foregroundwork.StatusRequested || len(record.Events) < 4 || record.Events[len(record.Events)-2].Kind != "work.invalidated" { t.Fatalf("drift reset = %#v err=%v", record, err) } @@ -115,7 +120,7 @@ func TestForegroundWorkSurvivesInvocationLocalRestartIdentity(t *testing.T) { manager, snapshot, transition, _ := fixture(t) ctx := context.Background() objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} - first, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + first, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))) if err != nil { t.Fatal(err) } @@ -125,7 +130,7 @@ func TestForegroundWorkSurvivesInvocationLocalRestartIdentity(t *testing.T) { restarted.Invocation.Correlation = "correlation-after-restart" restarted.Invocation.RuntimePath = "/different/immutable/runtime/location" restarted.Fingerprint = strings.Repeat("e", 64) - second, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, map[string]string{"incident": "incident.json"}) + second, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, workInputs("incident.json", strings.Repeat("e", 64))) if err != nil { t.Fatal(err) } @@ -133,7 +138,7 @@ func TestForegroundWorkSurvivesInvocationLocalRestartIdentity(t *testing.T) { t.Fatalf("restart invalidated stable work: before=%#v after=%#v", first.Request, second.Request) } restarted.Invocation.Ref = "refs/heads/other" - third, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, map[string]string{"incident": "incident.json"}) + third, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, workInputs("incident.json", strings.Repeat("e", 64))) if err != nil { t.Fatal(err) } @@ -142,12 +147,39 @@ func TestForegroundWorkSurvivesInvocationLocalRestartIdentity(t *testing.T) { } } +func TestForegroundWorkInputFingerprintInvalidatesOutputsAndStaging(t *testing.T) { + // control-law: same-locator input changes cannot reuse foreground-work outputs + manager, snapshot, transition, _ := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + first, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("1", 64))) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(first.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":"first input"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err != nil { + t.Fatal(err) + } + second, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("2", 64))) + if err != nil { + t.Fatal(err) + } + if second.Status != foregroundwork.StatusRequested || second.Request.Fingerprint == first.Request.Fingerprint || second.Request.StagingRoot == first.Request.StagingRoot { + t.Fatalf("input change reused request identity or staging: first=%#v second=%#v", first.Request, second.Request) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil || !strings.Contains(err.Error(), "no such file") { + t.Fatalf("invalidated output completed new request: %v", err) + } +} + func TestForegroundWorkRejectsMissingInvalidAndEscapingOutputs(t *testing.T) { // control-law: only declared bounded regular staged outputs become work evidence manager, snapshot, transition, root := fixture(t) ctx := context.Background() objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} - record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))) if err != nil { t.Fatal(err) } @@ -180,7 +212,7 @@ func TestForegroundWorkConcurrentCompletionIsSerialized(t *testing.T) { manager, snapshot, transition, _ := fixture(t) ctx := context.Background() objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} - record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}) + record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))) if err != nil { t.Fatal(err) } @@ -215,7 +247,7 @@ func TestForegroundWorkRejectsTamperedRuntimeRecord(t *testing.T) { manager, snapshot, transition, root := fixture(t) ctx := context.Background() objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} - if _, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]string{"incident": "incident.json"}); err != nil { + if _, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))); err != nil { t.Fatal(err) } path := filepath.Join(root, "flow", "work", "run-1", "diagnose", "record.json") diff --git a/boatstack/internal/softwaredelivery/protocol/work.go b/boatstack/internal/softwaredelivery/protocol/work.go index 0ce2755..339e271 100644 --- a/boatstack/internal/softwaredelivery/protocol/work.go +++ b/boatstack/internal/softwaredelivery/protocol/work.go @@ -13,6 +13,21 @@ import ( const WorkEvidenceSchemaVersion = 2 +// WorkInputValue binds the value presented to foreground work to the exact +// bytes selected by the trusted entry-input resolver. Value is an ergonomic +// locator or label; Fingerprint is the immutable execution identity. +type WorkInputValue struct { + Value string `json:"value"` + Fingerprint string `json:"fingerprint"` +} + +func (v WorkInputValue) Validate() error { + if v.Value == "" || !validSHA256(v.Fingerprint) { + return fmt.Errorf("foreground work input requires a value and exact fingerprint") + } + return nil +} + // WorkOutputEvidence is a verified, bounded foreground-work output. Content is // carried into admission so effects never trust a mutable staging path. type WorkOutputEvidence struct { diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 255abcf..d9dd373 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -50,33 +50,33 @@ func (o Operation) Valid() bool { } type Request struct { - SchemaVersion int `json:"schema_version"` - Operation Operation `json:"operation"` - Repository string `json:"repository"` - Host string `json:"host"` - CorrelationID string `json:"correlation_id"` - ProgramID string `json:"program_id,omitempty"` - ProgramFingerprint string `json:"program_fingerprint,omitempty"` - EntryID string `json:"entry_id,omitempty"` - FlowID string `json:"flow_id,omitempty"` - Objective model.Objective `json:"objective,omitempty"` - TransitionID catalog.TransitionID `json:"transition_id,omitempty"` - Prescription protocol.Prescription `json:"prescription,omitempty"` - Authority protocol.AuthorityBundle `json:"authority,omitempty"` - RepositoryAuthority bool `json:"repository_authority,omitempty"` - Parameters protocol.Parameters `json:"parameters,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` - Command string `json:"command,omitempty"` - DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` - DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` - DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` - WorkInputs map[string]string `json:"work_inputs,omitempty"` - WorkID string `json:"work_id,omitempty"` - WorkQuestionPrompt string `json:"work_question_prompt,omitempty"` - WorkQuestionSchema []byte `json:"work_question_schema,omitempty"` - WorkQuestionID string `json:"work_question_id,omitempty"` - WorkAnswer []byte `json:"work_answer,omitempty"` - WorkBlockReason string `json:"work_block_reason,omitempty"` + SchemaVersion int `json:"schema_version"` + Operation Operation `json:"operation"` + Repository string `json:"repository"` + Host string `json:"host"` + CorrelationID string `json:"correlation_id"` + ProgramID string `json:"program_id,omitempty"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + EntryID string `json:"entry_id,omitempty"` + FlowID string `json:"flow_id,omitempty"` + Objective model.Objective `json:"objective,omitempty"` + TransitionID catalog.TransitionID `json:"transition_id,omitempty"` + Prescription protocol.Prescription `json:"prescription,omitempty"` + Authority protocol.AuthorityBundle `json:"authority,omitempty"` + RepositoryAuthority bool `json:"repository_authority,omitempty"` + Parameters protocol.Parameters `json:"parameters,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + Command string `json:"command,omitempty"` + DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` + DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` + DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` + WorkInputs map[string]protocol.WorkInputValue `json:"work_inputs,omitempty"` + WorkID string `json:"work_id,omitempty"` + WorkQuestionPrompt string `json:"work_question_prompt,omitempty"` + WorkQuestionSchema []byte `json:"work_question_schema,omitempty"` + WorkQuestionID string `json:"work_question_id,omitempty"` + WorkAnswer []byte `json:"work_answer,omitempty"` + WorkBlockReason string `json:"work_block_reason,omitempty"` } func (r Request) Validate(now time.Time) error { @@ -103,6 +103,14 @@ func (r Request) Validate(now time.Time) error { return fmt.Errorf("surface delegated Flow request has invalid authority %q", authority) } } + for id, input := range r.WorkInputs { + if !flowContextIdentity.MatchString(id) { + return fmt.Errorf("surface foreground work input has invalid identity %q", id) + } + if err := input.Validate(); err != nil { + return fmt.Errorf("surface foreground work input %q: %w", id, err) + } + } if r.Operation != OperationCatalog { knownHost := false for _, host := range CanonicalHostNames() { diff --git a/release-notes/2026-08-15-foreground-work.md b/release-notes/2026-08-15-foreground-work.md index deedb10..b00594d 100644 --- a/release-notes/2026-08-15-foreground-work.md +++ b/release-notes/2026-08-15-foreground-work.md @@ -3,8 +3,9 @@ Flows can now declare exact instruction assets, entry inputs, and staged output contracts for human or agent work. Boatstack suspends and resumes the same run, validates the result as evidence, and still admits effects only through trusted -operators. The software-delivery adapter also provides optional planning-package -admission, approval, and promotion operations. +operators. Input fingerprints and request-specific staging prevent changed +inputs from reusing stale work. The software-delivery adapter also provides +optional planning-package admission, approval, and promotion operations. The runtime performs a one-step, transactional schema-4 state upgrade and verifies every declared planning-package output before approval. Trusted @@ -18,5 +19,6 @@ authority remain unavailable until verified configuration evidence exists. Published-PR entries now stop until intended delivery changes are committed, bind the preview and push to that exact commit, and derive short-lived GitHub -provider capability through the trusted runtime boundary. Caller-provided -provider receipts are rejected. +provider capability through each trusted transition's declared fingerprint +binding, including correction and recovery. Caller-provided provider receipts +are rejected. From 4b6475a60fa39d0f70550debc9235b5cc3eed54d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 15:52:14 +0100 Subject: [PATCH 4/7] Make foreground work regression portable --- .../internal/softwaredelivery/foregroundwork/manager_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go index 2e15fb9..506f525 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go @@ -169,7 +169,7 @@ func TestForegroundWorkInputFingerprintInvalidatesOutputsAndStaging(t *testing.T if second.Status != foregroundwork.StatusRequested || second.Request.Fingerprint == first.Request.Fingerprint || second.Request.StagingRoot == first.Request.StagingRoot { t.Fatalf("input change reused request identity or staging: first=%#v second=%#v", first.Request, second.Request) } - if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil || !strings.Contains(err.Error(), "no such file") { + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { t.Fatalf("invalidated output completed new request: %v", err) } } From 602aa046f97904aaa1eadf47cd04774998e4eda9 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 18:08:00 +0100 Subject: [PATCH 5/7] fix(runtime): bind atomic repository control bundles --- .../cmd/boatstack-helper/control_bundle.go | 321 +++++++++++++++ .../boatstack-helper/delegation_command.go | 21 + .../boatstack-helper/delegation_runtime.go | 2 +- .../cmd/boatstack-helper/flow_command.go | 2 +- .../cmd/boatstack-helper/flow_runtime.go | 90 ++--- .../cmd/boatstack-helper/flow_runtime_test.go | 117 +++++- boatstack/cmd/boatstack-helper/main.go | 22 +- .../cmd/boatstack-helper/work_command.go | 8 + boatstack/delivery_controller.go | 4 +- boatstack/flow/skillprojection/bootstrap.go | 30 +- .../flow/skillprojection/bootstrap_test.go | 7 +- boatstack/flow/softwaredelivery/skills.go | 3 +- boatstack/flow/standard/completeness_test.go | 2 +- boatstack/internal/runtime/control_bundle.go | 365 ++++++++++++++++++ .../internal/runtime/control_bundle_test.go | 85 ++++ .../softwaredelivery/delegation/record.go | 35 +- .../delegation/record_test.go | 2 +- .../softwaredelivery/durable/state.go | 18 +- .../softwaredelivery/durable/state_facet.go | 5 +- .../durable/state_schema_test.go | 10 +- .../effects/command_boundary.go | 47 +-- .../softwaredelivery/effects/driver.go | 39 +- .../softwaredelivery/effects/host_skills.go | 15 + .../effects/integration_test.go | 130 ++++++- .../softwaredelivery/effects/journal.go | 11 + .../softwaredelivery/effects/prepared.go | 6 + .../softwaredelivery/effects/recovery.go | 51 ++- .../softwaredelivery/effects/recovery_test.go | 20 +- .../softwaredelivery/engine/engine.go | 50 ++- .../engine/maintenance_replay_test.go | 2 +- .../softwaredelivery/protocol/admission.go | 92 +++-- .../protocol/control_bundle.go | 82 ++++ .../softwaredelivery/protocol/prescription.go | 36 +- .../softwaredelivery/protocol/receipt.go | 106 ++--- .../softwaredelivery/surfaces/protocol.go | 69 ++-- .../2026-08-15-atomic-control-bundles.md | 11 + 36 files changed, 1610 insertions(+), 306 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/control_bundle.go create mode 100644 boatstack/internal/runtime/control_bundle.go create mode 100644 boatstack/internal/runtime/control_bundle_test.go create mode 100644 boatstack/internal/softwaredelivery/protocol/control_bundle.go create mode 100644 release-notes/2026-08-15-atomic-control-bundles.md diff --git a/boatstack/cmd/boatstack-helper/control_bundle.go b/boatstack/cmd/boatstack-helper/control_bundle.go new file mode 100644 index 0000000..8c996e9 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/control_bundle.go @@ -0,0 +1,321 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "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/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +type hostSkillProjectionManifest struct { + SchemaVersion int `json:"schema_version"` + Files map[string]string `json:"files"` +} + +func buildRepositoryControlBundle(ctx context.Context, repository string) (boatstackruntime.ControlBundleSnapshot, error) { + return buildRepositoryControlBundleAllowingInitialization(ctx, repository, false) +} + +func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, repository string, allowMissingProject bool) (boatstackruntime.ControlBundleSnapshot, error) { + repository, err := filepath.Abs(repository) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + paths := map[string]struct{}{} + absent := []string{} + if _, statErr := os.Lstat(filepath.Join(repository, ".boatstack", "project.json")); statErr == nil { + paths[".boatstack/project.json"] = struct{}{} + } else if os.IsNotExist(statErr) && allowMissingProject { + absent = append(absent, ".boatstack/project.json") + } else if statErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: .boatstack/project.json is required: %w", statErr) + } + if _, statErr := os.Lstat(filepath.Join(repository, ".boatstack", "runtime.json")); statErr == nil { + paths[".boatstack/runtime.json"] = struct{}{} + } else if os.IsNotExist(statErr) { + absent = append(absent, ".boatstack/runtime.json") + } else if !os.IsNotExist(statErr) { + return boatstackruntime.ControlBundleSnapshot{}, statErr + } + manifestPath := filepath.Join(repository, ".boatstack", "host-skills.json") + if raw, readErr := os.ReadFile(manifestPath); readErr == nil { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var manifest hostSkillProjectionManifest + decodeErr := decoder.Decode(&manifest) + var trailing any + trailingErr := decoder.Decode(&trailing) + if decodeErr != nil || trailingErr != io.EOF || manifest.SchemaVersion != 1 || manifest.Files == nil { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host-skill manifest is malformed") + } + paths[".boatstack/host-skills.json"] = struct{}{} + for path, expected := range manifest.Files { + absolute, pathErr := exactRepositoryPath(repository, filepath.FromSlash(path)) + if pathErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, pathErr + } + raw, fileErr := os.ReadFile(absolute) + if fileErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, fileErr + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != expected { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_STALE: host skill %s does not match its manifest", path) + } + paths[filepath.ToSlash(path)] = struct{}{} + } + } else if !os.IsNotExist(readErr) { + return boatstackruntime.ControlBundleSnapshot{}, readErr + } else { + absent = append(absent, ".boatstack/host-skills.json") + } + artifacts, err := filepath.Glob(filepath.Join(repository, ".boatstack", "flows", "*.flow.ir.json")) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + sort.Strings(artifacts) + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + for _, artifactPath := range artifacts { + raw, readErr := os.ReadFile(artifactPath) + if readErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, readErr + } + artifact, loadErr := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if loadErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, loadErr + } + if _, checkErr := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills); checkErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, checkErr + } + relative, relErr := filepath.Rel(repository, artifactPath) + if relErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, relErr + } + paths[filepath.ToSlash(relative)] = struct{}{} + paths[artifact.SourcePath] = struct{}{} + paths[artifact.DependencyLockPath] = struct{}{} + for path := range artifact.Assets { + paths[path] = struct{}{} + } + for path := range artifact.GeneratedSkills { + paths[path] = struct{}{} + } + } + files := make(map[string][]byte, len(paths)) + for path := range paths { + absolute, pathErr := exactRepositoryPath(repository, filepath.FromSlash(path)) + if pathErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, pathErr + } + info, statErr := os.Lstat(absolute) + if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: %s is not a regular file", path) + } + raw, readErr := os.ReadFile(absolute) + if readErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, readErr + } + files[filepath.ToSlash(path)] = raw + } + return boatstackruntime.NewControlBundleSnapshotWithAbsent(files, absent) +} + +func controlBundleRequired(id catalog.TransitionID) bool { + switch id { + case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.initialize", "installation.update", "installation.reconcile-update", "catalog.reconcile", + "workspace.cut", "workspace.cleanup", "workspace.reap", "workspace.reconcile": + return true + default: + return false + } +} + +func bindControlBundle(ctx context.Context, repository string, transitionID catalog.TransitionID, parameters protocol.Parameters) (*boatstackruntime.ControlBundleContract, string, error) { + snapshot, err := buildRepositoryControlBundleAllowingInitialization(ctx, repository, transitionID == "installation.initialize") + if err != nil { + return nil, "", err + } + var sourcePin *boatstackruntime.Pin + if pinRaw, readErr := os.ReadFile(boatstackruntime.PinPath(repository)); readErr == nil { + pin, decodeErr := boatstackruntime.DecodePin(pinRaw) + if decodeErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_INVALID: decode runtime pin: %w", decodeErr) + } + sourcePin = &pin + } else if !os.IsNotExist(readErr) { + return nil, "", readErr + } + if !controlBundleRequired(transitionID) { + contract, contractErr := boatstackruntime.NewControlBundleContractWithPins(snapshot, nil, "", sourcePin, nil) + return &contract, snapshot.Fingerprint, contractErr + } + var target *boatstackruntime.ControlBundleSnapshot + targetRevision := "" + switch transitionID { + case "installation.initialize": + configPath, ok := parameters.Get("config_path") + if !ok { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: installation.initialize requires config_path") + } + configInfo, statErr := os.Lstat(configPath) + if statErr != nil || configInfo.Mode()&os.ModeSymlink != 0 || !configInfo.Mode().IsRegular() { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: project configuration is not a regular file") + } + configRaw, readErr := os.ReadFile(configPath) + if readErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: read project configuration: %w", readErr) + } + projected, projectErr := boatstackruntime.ReplaceControlBundleFile(snapshot, ".boatstack/project.json", configRaw) + if projectErr != nil { + return nil, "", projectErr + } + config, decodeErr := protocol.DecodeProjectConfig(configRaw) + if decodeErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: decode project configuration: %w", decodeErr) + } + _, configFingerprint, fingerprintErr := protocol.ProjectConfigFingerprint(configRaw) + if fingerprintErr != nil { + return nil, "", fingerprintErr + } + if expected, exists := parameters.Get("config_sha256"); !exists || expected != configFingerprint { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: project configuration fingerprint changed") + } + hostFiles, manifestRaw, projectionErr := effects.ProjectedHostSkillFiles(config.Hosts) + if projectionErr != nil { + return nil, "", projectionErr + } + projected, projectErr = boatstackruntime.ReplaceControlBundleFile(projected, ".boatstack/host-skills.json", manifestRaw) + if projectErr != nil { + return nil, "", projectErr + } + for path, raw := range hostFiles { + projected, projectErr = boatstackruntime.ReplaceControlBundleFile(projected, path, raw) + if projectErr != nil { + return nil, "", projectErr + } + } + target = &projected + case "installation.update", "installation.reconcile-update": + configRaw, readErr := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if readErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: read project configuration: %w", readErr) + } + config, decodeErr := protocol.DecodeProjectConfig(configRaw) + if decodeErr != nil { + return nil, "", decodeErr + } + hostFiles, manifestRaw, projectionErr := effects.ProjectedHostSkillFiles(config.Hosts) + if projectionErr != nil { + return nil, "", projectionErr + } + projected, projectionErr := boatstackruntime.ReplaceControlBundleFile(snapshot, ".boatstack/host-skills.json", manifestRaw) + if projectionErr != nil { + return nil, "", projectionErr + } + for path, raw := range hostFiles { + projected, projectionErr = boatstackruntime.ReplaceControlBundleFile(projected, path, raw) + if projectionErr != nil { + return nil, "", projectionErr + } + } + target = &projected + case "workspace.cut": + baseRef, exists := parameters.Get("base_ref") + if !exists { + return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace.cut requires an exact base_ref") + } + resolvedRevision, resolveErr := boatstackruntime.ResolveCommitRevision(ctx, repository, baseRef) + if resolveErr != nil { + return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: resolve base_ref %q: %w", baseRef, resolveErr) + } + targetRevision = resolvedRevision + if verifyErr := boatstackruntime.VerifyControlBundleRevision(ctx, repository, targetRevision, snapshot); verifyErr != nil { + return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: %w", verifyErr) + } + copy := snapshot + copy.Files = append([]boatstackruntime.ControlBundleFile(nil), snapshot.Files...) + target = © + case "workspace.cleanup", "workspace.reap": + copy := snapshot + copy.Files = append([]boatstackruntime.ControlBundleFile(nil), snapshot.Files...) + target = © + case "workspace.reconcile": + transactionID, ok := parameters.Get("transaction_id") + if !ok { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: workspace.reconcile requires transaction_id") + } + resolver, resolverErr := plant.NewResolver("") + if resolverErr != nil { + return nil, "", resolverErr + } + invocation, invocationErr := resolver.ResolveInvocation(ctx, repository, "cli", "workspace-control-bundle-recovery") + if invocationErr != nil { + return nil, "", invocationErr + } + layout, _, layoutErr := resolver.ResolveLayout(ctx, invocation) + if layoutErr != nil { + return nil, "", layoutErr + } + recoveredTarget, recoveredRevision, recoveryErr := effects.InterruptedWorkspaceTarget(layout, transactionID) + if recoveryErr != nil { + return nil, "", recoveryErr + } + target, targetRevision = &recoveredTarget, recoveredRevision + } + var targetPin *boatstackruntime.Pin + if target != nil { + targetPin = sourcePin + } + contract, err := boatstackruntime.NewControlBundleContractWithPins(snapshot, target, targetRevision, sourcePin, targetPin) + return &contract, snapshot.Fingerprint, err +} + +func bindTrustedRequestControlBundle(ctx context.Context, request *surfaces.Request) error { + if request == nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: request is nil") + } + request.ControlBundle = nil + request.ControlBundleFingerprint = "" + if request.ProgramID == "" && !controlBundleRequired(request.TransitionID) { + return nil + } + bundle, fingerprint, err := bindControlBundle(ctx, request.Repository, request.TransitionID, request.Parameters) + if err != nil { + return err + } + request.ControlBundle = bundle + request.ControlBundleFingerprint = fingerprint + return nil +} + +func verifyTrustedRequestControlBundle(request surfaces.Request) error { + if request.ControlBundle == nil { + if controlBundleRequired(request.TransitionID) { + return fmt.Errorf("CONTROL_BUNDLE_REQUIRED: transition %q has no trusted bundle", request.TransitionID) + } + return nil + } + return boatstackruntime.VerifyControlBundleRoot(request.Repository, request.ControlBundle.Source) +} diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 06adf31..f90f244 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -271,7 +271,16 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } + resolveLease, err := acquireFlowExecutionLease(resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if err := verifyTrustedRequestControlBundle(resolveRequest); err != nil { + resolveLease.Release() + return surfaces.Response{}, err + } resolved, err := kernel.Handle(ctx, resolveRequest) + resolveLease.Release() if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } @@ -307,7 +316,16 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } + resolveLease, err = acquireFlowExecutionLease(resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if err := verifyTrustedRequestControlBundle(resolveRequest); err != nil { + resolveLease.Release() + return surfaces.Response{}, err + } resolved, err = kernel.Handle(ctx, resolveRequest) + resolveLease.Release() if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } @@ -337,6 +355,9 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return surfaces.Response{}, err } defer lease.Release() + if err := verifyTrustedRequestControlBundle(applyRequest); err != nil { + return surfaces.Response{}, err + } applied, err := kernel.Handle(ctx, applyRequest) targetSatisfied := kernel.TargetSatisfied(applied.Snapshot, applyRequest.Objective) if settleErr := settleDelegationAtTarget(ctx, applyRequest, applied, targetSatisfied, delegationLock != nil); settleErr != nil && err == nil { diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 9cc829a..49f43dd 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -77,7 +77,7 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo releaseOnError() return nil, nil, err } - if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint { + if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint { releaseOnError() return nil, nil, fmt.Errorf("DELEGATION_DRIFT: authorization does not match the current run context") } diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index e3ab874..a606115 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -21,7 +21,7 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" ) -const flowCompilerVersion = "control-program.compiler.2" +const flowCompilerVersion = "control-program.compiler.3" type flowCommandOptions struct { repository string diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index eb51347..bac1ce6 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -14,7 +14,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/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" @@ -119,6 +119,32 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if options.targetID != string(objective.TargetID) || options.trustedObjectiveClass != string(objective.TrustedClass) || options.deliveryID != deliveryID || options.objectiveID != expectedObjectiveID { return commandOptions{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: objective or delivery changed across the run") } + if options.transitionID == "installation.initialize" { + configPath := filepath.Join(repository, ".boatstack", "project.json") + initialParameters, parseErr := parseParameters(options.parameters) + if parseErr != nil { + return commandOptions{}, parseErr + } + if err := bindResolvedParameter(&options, initialParameters, "config_path", configPath); err != nil { + return commandOptions{}, err + } + if err := populateProjectConfigFingerprint(&options); err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind verified project configuration: %w", err) + } + if err := populateRuntimeParameters(&options); err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind exact runtime identity: %w", err) + } + } + parameters, err := parseParameters(options.parameters) + if err != nil { + return commandOptions{}, err + } + bundle, bundleFingerprint, err := bindControlBundle(ctx, repository, catalog.TransitionID(options.transitionID), parameters) + if err != nil { + return commandOptions{}, err + } + options.controlBundle = bundle + options.controlBundleFingerprint = bundleFingerprint if entry.Delegation != nil { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { @@ -138,7 +164,8 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } delegationRequest := delegation.Request{ RunID: options.runID, ProgramID: options.programID, ProgramFingerprint: compiled.Fingerprint, - EntryID: options.entryID, TargetID: string(objective.TargetID), ObjectiveID: options.objectiveID, DeliveryID: deliveryID, + ControlBundleFingerprint: bundleFingerprint, + EntryID: options.entryID, TargetID: string(objective.TargetID), ObjectiveID: options.objectiveID, DeliveryID: deliveryID, InputFingerprints: []string{planFingerprint}, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, InitialWorktreeID: invocation.WorktreeID, InitialRef: invocation.Ref, BindingFingerprint: entry.Delegation.Fingerprint, RequestedAuthorities: append([]string(nil), entry.Delegation.Authorities...), @@ -155,8 +182,8 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if record, loadErr := delegation.Load(recordPath); loadErr == nil { bound := record.Request inputDrift := !options.activeFlowBound && strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") - if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { - return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request") + if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.ControlBundleFingerprint != delegationRequest.ControlBundleFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { + return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request (bundle %s, authorized %s)", delegationRequest.ControlBundleFingerprint, bound.ControlBundleFingerprint) } delegationRequest = bound } else if !os.IsNotExist(loadErr) { @@ -172,10 +199,6 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, options.delegationDescription = description options.delegationRequest = delegationRequest } - parameters, err := parseParameters(options.parameters) - if err != nil { - return commandOptions{}, err - } for name, expected := range map[string]string{ "target_id": string(objective.TargetID), "delivery_id": deliveryID, @@ -187,17 +210,6 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } } switch options.transitionID { - case "installation.initialize": - configPath := filepath.Join(repository, ".boatstack", "project.json") - if err := bindResolvedParameter(&options, parameters, "config_path", configPath); err != nil { - return commandOptions{}, err - } - if err := populateProjectConfigFingerprint(&options); err != nil { - return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind verified project configuration: %w", err) - } - if err := populateRuntimeParameters(&options); err != nil { - return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind exact runtime identity: %w", err) - } case "objective.bind": if err := bindResolvedParameter(&options, parameters, "target_id", string(objective.TargetID)); err != nil { return commandOptions{}, err @@ -227,14 +239,6 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err := bindPublicationPreviewParameters(ctx, repository, deliveryID, options.host, &options, parameters); err != nil { return commandOptions{}, err } - case "workspace.cut": - baseRef, exists := parameters.Get("base_ref") - if !exists { - return commandOptions{}, fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace.cut requires an exact base_ref") - } - if err := verifyWorkspaceControlBundleAtRevision(ctx, repository, artifactPath, artifact, baseRef); err != nil { - return commandOptions{}, err - } } return options, nil } @@ -283,36 +287,6 @@ func bindPublicationPreviewParameters(ctx context.Context, repository, deliveryI return nil } -func verifyWorkspaceControlBundleAtRevision(ctx context.Context, repository, artifactPath string, artifact controlprogram.Artifact, revision string) error { - if err := protocol.ValidateGitReference(revision); err != nil { - return fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: invalid workspace base_ref: %w", err) - } - artifactRelative, err := filepath.Rel(repository, artifactPath) - if err != nil { - return fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: resolve compiled artifact path: %w", err) - } - paths := map[string]struct{}{ - filepath.ToSlash(artifactRelative): {}, - artifact.SourcePath: {}, - artifact.DependencyLockPath: {}, - } - for path := range artifact.Assets { - paths[path] = struct{}{} - } - for path := range artifact.GeneratedSkills { - paths[path] = struct{}{} - } - ordered := make([]string, 0, len(paths)) - for path := range paths { - ordered = append(ordered, path) - } - sort.Strings(ordered) - if err := boatstackruntime.VerifyFlowProjectionAtRevision(ctx, repository, revision, ordered); err != nil { - return fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace base %q does not contain the active control bundle; commit or regenerate the Flow bundle before workspace.cut: %w", revision, err) - } - return nil -} - func populateProjectConfigFingerprint(options *commandOptions) error { parameters, err := parseParameters(options.parameters) if err != nil { @@ -516,6 +490,8 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R request.DelegationRequestFingerprint = bound.delegationRequestFingerprint request.DelegatedAuthorities = delegationClasses(bound.delegationAuthorities) request.WorkInputs = bound.workInputs + request.ControlBundle = bound.controlBundle + request.ControlBundleFingerprint = bound.controlBundleFingerprint return request, nil } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 62a589e..a553d99 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -207,6 +207,10 @@ func bindSharedGitCommon(t *testing.T, repository, gitDirectory, commonDirectory func writeFlowArtifact(t *testing.T, repository string, document controlprogram.Document, sourcePath string, source []byte, lockPath string, lock []byte) { t.Helper() + projectPath := filepath.Join(repository, ".boatstack", "project.json") + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) + } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { t.Fatal(err) @@ -421,7 +425,11 @@ func TestWorkspaceCutRejectsControlBundleThatIsNotInBaseRevision(t *testing.T) { if err != nil { t.Fatal(err) } - if err := verifyWorkspaceControlBundleAtRevision(context.Background(), repository, artifactPath, artifact, "HEAD"); err != nil { + bundle, err := buildRepositoryControlBundle(context.Background(), repository) + if err != nil { + t.Fatal(err) + } + if err := boatstackruntime.VerifyControlBundleRevision(context.Background(), repository, "HEAD", bundle); err != nil { t.Fatalf("committed control bundle rejected: %v", err) } @@ -436,12 +444,102 @@ func TestWorkspaceCutRejectsControlBundleThatIsNotInBaseRevision(t *testing.T) { if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(skillPath)), []byte("regenerated but uncommitted\n"), 0o600); err != nil { t.Fatal(err) } - err = verifyWorkspaceControlBundleAtRevision(context.Background(), repository, artifactPath, artifact, "HEAD") - if err == nil || !strings.Contains(err.Error(), "WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED") || !strings.Contains(err.Error(), skillPath) { + _, err = buildRepositoryControlBundle(context.Background(), repository) + if err == nil || !strings.Contains(err.Error(), skillPath) { t.Fatalf("uncommitted generated skill result = %v", err) } } +func TestWorkspaceCutRejectsUncommittedRuntimePinBeforeEffect(t *testing.T) { + // control-law: a runtime pin cannot outrun the committed Flow projection + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin( + boatstackruntime.Identity{Version: "v-test", SHA256: strings.Repeat("a", 64), SourceRevision: "test-revision"}, + strings.Repeat("b", 64), durable.StateSchemaVersion, + )) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".boatstack", "runtime.json"), pinRaw, 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "workspace") + _, _, err = bindControlBundle(context.Background(), repository, "workspace.cut", protocol.Parameters{ + {Name: "base_ref", Value: "HEAD"}, {Name: "branch", Value: "feature/bundle"}, {Name: "destination", Value: destination}, + }) + if err == nil || !strings.Contains(err.Error(), "WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED") || !strings.Contains(err.Error(), ".boatstack/runtime.json") { + t.Fatalf("uncommitted runtime pin result = %v", err) + } + if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { + t.Fatalf("workspace was created before bundle admission: %v", statErr) + } +} + +func TestWorkspaceCutFreezesMovingBaseReference(t *testing.T) { + // control-law: a moving branch cannot change an already admitted workspace base + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + want := strings.TrimSpace(runFlowGitOutput(t, repository, "rev-parse", "HEAD")) + contract, _, err := bindControlBundle(context.Background(), repository, "workspace.cut", protocol.Parameters{{Name: "base_ref", Value: "HEAD"}}) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, "README.md", []byte("move branch\n")) + runFlowGit(t, repository, "add", "README.md") + runFlowGit(t, repository, "commit", "-q", "-m", "move branch") + if contract.TargetRevision != want { + t.Fatalf("target revision changed with branch: got %s want %s", contract.TargetRevision, want) + } +} + +func TestOneStaleFlowBlocksMultiFlowControlBundle(t *testing.T) { + // control-law: a repository control bundle is complete across every Flow + repository := flowRepository(t) + document := productDeliveryDocument("secondary-delivery") + sourcePath := ".boatstack/flows/secondary-delivery.flow.ts" + source := []byte("secondary source") + lockPath := "package-lock.json" + lockRaw, err := os.ReadFile(filepath.Join(repository, lockPath)) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, sourcePath, source) + writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, lockRaw) + artifactRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "flows", "secondary-delivery.flow.ir.json")) + if err != nil { + t.Fatal(err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(artifactRaw)) + if err != nil { + t.Fatal(err) + } + for path := range artifact.GeneratedSkills { + writeFixture(t, repository, path, []byte("stale secondary projection\n")) + if _, bundleErr := buildRepositoryControlBundle(context.Background(), repository); bundleErr == nil || !strings.Contains(bundleErr.Error(), path) { + t.Fatalf("stale secondary Flow did not block complete bundle: %v", bundleErr) + } + return + } + t.Fatal("secondary Flow generated no entry skill") +} + func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { // control-law: entry-resolved-inputs-cannot-be-replaced-by-callers for _, surface := range []string{"cli", "rpc"} { @@ -1761,17 +1859,8 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) } otherWorktree := filepath.Join(t.TempDir(), "other-worktree") runFlowGit(t, repository, "worktree", "add", "-q", "-b", "other-worktree", otherWorktree) - otherBound, err := bindFlowEntry(context.Background(), commandOptions{repository: otherWorktree, programID: "product-delivery", entryID: "run", runID: bound.runID, deliveryID: bound.deliveryID, host: "codex"}) - if err != nil { - t.Fatal(err) - } - otherRequest, err := buildRequest(surfaces.OperationResolve, otherBound) - if err != nil { - t.Fatal(err) - } - otherLock, otherSuspension, otherErr := prepareDelegation(context.Background(), &otherRequest) - if otherLock != nil || otherSuspension != nil || otherErr == nil || !strings.Contains(otherErr.Error(), "DELEGATION_CONTEXT_UNAUTHORIZED") { - t.Fatalf("unauthorized worktree = lock=%v response=%#v err=%v", otherLock, otherSuspension, otherErr) + if _, otherErr := bindFlowEntry(context.Background(), commandOptions{repository: otherWorktree, programID: "product-delivery", entryID: "run", runID: bound.runID, deliveryID: bound.deliveryID, host: "codex"}); otherErr == nil || !strings.Contains(otherErr.Error(), "DELEGATION_DRIFT") { + t.Fatalf("unauthorized worktree bundle was not rejected: %v", otherErr) } runFlowGit(t, repository, "checkout", "-q", "-b", "changed-ref") refBound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", runID: bound.runID, deliveryID: bound.deliveryID, host: "codex"}) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 7ae5d4e..25f41a0 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -85,6 +85,8 @@ type commandOptions struct { workAnswerPath string workBlockReason string workResultFingerprint string + controlBundle *boatstackruntime.ControlBundleContract + controlBundleFingerprint string } func main() { @@ -141,6 +143,11 @@ func run(arguments []string) error { if err != nil { return err } + if request.ProgramID == "" { + if err := bindTrustedRequestControlBundle(context.Background(), &request); err != nil { + return err + } + } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) if err != nil { return err @@ -156,6 +163,9 @@ func run(arguments []string) error { return err } defer lease.Release() + if err := verifyTrustedRequestControlBundle(request); err != nil { + return err + } kernel, err := standardKernel(context.Background(), request) if err != nil { return err @@ -219,6 +229,11 @@ func runRPC() error { if err != nil { return err } + if request.ProgramID == "" { + if err := bindTrustedRequestControlBundle(context.Background(), &request); err != nil { + return err + } + } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) if err != nil { return err @@ -236,6 +251,9 @@ func runRPC() error { return err } defer lease.Release() + if err := verifyTrustedRequestControlBundle(request); err != nil { + return err + } kernel, err := standardKernel(context.Background(), request) if err != nil { return err @@ -428,7 +446,7 @@ func standardKernel(ctx context.Context, request surfaces.Request) (boatstack.De } func acquireFlowExecutionLease(request surfaces.Request) (*boatstackruntime.FlowProjectionLease, error) { - if request.ProgramID == "" || (request.Operation != surfaces.OperationApply && request.Operation != surfaces.OperationRecover) { + if request.ProgramID == "" && request.ControlBundle == nil { return &boatstackruntime.FlowProjectionLease{}, nil } return boatstackruntime.AcquireFlowProjectionLease(request.Repository) @@ -623,6 +641,8 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface WorkQuestionPrompt: options.workQuestionPrompt, WorkQuestionID: options.workQuestionID, WorkBlockReason: options.workBlockReason, + ControlBundle: options.controlBundle, + ControlBundleFingerprint: options.controlBundleFingerprint, }, nil } diff --git a/boatstack/cmd/boatstack-helper/work_command.go b/boatstack/cmd/boatstack-helper/work_command.go index e565460..d9dbfa1 100644 --- a/boatstack/cmd/boatstack-helper/work_command.go +++ b/boatstack/cmd/boatstack-helper/work_command.go @@ -52,6 +52,14 @@ func runFlowWork(arguments []string) error { return fmt.Errorf("foreground work answer file is not JSON") } } + lease, err := acquireFlowExecutionLease(request) + if err != nil { + return err + } + defer lease.Release() + if err := verifyTrustedRequestControlBundle(request); err != nil { + return err + } kernel, err := standardKernel(context.Background(), request) if err != nil { return err diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index c0c117d..4073ed7 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -126,7 +126,7 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request switch request.Operation { case surfaces.OperationResolve, surfaces.OperationExplain: explain := request.Operation == surfaces.OperationExplain - resolveRequest := engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain} + resolveRequest := engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain, ControlBundle: request.ControlBundle} resolution, resolveErr := k.engine.Resolve(ctx, resolveRequest) if !explain && resolveErr == nil && resolution.Decision.Kind == supervisor.DecisionCandidate && resolution.Decision.Transition != nil && resolution.Decision.Transition.Work != nil { record, workErr := k.work.Ensure(ctx, invocation, request.FlowID, request.ProgramID, request.EntryID, resolution.Objective, resolution.Snapshot, *resolution.Decision.Transition, request.WorkInputs) @@ -183,7 +183,7 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request work, response.Work = record.Result, &record } result, applyErr := k.engine.Apply(ctx, engine.ApplyRequest{ - ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID, Work: work}, + ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID, Work: work, ControlBundle: request.ControlBundle}, FlowID: request.FlowID, Prescription: request.Prescription, Parameters: request.Parameters, IdempotencyKey: request.IdempotencyKey, AdmissionLifetime: 2 * time.Minute, }) response.Prescription = &request.Prescription diff --git a/boatstack/flow/skillprojection/bootstrap.go b/boatstack/flow/skillprojection/bootstrap.go index 6ab2788..809e31e 100644 --- a/boatstack/flow/skillprojection/bootstrap.go +++ b/boatstack/flow/skillprojection/bootstrap.go @@ -1,33 +1,31 @@ package skillprojection -import "fmt" - // BootstrapContract is shared by every generated Flow entry skill. It covers // failures that occur before a repository Control Program can be loaded. -func BootstrapContract(installerVersion string) string { - return fmt.Sprintf(`Before starting the Flow, verify that the `+"`boatstack`"+` command is -available (`+"`command -v boatstack`"+` on POSIX or `+"`Get-Command boatstack`"+` in +func BootstrapContract() string { + return `Before starting the Flow, verify that the ` + "`boatstack`" + ` command is +available (` + "`command -v boatstack`" + ` on POSIX or ` + "`Get-Command boatstack`" + ` in PowerShell). If it is absent, read the exact committed -`+"`.boatstack/runtime.json`"+` regular file. Report -`+"`BOATSTACK_LAUNCHER_NOT_FOUND`"+`, the pinned version and SHA-256, and the +` + "`.boatstack/runtime.json`" + ` regular file. Report +` + "`BOATSTACK_LAUNCHER_NOT_FOUND`" + `, the pinned version and SHA-256, and the tag-specific installer command for the current platform: POSIX: -`+"`BOATSTACK_MODE=hydrate BOATSTACK_VERSION= BOATSTACK_EXPECTED_RUNTIME_SHA256= /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.sh)\"`"+` +` + "`BOATSTACK_MODE=hydrate BOATSTACK_VERSION= BOATSTACK_EXPECTED_RUNTIME_SHA256= /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack//install.sh)\"`" + ` PowerShell: -`+"`$env:BOATSTACK_MODE='hydrate'; $env:BOATSTACK_VERSION=''; $env:BOATSTACK_EXPECTED_RUNTIME_SHA256=''; Invoke-RestMethod https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.ps1 | Invoke-Expression`"+` +` + "`$env:BOATSTACK_MODE='hydrate'; $env:BOATSTACK_VERSION=''; $env:BOATSTACK_EXPECTED_RUNTIME_SHA256=''; Invoke-RestMethod https://raw.githubusercontent.com/operatorstack/boatstack//install.ps1 | Invoke-Expression`" + ` -Replace `+"``"+` and `+"``"+` only with the validated -values in the pin. The installer comes from Boatstack %s, the runtime version -that generated this skill, so it can hydrate older pinned runtime artifacts. -If the pin is absent or invalid, report `+"`BOATSTACK_RUNTIME_PIN_MISSING`"+` or -`+"`BOATSTACK_RUNTIME_PIN_INVALID`"+` and stop without guessing a version or -selecting `+"`latest`"+`. +Replace ` + "``" + ` and ` + "``" + ` only with the validated +values in the pin. The installer tag and runtime identity therefore come from +the same committed repository pin. +If the pin is absent or invalid, report ` + "`BOATSTACK_RUNTIME_PIN_MISSING`" + ` or +` + "`BOATSTACK_RUNTIME_PIN_INVALID`" + ` and stop without guessing a version or +selecting ` + "`latest`" + `. Display the installer command and ask for explicit approval. Never run it or authorize installation on the user's behalf. A bootstrap failure creates no Flow run ID. Preserve any Boatstack bootstrap diagnostic verbatim, including stderr, and resume this same requested entry only after the user has installed -the exact runtime.`, installerVersion, installerVersion, installerVersion) +the exact runtime.` } diff --git a/boatstack/flow/skillprojection/bootstrap_test.go b/boatstack/flow/skillprojection/bootstrap_test.go index dc77022..059ef60 100644 --- a/boatstack/flow/skillprojection/bootstrap_test.go +++ b/boatstack/flow/skillprojection/bootstrap_test.go @@ -7,14 +7,14 @@ import ( func TestBootstrapContractFailsClosedBeforeFlowExecution(t *testing.T) { // control-law: generated-skills-report-bootstrap-recovery-without-executing-it - contract := BootstrapContract("v9.9.10") + contract := BootstrapContract() for _, expected := range []string{ "command -v boatstack", "Get-Command boatstack", ".boatstack/runtime.json", "BOATSTACK_LAUNCHER_NOT_FOUND", "BOATSTACK_RUNTIME_PIN_MISSING", "BOATSTACK_RUNTIME_PIN_INVALID", "explicit approval", "Never run it", "creates no\nFlow run ID", "resume this same requested entry", "BOATSTACK_MODE=hydrate", "BOATSTACK_VERSION=", - "BOATSTACK_EXPECTED_RUNTIME_SHA256=", "/v9.9.10/install.sh", + "BOATSTACK_EXPECTED_RUNTIME_SHA256=", "//install.sh", } { if !strings.Contains(contract, expected) { t.Fatalf("bootstrap contract lacks %q", expected) @@ -26,4 +26,7 @@ func TestBootstrapContractFailsClosedBeforeFlowExecution(t *testing.T) { if strings.Contains(contract, "BOATSTACK_MODE=update") { t.Fatal("bootstrap contract permits repository mutation during runtime recovery") } + if strings.Contains(contract, "v9.9.10") { + t.Fatal("bootstrap contract depends on the generating runtime version") + } } diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index eadff4c..793a4d2 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -8,7 +8,6 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/flow/skillprojection" - "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" ) func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) { @@ -159,7 +158,7 @@ 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, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics, workProtocol, publication)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics, workProtocol, publication)) } func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 6418190..c47d897 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -147,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" && relative != "internal/runtime/flow_files.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" && relative != "internal/runtime/control_bundle.go" { t.Errorf("unclassified command boundary in %s", relative) } } diff --git a/boatstack/internal/runtime/control_bundle.go b/boatstack/internal/runtime/control_bundle.go new file mode 100644 index 0000000..b0a318a --- /dev/null +++ b/boatstack/internal/runtime/control_bundle.go @@ -0,0 +1,365 @@ +package runtime + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +const ControlBundleSchemaVersion = 1 + +// ControlBundleFile binds one repository-relative control file to exact bytes. +type ControlBundleFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256,omitempty"` + Absent bool `json:"absent,omitempty"` +} + +// ControlBundleSnapshot is the canonical executable control projection at one +// repository root. +type ControlBundleSnapshot struct { + Fingerprint string `json:"fingerprint"` + Files []ControlBundleFile `json:"files"` +} + +// ControlBundleContract binds the source projection and, for execution-context +// advances, the exact projection required at the target root and revision. +type ControlBundleContract struct { + SchemaVersion int `json:"schema_version"` + Fingerprint string `json:"fingerprint"` + Source ControlBundleSnapshot `json:"source"` + Target *ControlBundleSnapshot `json:"target,omitempty"` + TargetRevision string `json:"target_revision,omitempty"` + SourceRuntimePin *Pin `json:"source_runtime_pin,omitempty"` + TargetRuntimePin *Pin `json:"target_runtime_pin,omitempty"` +} + +func NewControlBundleSnapshot(files map[string][]byte) (ControlBundleSnapshot, error) { + return NewControlBundleSnapshotWithAbsent(files, nil) +} + +func NewControlBundleSnapshotWithAbsent(files map[string][]byte, absent []string) (ControlBundleSnapshot, error) { + bindings := make([]ControlBundleFile, 0, len(files)) + for path, raw := range files { + if !safeProjectionRelative(path) { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) + } + digest := sha256.Sum256(raw) + bindings = append(bindings, ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])}) + } + for _, path := range absent { + if !safeProjectionRelative(path) { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) + } + bindings = append(bindings, ControlBundleFile{Path: path, Absent: true}) + } + if len(bindings) == 0 { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: bundle has no files") + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].Path < bindings[j].Path }) + for index := 1; index < len(bindings); index++ { + if bindings[index-1].Path == bindings[index].Path { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: duplicate path %q", bindings[index].Path) + } + } + fingerprint, err := controlBundleDigest(bindings) + if err != nil { + return ControlBundleSnapshot{}, err + } + return ControlBundleSnapshot{Fingerprint: fingerprint, Files: bindings}, nil +} + +// ReplaceControlBundleFile derives a target snapshot without trusting a +// caller-supplied target fingerprint. +func ReplaceControlBundleFile(snapshot ControlBundleSnapshot, path string, raw []byte) (ControlBundleSnapshot, error) { + if err := snapshot.validate(); err != nil { + return ControlBundleSnapshot{}, err + } + if !safeProjectionRelative(path) { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) + } + digest := sha256.Sum256(raw) + binding := ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])} + files := append([]ControlBundleFile(nil), snapshot.Files...) + replaced := false + for index := range files { + if files[index].Path == path { + files[index], replaced = binding, true + break + } + } + if !replaced { + files = append(files, binding) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + } + fingerprint, err := controlBundleDigest(files) + if err != nil { + return ControlBundleSnapshot{}, err + } + return ControlBundleSnapshot{Fingerprint: fingerprint, Files: files}, nil +} + +func NewControlBundleContract(source ControlBundleSnapshot, target *ControlBundleSnapshot, targetRevision string) (ControlBundleContract, error) { + return NewControlBundleContractWithPins(source, target, targetRevision, nil, nil) +} + +func NewControlBundleContractWithPins(source ControlBundleSnapshot, target *ControlBundleSnapshot, targetRevision string, sourcePin, targetPin *Pin) (ControlBundleContract, error) { + contract := ControlBundleContract{SchemaVersion: ControlBundleSchemaVersion, Source: source, Target: target, TargetRevision: targetRevision, SourceRuntimePin: sourcePin, TargetRuntimePin: targetPin} + if err := contract.validateFields(); err != nil { + return ControlBundleContract{}, err + } + identity := contract + identity.Fingerprint = "" + fingerprint, err := controlBundleDigest(identity) + if err != nil { + return ControlBundleContract{}, err + } + contract.Fingerprint = fingerprint + return contract, nil +} + +func (c ControlBundleContract) Validate() error { + if err := c.validateFields(); err != nil { + return err + } + identity := c + want := identity.Fingerprint + identity.Fingerprint = "" + got, err := controlBundleDigest(identity) + if err != nil || want == "" || got != want { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: contract fingerprint mismatch") + } + return nil +} + +func (c ControlBundleContract) validateFields() error { + if c.SchemaVersion != ControlBundleSchemaVersion { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: unsupported schema") + } + if err := c.Source.validate(); err != nil { + return err + } + if c.Target != nil { + if err := c.Target.validate(); err != nil { + return err + } + } else if c.TargetRevision != "" { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target revision has no target bundle") + } + if err := validateBoundRuntimePin(c.Source, c.SourceRuntimePin); err != nil { + return err + } + if c.Target != nil { + if err := validateBoundRuntimePin(*c.Target, c.TargetRuntimePin); err != nil { + return err + } + } else if c.TargetRuntimePin != nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target runtime pin has no target bundle") + } + if c.TargetRevision != "" && !validObjectIdentity(c.TargetRevision) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target revision is not an exact object identity") + } + return nil +} + +func validateBoundRuntimePin(snapshot ControlBundleSnapshot, pin *Pin) error { + var binding *ControlBundleFile + for index := range snapshot.Files { + if snapshot.Files[index].Path == ".boatstack/runtime.json" { + binding = &snapshot.Files[index] + break + } + } + if binding == nil || binding.Absent { + if pin != nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: runtime-pin metadata has no runtime file") + } + return nil + } + if pin == nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: runtime file has no decoded pin metadata") + } + raw, err := EncodePin(*pin) + if err != nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: %w", err) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != binding.SHA256 { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: runtime-pin metadata does not match bundle bytes") + } + return nil +} + +func (s ControlBundleSnapshot) validate() error { + if !validControlDigest(s.Fingerprint) || len(s.Files) == 0 { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: snapshot is incomplete") + } + prior := "" + for _, file := range s.Files { + if !safeProjectionRelative(file.Path) || file.Path <= prior || (file.Absent && file.SHA256 != "") || (!file.Absent && !validControlDigest(file.SHA256)) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: file bindings are not canonical") + } + prior = file.Path + } + fingerprint, err := controlBundleDigest(s.Files) + if err != nil || fingerprint != s.Fingerprint { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: snapshot fingerprint mismatch") + } + return nil +} + +func validControlDigest(value string) bool { + if len(value) != 64 || strings.ToLower(value) != value { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func validObjectIdentity(value string) bool { + if (len(value) != 40 && len(value) != 64) || strings.ToLower(value) != value { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func VerifyControlBundleRoot(repository string, snapshot ControlBundleSnapshot) error { + if err := snapshot.validate(); err != nil { + return err + } + repository, err := filepath.EvalSymlinks(repository) + if err != nil || !filepath.IsAbs(repository) || filepath.Clean(repository) != repository { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: repository root is not exact") + } + for _, file := range snapshot.Files { + if file.Absent { + if _, statErr := os.Lstat(filepath.Join(repository, filepath.FromSlash(file.Path))); !os.IsNotExist(statErr) { + return fmt.Errorf("CONTROL_BUNDLE_STALE: %s must be absent", file.Path) + } + continue + } + raw, readErr := readBundleFile(repository, file.Path) + if readErr != nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: %s: %w", file.Path, readErr) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != file.SHA256 { + return fmt.Errorf("CONTROL_BUNDLE_STALE: %s does not match the admitted bundle", file.Path) + } + } + return nil +} + +func VerifyControlBundleRevision(ctx context.Context, repository, revision string, snapshot ControlBundleSnapshot) error { + if err := snapshot.validate(); err != nil { + return err + } + if repository == "" || !filepath.IsAbs(repository) || revision == "" { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: revision verification is incomplete") + } + for _, file := range snapshot.Files { + if file.Absent { + command := exec.CommandContext(ctx, "git", "cat-file", "-e", revision+":"+file.Path) + command.Dir = repository + if command.Run() == nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s unexpectedly contains %s", revision, file.Path) + } + continue + } + command := exec.CommandContext(ctx, "git", "show", revision+":"+file.Path) + command.Dir = repository + raw, err := command.Output() + if err != nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s lacks %s", revision, file.Path) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != file.SHA256 { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s has different %s", revision, file.Path) + } + } + return nil +} + +func ResolveCommitRevision(ctx context.Context, repository, reference string) (string, error) { + command := exec.CommandContext(ctx, "git", "rev-parse", "--verify", reference+"^{commit}") + command.Dir = repository + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("resolve Git reference %q: %w", reference, err) + } + revision := strings.TrimSpace(string(output)) + if (len(revision) != 40 && len(revision) != 64) || strings.ToLower(revision) != revision { + return "", fmt.Errorf("Git reference %q did not resolve to an exact object identity", reference) + } + if _, err := hex.DecodeString(revision); err != nil { + return "", fmt.Errorf("Git reference %q did not resolve to an exact object identity", reference) + } + return revision, nil +} + +// VerifyControlBundleHead binds a newly created execution context to both the +// admitted control bytes and the exact commit resolved before the effect. +func VerifyControlBundleHead(ctx context.Context, repository, revision string, snapshot ControlBundleSnapshot) error { + command := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "HEAD^{commit}") + command.Dir = repository + output, err := command.Output() + if err != nil || strings.TrimSpace(string(output)) != revision { + return fmt.Errorf("CONTROL_BUNDLE_STALE: target HEAD does not match admitted revision %s", revision) + } + return VerifyControlBundleRoot(repository, snapshot) +} + +func EncodeControlBundle(contract ControlBundleContract) ([]byte, error) { + if err := contract.Validate(); err != nil { + return nil, err + } + return json.Marshal(contract) +} + +func DecodeControlBundle(raw []byte) (ControlBundleContract, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var contract ControlBundleContract + if err := decoder.Decode(&contract); err != nil { + return ControlBundleContract{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return ControlBundleContract{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: trailing data") + } + return contract, contract.Validate() +} + +func readBundleFile(repository, relative string) ([]byte, error) { + absolute := filepath.Join(repository, filepath.FromSlash(relative)) + info, err := os.Lstat(absolute) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("control file is not a regular file") + } + resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil || (resolvedParent != repository && !strings.HasPrefix(resolvedParent, repository+string(filepath.Separator))) { + return nil, fmt.Errorf("control file escapes repository") + } + return os.ReadFile(absolute) +} + +func controlBundleDigest(value any) (string, error) { + raw, err := json.Marshal(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]), nil +} diff --git a/boatstack/internal/runtime/control_bundle_test.go b/boatstack/internal/runtime/control_bundle_test.go new file mode 100644 index 0000000..b25ce93 --- /dev/null +++ b/boatstack/internal/runtime/control_bundle_test.go @@ -0,0 +1,85 @@ +package runtime + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestControlBundleCanonicalizesFilesAndBindsAbsence(t *testing.T) { + left, err := NewControlBundleSnapshotWithAbsent(map[string][]byte{ + ".boatstack/project.json": []byte("project"), + ".agents/skills/run/SKILL.md": []byte("skill"), + }, []string{".boatstack/runtime.json"}) + if err != nil { + t.Fatal(err) + } + right, err := NewControlBundleSnapshotWithAbsent(map[string][]byte{ + ".agents/skills/run/SKILL.md": []byte("skill"), + ".boatstack/project.json": []byte("project"), + }, []string{".boatstack/runtime.json"}) + if err != nil { + t.Fatal(err) + } + if left.Fingerprint != right.Fingerprint { + t.Fatalf("ordering changed fingerprint: %s != %s", left.Fingerprint, right.Fingerprint) + } + replaced, err := ReplaceControlBundleFile(left, ".boatstack/runtime.json", []byte("pin")) + if err != nil { + t.Fatal(err) + } + if replaced.Fingerprint == left.Fingerprint || replaced.Files[2].Absent { + t.Fatalf("runtime pin replacement did not change the canonical bundle: %#v", replaced) + } +} + +func TestControlBundleVerifiesRootRevisionAndExactHead(t *testing.T) { + repository := t.TempDir() + runBundleGit(t, repository, "init", "-q") + runBundleGit(t, repository, "config", "user.email", "bundle@example.invalid") + runBundleGit(t, repository, "config", "user.name", "Bundle Test") + if err := os.MkdirAll(filepath.Join(repository, ".boatstack"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".boatstack", "project.json"), []byte("project\n"), 0o644); err != nil { + t.Fatal(err) + } + snapshot, err := NewControlBundleSnapshotWithAbsent(map[string][]byte{ + ".boatstack/project.json": []byte("project\n"), + }, []string{".boatstack/runtime.json"}) + if err != nil { + t.Fatal(err) + } + runBundleGit(t, repository, "add", ".boatstack/project.json") + runBundleGit(t, repository, "commit", "-q", "-m", "bundle") + revision := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + if err := VerifyControlBundleRoot(repository, snapshot); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleRevision(context.Background(), repository, revision, snapshot); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleHead(context.Background(), repository, revision, snapshot); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".boatstack", "runtime.json"), []byte("unexpected"), 0o644); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleRoot(repository, snapshot); err == nil || !strings.Contains(err.Error(), ".boatstack/runtime.json") { + t.Fatalf("unexpected runtime pin was not rejected: %v", err) + } +} + +func runBundleGit(t *testing.T, directory string, arguments ...string) string { + t.Helper() + command := exec.Command("git", arguments...) + command.Dir = directory + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", arguments, err, output) + } + return string(output) +} diff --git a/boatstack/internal/softwaredelivery/delegation/record.go b/boatstack/internal/softwaredelivery/delegation/record.go index af753f7..db49135 100644 --- a/boatstack/internal/softwaredelivery/delegation/record.go +++ b/boatstack/internal/softwaredelivery/delegation/record.go @@ -17,31 +17,32 @@ import ( const ( Schema = "run-delegation" - SchemaRevision = 1 + SchemaRevision = 2 ) var identity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) type Request struct { - RunID string `json:"run_id"` - ProgramID string `json:"program_id"` - ProgramFingerprint string `json:"program_fingerprint"` - EntryID string `json:"entry_id"` - TargetID string `json:"target_id"` - ObjectiveID string `json:"objective_id"` - DeliveryID string `json:"delivery_id"` - InputFingerprints []string `json:"input_fingerprints"` - RepositoryID string `json:"repository_id"` - GitCommonID string `json:"git_common_id"` - InitialWorktreeID string `json:"initial_worktree_id"` - InitialRef string `json:"initial_ref"` - BindingFingerprint string `json:"binding_fingerprint"` - RequestedAuthorities []string `json:"requested_authorities"` - Description string `json:"description"` + RunID string `json:"run_id"` + ProgramID string `json:"program_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + ObjectiveID string `json:"objective_id"` + DeliveryID string `json:"delivery_id"` + InputFingerprints []string `json:"input_fingerprints"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + InitialWorktreeID string `json:"initial_worktree_id"` + InitialRef string `json:"initial_ref"` + BindingFingerprint string `json:"binding_fingerprint"` + RequestedAuthorities []string `json:"requested_authorities"` + Description string `json:"description"` } func (r Request) Fingerprint() (string, error) { - if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || len(r.RequestedAuthorities) == 0 || r.Description == "" { + if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || len(r.RequestedAuthorities) == 0 || r.Description == "" { return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: request is incomplete") } r.InputFingerprints = append([]string(nil), r.InputFingerprints...) diff --git a/boatstack/internal/softwaredelivery/delegation/record_test.go b/boatstack/internal/softwaredelivery/delegation/record_test.go index 3266919..43ac4fc 100644 --- a/boatstack/internal/softwaredelivery/delegation/record_test.go +++ b/boatstack/internal/softwaredelivery/delegation/record_test.go @@ -9,7 +9,7 @@ import ( func request() delegation.Request { return delegation.Request{ - RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), EntryID: "run", + RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("c", 64), EntryID: "run", TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"b", "a"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", BindingFingerprint: strings.Repeat("b", 64), RequestedAuthorities: []string{"human", "autonomy"}, Description: "Run the program", diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index 2280351..95b0700 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -13,8 +13,8 @@ import ( ) const ( - StateSchemaVersion = 5 - priorStateSchemaVersion = 4 + StateSchemaVersion = 6 + priorStateSchemaVersion = 5 ) // CanReadStateSchema reports the current schema and its single supported @@ -35,6 +35,7 @@ type State struct { GitCommonID string `json:"git_common_id"` WorktreeID string `json:"worktree_id"` ProgramFingerprint string `json:"program_fingerprint,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` Revision uint64 `json:"revision"` Phase model.ProtocolPhase `json:"phase"` Engagement model.EngagementState `json:"engagement"` @@ -113,6 +114,9 @@ func (s State) Validate() error { if s.ProgramFingerprint != "" && len(s.ProgramFingerprint) != 64 { return fmt.Errorf("durable state has invalid program fingerprint") } + if s.ControlBundleFingerprint != "" && len(s.ControlBundleFingerprint) != 64 { + return fmt.Errorf("durable state has invalid control-bundle fingerprint") + } if s.PlanningPackageFingerprint != "" && len(s.PlanningPackageFingerprint) != 64 { return fmt.Errorf("durable state has invalid planning package fingerprint") } @@ -208,12 +212,10 @@ func DecodeState(value []byte) (State, error) { return State{}, fmt.Errorf("durable state contains trailing JSON") } if state.SchemaVersion == priorStateSchemaVersion { - // Schema 5 adds only planning-package state. Promote a genuine schema-4 - // value in memory so observation can select the transactional runtime - // update. The prior bytes remain the journal rollback source until that - // update commits. - if state.PlanningPackageFingerprint != "" || state.Plan == model.PlanPackageApproved { - return State{}, fmt.Errorf("durable state schema %d contains schema %d planning-package fields", priorStateSchemaVersion, StateSchemaVersion) + // Schema 6 adds only the optional control-bundle identity. The prior + // bytes remain the journal rollback source until a transition commits. + if state.ControlBundleFingerprint != "" { + return State{}, fmt.Errorf("durable state schema %d contains schema %d control-bundle fields", priorStateSchemaVersion, StateSchemaVersion) } state.SchemaVersion = StateSchemaVersion } diff --git a/boatstack/internal/softwaredelivery/durable/state_facet.go b/boatstack/internal/softwaredelivery/durable/state_facet.go index 466909c..246365d 100644 --- a/boatstack/internal/softwaredelivery/durable/state_facet.go +++ b/boatstack/internal/softwaredelivery/durable/state_facet.go @@ -9,8 +9,9 @@ import ( var stateFieldFacets = map[string]model.StateFacet{ "SchemaVersion": model.StateFacetControl, "RepositoryID": model.StateFacetControl, "GitCommonID": model.StateFacetControl, "WorktreeID": model.StateFacetControl, - "ProgramFingerprint": model.StateFacetProgram, - "Revision": model.StateFacetControl, "Phase": model.StateFacetControl, + "ProgramFingerprint": model.StateFacetProgram, + "ControlBundleFingerprint": model.StateFacetControl, + "Revision": model.StateFacetControl, "Phase": model.StateFacetControl, "Engagement": model.StateFacetProduct, "Delivery": model.StateFacetProduct, "Workspace": model.StateFacetProduct, "Plan": model.StateFacetProduct, "Configuration": model.StateFacetControl, "Runtime": model.StateFacetInstallation, diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index 84c00b8..cc4f0f5 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -37,7 +37,7 @@ func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *test } } -func TestDecodeStatePromotesSchemaFourWithoutChangingPriorBytes(t *testing.T) { +func TestDecodeStatePromotesSchemaFiveWithoutChangingPriorBytes(t *testing.T) { // control-law: forward state migration is read-only until a transaction commits state := State{ SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 7, @@ -55,7 +55,7 @@ func TestDecodeStatePromotesSchemaFourWithoutChangingPriorBytes(t *testing.T) { t.Fatal(err) } legacy["schema_version"] = float64(priorStateSchemaVersion) - delete(legacy, "planning_package_fingerprint") + delete(legacy, "control_bundle_fingerprint") prior, err := json.MarshalIndent(legacy, "", " ") if err != nil { t.Fatal(err) @@ -75,9 +75,9 @@ func TestDecodeStatePromotesSchemaFourWithoutChangingPriorBytes(t *testing.T) { } } -func TestDecodeStateRejectsSchemaFourWithSchemaFivePlanningState(t *testing.T) { - raw := []byte(`{"schema_version":4,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"package-approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"planning_package_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","updated_at":"1970-01-01T00:00:01Z"}`) +func TestDecodeStateRejectsSchemaFiveWithSchemaSixControlBundle(t *testing.T) { + raw := []byte(`{"schema_version":5,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","control_bundle_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) if _, err := DecodeState(raw); err == nil { - t.Fatal("schema-4 state smuggled schema-5 planning fields") + t.Fatal("schema-5 state smuggled schema-6 control-bundle fields") } } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index 9cc7f6d..ae3fdea 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -13,7 +13,6 @@ import ( "strings" "time" - boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -224,6 +223,10 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if err := protocol.ValidateGitReference(baseRef); err != nil { return settled, err } + if admission.ControlBundle == nil || admission.ControlBundle.Target == nil || admission.ControlBundle.TargetRevision == "" { + return settled, fmt.Errorf("CONTROL_BUNDLE_REQUIRED: workspace.cut has no exact target revision") + } + baseRevision := admission.ControlBundle.TargetRevision absolute, err := canonicalWorkspaceDestination(admission) if err != nil || absolute == layout.RepositoryRoot { return settled, fmt.Errorf("workspace destination must be an explicit non-primary path") @@ -233,7 +236,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio } else if !os.IsNotExist(err) { return settled, err } - baseConfig, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "show", baseRef+":.boatstack/project.json") + baseConfig, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "show", baseRevision+":.boatstack/project.json") if err != nil { return settled, fmt.Errorf("workspace base does not contain the verified Boatstack configuration: %w", err) } @@ -247,7 +250,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "check-ref-format", "--branch", branch); err != nil { return settled, fmt.Errorf("invalid workspace branch: %s: %w", strings.TrimSpace(string(output)), err) } - arguments := []string{"worktree", "add", "-b", branch, absolute, baseRef} + arguments := []string{"worktree", "add", "-b", branch, absolute, baseRevision} if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", arguments...); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } @@ -265,13 +268,6 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio neutralDirectory := filepath.Dir(state.WorkspacePath) gitPrefix := []string{"--git-dir", layout.GitCommonRoot} removeArguments := append(gitPrefix, "worktree", "remove") - managedOnly, managedErr := b.workspaceHasOnlyManagedRuntimePin(ctx, state) - if managedErr != nil { - return settled, managedErr - } - if managedOnly { - removeArguments = append(removeArguments, "--force") - } removeArguments = append(removeArguments, state.WorkspacePath) if output, err := b.runner.CombinedOutput(ctx, neutralDirectory, "git", removeArguments...); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil @@ -364,34 +360,3 @@ func publicationGeneratedPath(name string) bool { } return false } - -func (b NativeBoundary) workspaceHasOnlyManagedRuntimePin(ctx context.Context, state durable.State) (bool, error) { - output, err := b.runner.CombinedOutput(ctx, state.WorkspacePath, "git", "status", "--porcelain", "--untracked-files=all") - if err != nil { - return false, fmt.Errorf("inspect workspace before cleanup: %s: %w", strings.TrimSpace(string(output)), err) - } - status := strings.TrimSpace(string(output)) - if status == "" { - return false, nil - } - if status != "?? .boatstack/runtime.json" { - return false, fmt.Errorf("workspace cleanup refuses product or unmanaged changes: %s", status) - } - raw, err := os.ReadFile(boatstackruntime.PinPath(state.WorkspacePath)) - if err != nil { - return false, err - } - pin, err := boatstackruntime.DecodePin(raw) - if err != nil { - return false, err - } - want := boatstackruntime.NewPin( - boatstackruntime.Identity{Version: state.RuntimeVersion, SHA256: state.RuntimeFingerprint, SourceRevision: state.RuntimeSource}, - state.ProgramFingerprint, - durable.StateSchemaVersion, - ) - if pin != want { - return false, fmt.Errorf("workspace runtime pin does not match governed state") - } - return true, nil -} diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index f4b49a2..38f05a5 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -69,6 +69,11 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if currentInvocation.RepositoryID != admission.Invocation.RepositoryID || currentInvocation.GitCommonID != admission.Invocation.GitCommonID || currentInvocation.WorktreeID != admission.Invocation.WorktreeID { return nil, fmt.Errorf("effect invocation identity changed before preparation") } + if admission.ControlBundle != nil { + if err := boatstackruntime.VerifyControlBundleRoot(layout.RepositoryRoot, admission.ControlBundle.Source); err != nil { + return nil, err + } + } if transition.ID == "recovery.resume" || transition.ID == "recovery.rollback" || transition.ID == "workspace.reconcile" { prepared, prepareErr := d.prepareRecoveryReplay(ctx, layout, admission, transition) if prepareErr != nil { @@ -109,10 +114,21 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if err := d.verifyClearedWorkspaceDestination(ctx, state, admission, transition); err != nil { return nil, err } + if admission.ControlBundle != nil && admission.ControlBundle.Target != nil && (transition.ID == "workspace.cleanup" || transition.ID == "workspace.reap") { + if err := boatstackruntime.VerifyControlBundleRoot(state.WorkspaceSourcePath, *admission.ControlBundle.Target); err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_STALE: preserved source checkout: %w", err) + } + } if err := verifyRuntimeParameters(admission, transition); err != nil { return nil, err } next := state + if admission.ControlBundle != nil { + next.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + next.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } if next.ProgramFingerprint == "" { next.ProgramFingerprint = admission.ExpectedProgramFingerprint } @@ -182,12 +198,6 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return nil, pinErr } mutations = append(mutations, pinMutation) - } else if transition.ID == "workspace.cut" { - pinMutation, pinErr := prepareRuntimePinMutation(next.WorkspacePath, next) - if pinErr != nil { - return nil, pinErr - } - mutations = append(mutations, pinMutation) } statePath := layout.StatePath stateInstallLast := true @@ -294,6 +304,23 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans } mutations = annotateStateFacetMutations(mutations, changedFacets) prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changedFacets} + if admission.ControlBundle != nil && admission.ControlBundle.Target != nil { + targetRoot := layout.RepositoryRoot + switch transition.ID { + case "workspace.cut": + targetRoot = next.WorkspacePath + case "workspace.cleanup", "workspace.reap": + targetRoot = state.WorkspaceSourcePath + } + target := *admission.ControlBundle.Target + targetRevision := admission.ControlBundle.TargetRevision + prepared.postVerify = func(ctx context.Context) error { + if transition.ID == "workspace.cut" { + return boatstackruntime.VerifyControlBundleHead(ctx, targetRoot, targetRevision, target) + } + return boatstackruntime.VerifyControlBundleRoot(targetRoot, target) + } + } if err := bindPreparedCapabilities(prepared, admission, transition); err != nil { return nil, err } diff --git a/boatstack/internal/softwaredelivery/effects/host_skills.go b/boatstack/internal/softwaredelivery/effects/host_skills.go index e6e9935..86b4e3a 100644 --- a/boatstack/internal/softwaredelivery/effects/host_skills.go +++ b/boatstack/internal/softwaredelivery/effects/host_skills.go @@ -130,6 +130,21 @@ func desiredHostSkillFiles(hosts []string) map[string][]byte { return desired } +// ProjectedHostSkillFiles returns the exact runtime-owned host projection and +// manifest bytes without mutating a repository. +func ProjectedHostSkillFiles(hosts []string) (map[string][]byte, []byte, error) { + desired := desiredHostSkillFiles(hosts) + manifest := hostSkillManifest{SchemaVersion: hostSkillManifestSchema, Files: map[string]string{}} + for path, raw := range desired { + manifest.Files[path] = sha256Bytes(raw) + } + manifestRaw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, nil, err + } + return desired, append(manifestRaw, '\n'), nil +} + func prepareHostSkillMutations(repository string, hosts []string) ([]ports.ResourceMutation, error) { desired := desiredHostSkillFiles(hosts) manifestPath := filepath.Join(repository, ".boatstack", "host-skills.json") diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index a4adb8d..8ff964a 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -62,6 +62,7 @@ func testProgram() delivery.ControlProgram { func prescribeEngine(t *testing.T, ctx context.Context, kernel engine.Engine, request engine.ApplyRequest) engine.ApplyRequest { t.Helper() + request.ControlBundle = testControlBundle(t, request.Invocation.InvokingPath, request.Requested, request.Parameters) resolve := request.ResolveRequest resolve.Parameters = request.Parameters resolution, err := kernel.Resolve(ctx, resolve) @@ -77,6 +78,8 @@ func prescribeEngine(t *testing.T, ctx context.Context, kernel engine.Engine, re func prescribeSurface(t *testing.T, ctx context.Context, kernel boatstack.DeliveryController, request surfaces.Request) surfaces.Request { t.Helper() + request.ControlBundle = testControlBundle(t, request.Repository, request.TransitionID, request.Parameters) + request.ControlBundleFingerprint = request.ControlBundle.Source.Fingerprint resolve := request resolve.Operation = surfaces.OperationResolve resolve.FlowID = "" @@ -92,6 +95,57 @@ func prescribeSurface(t *testing.T, ctx context.Context, kernel boatstack.Delive return request } +func testControlBundle(t *testing.T, repository string, transitionID catalog.TransitionID, parameters protocol.Parameters) *boatstackruntime.ControlBundleContract { + t.Helper() + raw, err := os.ReadFile(filepath.Join(repository, "README.md")) + if err != nil { + t.Fatal(err) + } + files := map[string][]byte{"README.md": raw} + var sourcePin *boatstackruntime.Pin + if pinRaw, readErr := os.ReadFile(boatstackruntime.PinPath(repository)); readErr == nil { + files[".boatstack/runtime.json"] = pinRaw + pin, decodeErr := boatstackruntime.DecodePin(pinRaw) + if decodeErr != nil { + t.Fatal(decodeErr) + } + sourcePin = &pin + } else if !os.IsNotExist(readErr) { + t.Fatal(readErr) + } + source, err := boatstackruntime.NewControlBundleSnapshot(files) + if err != nil { + t.Fatal(err) + } + var target *boatstackruntime.ControlBundleSnapshot + targetRevision := "" + switch transitionID { + case "workspace.cut": + baseRef, _ := parameters.Get("base_ref") + command := exec.Command("git", "rev-parse", "--verify", baseRef+"^{commit}") + command.Dir = repository + output, resolveErr := command.Output() + if resolveErr != nil { + t.Fatal(resolveErr) + } + targetRevision = strings.TrimSpace(string(output)) + copy := source + target = © + case "workspace.cleanup", "workspace.reap", "workspace.reconcile": + copy := source + target = © + } + var targetPin *boatstackruntime.Pin + if target != nil { + targetPin = sourcePin + } + contract, err := boatstackruntime.NewControlBundleContractWithPins(source, target, targetRevision, sourcePin, targetPin) + if err != nil { + t.Fatal(err) + } + return &contract +} + func (c fixedClock) Now() time.Time { return c.value } func installTestRuntime(t *testing.T, executable string, raw []byte) string { @@ -128,6 +182,78 @@ func testRepository(t *testing.T) string { return repository } +func TestStaleControlBundleStopsBeforeManagedStateOrRuntimePin(t *testing.T) { + // control-law: control bundle mismatch is a pre-effect blocker + ctx := context.Background() + repository := testRepository(t) + externalRoot := t.TempDir() + kernel, err := boatstack.NewDeliveryController(externalRoot, testProgram()) + if err != nil { + t.Fatal(err) + } + readme, err := os.ReadFile(filepath.Join(repository, "README.md")) + if err != nil { + t.Fatal(err) + } + snapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{"README.md": readme}) + if err != nil { + t.Fatal(err) + } + contract, err := boatstackruntime.NewControlBundleContract(snapshot, nil, "") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("changed after binding\n"), 0o644); err != nil { + t.Fatal(err) + } + executable, _ := os.Executable() + executable, _ = filepath.Abs(executable) + executable, _ = filepath.EvalSymlinks(executable) + runtimeRaw, _ := os.ReadFile(executable) + runtimeVersion := installTestRuntime(t, executable, runtimeRaw) + configPath := filepath.Join(t.TempDir(), "project.json") + configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + request := surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, Host: "cli", CorrelationID: "stale-bundle", + FlowID: "flow-stale-bundle", TransitionID: "installation.initialize", + Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "human", Class: catalog.AuthorityHuman, Subject: "operator", Fingerprint: "human-proof", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}}}, + Parameters: protocol.Parameters{ + {Name: "source_revision", Value: "fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, + }, + ControlBundle: &contract, ControlBundleFingerprint: contract.Source.Fingerprint, + } + response, handleErr := kernel.Handle(ctx, request) + if handleErr != nil { + t.Fatal(handleErr) + } + if response.Decision == nil || response.Decision.Kind != supervisor.DecisionUnresolved || !strings.Contains(response.Decision.Reason, "CONTROL_BUNDLE_STALE") { + t.Fatalf("stale bundle decision = %#v", response.Decision) + } + resolver, err := plant.NewResolver(externalRoot) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "stale-bundle-check") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + t.Fatal(err) + } + if _, statErr := os.Stat(layout.StatePath); !os.IsNotExist(statErr) { + t.Fatalf("stale bundle created managed state: %v", statErr) + } + if _, statErr := os.Stat(boatstackruntime.PinPath(repository)); !os.IsNotExist(statErr) { + t.Fatalf("stale bundle created runtime pin: %v", statErr) + } +} + func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) { // control-law: request-to-boundary-to-effect-to-verified-receipt ctx := context.Background() @@ -521,7 +647,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { t.Fatal(err) } legacyState["schema_version"] = float64(durable.StateSchemaVersion - 1) - delete(legacyState, "planning_package_fingerprint") + delete(legacyState, "control_bundle_fingerprint") legacyRaw, err := json.MarshalIndent(legacyState, "", " ") if err != nil { t.Fatal(err) @@ -920,7 +1046,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) {Name: "config_path", Value: configSource}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }) apply(sourceInvocation, "objective.bind", human, protocol.Parameters{{Name: "target_id", Value: string(objective.TargetID)}, {Name: "delivery_id", Value: objective.DeliveryID}}) - run(t, repository, "git", "add", ".boatstack/project.json") + run(t, repository, "git", "add", ".boatstack/project.json", ".boatstack/runtime.json") run(t, repository, "git", "commit", "-q", "-m", "install Boatstack configuration") repositoryAuthority := func(path string) protocol.AuthorityBundle { raw, readErr := os.ReadFile(filepath.Join(path, ".boatstack", "project.json")) diff --git a/boatstack/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go index 87e7e98..3b9d12d 100644 --- a/boatstack/internal/softwaredelivery/effects/journal.go +++ b/boatstack/internal/softwaredelivery/effects/journal.go @@ -144,6 +144,17 @@ func readJournal(path string) (journalRecord, error) { receipt.DeliveryID != admission.Objective.DeliveryID || receipt.ObjectiveScope != admission.ObjectiveScope || receipt.ObjectiveStatus != admission.ObjectiveStatus { return journalRecord{}, fmt.Errorf("committed transition fact in %s does not match its exact admission", path) } + if admission.ControlBundle != nil { + targetFingerprint := admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + targetFingerprint = admission.ControlBundle.Target.Fingerprint + } + if receipt.ControlBundleSourceFingerprint != admission.ControlBundle.Source.Fingerprint || receipt.ControlBundleTargetFingerprint != targetFingerprint { + return journalRecord{}, fmt.Errorf("committed transition fact in %s does not match its admitted control bundle", path) + } + } else if receipt.ControlBundleSourceFingerprint != "" || receipt.ControlBundleTargetFingerprint != "" { + return journalRecord{}, fmt.Errorf("committed transition fact in %s invents a control bundle", path) + } if err := validateCommittedMutationFacts(record.TransitionClass, record.Mutations, receipt.ChangedStateFacets, receipt.CommittedEffects); err != nil { return journalRecord{}, fmt.Errorf("committed transition fact in %s: %w", path, err) } diff --git a/boatstack/internal/softwaredelivery/effects/prepared.go b/boatstack/internal/softwaredelivery/effects/prepared.go index 9926dfb..61d1fa0 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared.go +++ b/boatstack/internal/softwaredelivery/effects/prepared.go @@ -19,6 +19,7 @@ type boundaryCall func(context.Context) (ports.EffectResult, error) type preparedEffect struct { mutations []ports.ResourceMutation boundary boundaryCall + postVerify func(context.Context) error verifyInvocation *model.InvocationContext applied []ports.ResourceMutation boundarySettled bool @@ -139,6 +140,11 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error } p.applied = append(p.applied, mutation) } + if p.postVerify != nil { + if err := p.postVerify(ctx); err != nil { + return result, err + } + } return result, nil } diff --git a/boatstack/internal/softwaredelivery/effects/recovery.go b/boatstack/internal/softwaredelivery/effects/recovery.go index 2a1da05..987a55b 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery.go +++ b/boatstack/internal/softwaredelivery/effects/recovery.go @@ -9,6 +9,7 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -85,6 +86,12 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po resume := false var verificationInvocation *model.InvocationContext if _, statErr := os.Stat(destination); statErr == nil { + if record.Admission.ControlBundle == nil || record.Admission.ControlBundle.Target == nil || record.Admission.ControlBundle.TargetRevision == "" { + return nil, fmt.Errorf("workspace reconciliation has no exact interrupted target bundle") + } + if verifyErr := boatstackruntime.VerifyControlBundleHead(ctx, destination, record.Admission.ControlBundle.TargetRevision, *record.Admission.ControlBundle.Target); verifyErr != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_STALE: partially created workspace: %w", verifyErr) + } current, resolveErr := d.resolver.ResolveInvocation(ctx, destination, admission.Invocation.Host, admission.Invocation.Correlation) if resolveErr != nil { return nil, fmt.Errorf("resolve partially created workspace: %w", resolveErr) @@ -139,7 +146,15 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po return nil, err } mutations = annotateStateFacetMutations(mutations, changed) - return &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changed}, nil + prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changed} + if record.Admission.ControlBundle != nil && record.Admission.ControlBundle.Target != nil { + target := *record.Admission.ControlBundle.Target + targetRevision := record.Admission.ControlBundle.TargetRevision + prepared.postVerify = func(ctx context.Context) error { + return boatstackruntime.VerifyControlBundleHead(ctx, destination, targetRevision, target) + } + } + return prepared, nil } func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID, invocation model.InvocationContext, mutations []ports.ResourceMutation) ([]model.StateFacet, error) { @@ -169,6 +184,12 @@ func (d Driver) advanceRecoveredState(layout ports.ControllerLayout, admission p if mutation.Delete && mutation.TargetLink == "" && filepath.Clean(mutation.Path) == filepath.Clean(layout.StatePath) { state := durable.Default(admission.Invocation, d.clock.Now()) state.ProgramFingerprint = admission.ExpectedProgramFingerprint + if admission.ControlBundle != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } state.Revision = resultingRevision state.LastTransition = transition state.UpdatedAt = d.clock.Now().UTC() @@ -189,6 +210,12 @@ func (d Driver) advanceRecoveredState(layout ports.ControllerLayout, admission p continue } state.Revision = resultingRevision + if admission.ControlBundle != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } state.LastTransition = transition state.UpdatedAt = d.clock.Now().UTC() mutation.Target, err = durable.EncodeState(state) @@ -208,6 +235,12 @@ func (d Driver) advanceRecoveredState(layout ports.ControllerLayout, admission p return nil, fmt.Errorf("recovery state revision changed after admission") } state.Revision = resultingRevision + if admission.ControlBundle != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } state.LastTransition = transition state.UpdatedAt = d.clock.Now().UTC() raw, err := durable.EncodeState(state) @@ -237,6 +270,22 @@ func loadInterruptedJournal(layout ports.ControllerLayout, transactionID string) return record, path, nil } +// InterruptedWorkspaceTarget exposes only the immutable target bundle needed +// to admit workspace reconciliation. Journal mutation details remain private +// to the effect boundary. +func InterruptedWorkspaceTarget(layout ports.ControllerLayout, transactionID string) (boatstackruntime.ControlBundleSnapshot, string, error) { + record, _, err := loadInterruptedJournal(layout, transactionID) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, "", err + } + if record.TransitionID != "workspace.cut" || record.Admission.ControlBundle == nil || record.Admission.ControlBundle.Target == nil || record.Admission.ControlBundle.TargetRevision == "" { + return boatstackruntime.ControlBundleSnapshot{}, "", fmt.Errorf("workspace reconciliation has no exact interrupted target bundle") + } + target := *record.Admission.ControlBundle.Target + target.Files = append([]boatstackruntime.ControlBundleFile(nil), target.Files...) + return target, record.Admission.ControlBundle.TargetRevision, nil +} + func prepareJournalClosure(layout ports.ControllerLayout, transactionID, outcome string, now time.Time, excludeAdmissionID string) ([]ports.ResourceMutation, error) { var journals []struct { path string diff --git a/boatstack/internal/softwaredelivery/effects/recovery_test.go b/boatstack/internal/softwaredelivery/effects/recovery_test.go index 833b784..164006c 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery_test.go +++ b/boatstack/internal/softwaredelivery/effects/recovery_test.go @@ -120,15 +120,31 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing. {Name: "source_revision", Value: "recovery-fixture"}, {Name: "runtime_version", Value: runtimeIdentity.Version}, {Name: "runtime_sha256", Value: sha256Bytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint}, } + controlRaw, err := os.ReadFile(filepath.Join(repository, "README.md")) + if err != nil { + t.Fatal(err) + } + controlSnapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{"README.md": controlRaw}) + if err != nil { + t.Fatal(err) + } + controlBundle, err := boatstackruntime.NewControlBundleContract(controlSnapshot, nil, "") + if err != nil { + t.Fatal(err) + } + projectedBundle, err := protocol.ProjectControlBundle(initial, transition, parameters, &controlBundle) + if err != nil { + t.Fatal(err) + } capabilities, err := protocol.ProjectCapabilities(initial, transition, authority, clock.Now()) if err != nil { t.Fatal(err) } - prescription, err := protocol.NewPrescription(initial, transition, capabilities) + prescription, err := protocol.NewPrescriptionWithWorkAndBundle(initial, transition, capabilities, nil, projectedBundle) if err != nil { t.Fatal(err) } - admission, err := protocol.NewAdmission(initial, objective, transition, prescription, authority, parameters, clock.Now(), time.Minute) + admission, err := protocol.NewAdmissionWithWorkAndBundle(initial, objective, transition, prescription, authority, parameters, nil, projectedBundle, clock.Now(), time.Minute) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index 459e0b8..0f422be 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -7,6 +7,7 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" @@ -39,13 +40,14 @@ func (e Engine) canonicalize(observation model.Observation) (model.Snapshot, err } type ResolveRequest struct { - Invocation model.InvocationContext - Objective model.Objective - Authority protocol.AuthorityBundle - Parameters protocol.Parameters - Requested catalog.TransitionID - Trace bool - Work *protocol.WorkEvidence + Invocation model.InvocationContext + Objective model.Objective + Authority protocol.AuthorityBundle + Parameters protocol.Parameters + Requested catalog.TransitionID + Trace bool + Work *protocol.WorkEvidence + ControlBundle *boatstackruntime.ControlBundleContract } type Resolution struct { @@ -151,7 +153,11 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution updateDecisionTrace(decisionTrace, decision) return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } - prescription, prescriptionErr := protocol.NewPrescriptionWithWork(snapshot, *decision.Transition, capabilities, request.Work) + bundle, bundleErr := protocol.ProjectControlBundle(snapshot, *decision.Transition, request.Parameters, request.ControlBundle) + if bundleErr != nil { + return Resolution{}, bundleErr + } + prescription, prescriptionErr := protocol.NewPrescriptionWithWorkAndBundle(snapshot, *decision.Transition, capabilities, request.Work, bundle) if prescriptionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = prescriptionErr.Error() @@ -159,7 +165,7 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution updateDecisionTrace(decisionTrace, decision) return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } - admission, admissionErr := protocol.NewAdmissionWithWork(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, request.Work, now, 2*time.Minute) + admission, admissionErr := protocol.NewAdmissionWithWorkAndBundle(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, request.Work, bundle, now, 2*time.Minute) if admissionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = admissionErr.Error() @@ -327,7 +333,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check supplied idempotency key: %w", err) } if ok { - if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint, request.ControlBundle); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: request.Authority.GrantedCapabilities(e.clock.Now())}) @@ -377,7 +383,11 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe } transition := *resolution.Decision.Transition now := e.clock.Now() - admission, err := protocol.NewAdmissionWithWork(resolution.Snapshot, request.Objective, transition, request.Prescription, request.Authority, request.Parameters, request.Work, now, request.AdmissionLifetime) + bundle, err := protocol.ProjectControlBundle(resolution.Snapshot, transition, request.Parameters, request.ControlBundle) + if err != nil { + return result, err + } + admission, err := protocol.NewAdmissionWithWorkAndBundle(resolution.Snapshot, request.Objective, transition, request.Prescription, request.Authority, request.Parameters, request.Work, bundle, now, request.AdmissionLifetime) if err != nil { return result, err } @@ -391,7 +401,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check idempotency: %w", err) } if ok { - if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint, admission.ControlBundle); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: admission.GrantedCapabilities}) @@ -440,7 +450,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check locked idempotency: %w", findErr) } if ok { - if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint, admission.ControlBundle); err != nil { return result, err } if err := validateReplayObjectiveState(prior, lockedSnapshot); err != nil { @@ -584,7 +594,7 @@ func validatePrescriptionCurrent(prescription protocol.Prescription, snapshot mo } } -func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest, programFingerprint string) error { +func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest, programFingerprint string, bundle *boatstackruntime.ControlBundleContract) error { if prior.Program.Fingerprint != programFingerprint { return fmt.Errorf("idempotency receipt belongs to a different control program") } @@ -609,6 +619,18 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques if prior.WorkResultFingerprint != workFingerprint { return fmt.Errorf("idempotency receipt belongs to a different foreground work result") } + if bundle == nil { + if prior.ControlBundleSourceFingerprint != "" || prior.ControlBundleTargetFingerprint != "" { + return fmt.Errorf("idempotency receipt belongs to a repository control bundle") + } + } else { + if prior.ControlBundleSourceFingerprint != bundle.Source.Fingerprint { + return fmt.Errorf("idempotency receipt belongs to a different repository control bundle") + } + if bundle.Target != nil && prior.ControlBundleTargetFingerprint != bundle.Target.Fingerprint { + return fmt.Errorf("idempotency receipt belongs to a different repository control-bundle target") + } + } return nil } diff --git a/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go b/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go index 6d5e9e3..c38ca96 100644 --- a/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go +++ b/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go @@ -29,7 +29,7 @@ func TestMaintenanceReplayBindsDurableObjectiveState(t *testing.T) { t.Run(test.name, func(t *testing.T) { test.receipt.FlowID = request.FlowID test.receipt.Program = syntheticProgram - if err := validateReplayRequest(test.receipt, request, syntheticProgramFingerprint); err != nil { + if err := validateReplayRequest(test.receipt, request, syntheticProgramFingerprint, nil); err != nil { t.Fatalf("command objective affected maintenance replay identity: %v", err) } err := validateReplayObjectiveState(test.receipt, model.Snapshot{Observation: model.Observation{Objective: test.fact}}) diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 4e2e24c..0d38b51 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -5,42 +5,44 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const AdmissionSchemaVersion = 7 +const AdmissionSchemaVersion = 8 type Admission struct { - SchemaVersion int `json:"schema_version"` - ID string `json:"id"` - PrescriptionID string `json:"prescription_id"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionVersion int `json:"transition_version"` - ExpectedStateRevision uint64 `json:"expected_state_revision"` - ExpectedProgramFingerprint string `json:"expected_program_fingerprint"` - PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` - ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` - ExpectedSnapshotFingerprint string `json:"expected_snapshot_fingerprint"` - ExpectedObjectiveBindingFingerprint string `json:"expected_objective_binding_fingerprint"` - SourceRevision string `json:"source_revision,omitempty"` - WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` - SourcePhase model.ProtocolPhase `json:"source_phase"` - Invocation model.InvocationContext `json:"invocation"` - Objective model.Objective `json:"objective"` - ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` - ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` - Authority AuthorityBundle `json:"authority"` - AuthorityFingerprint string `json:"authority_fingerprint"` - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` - EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` - Parameters Parameters `json:"parameters,omitempty"` - Evidence []string `json:"evidence"` - IdempotencyKey string `json:"idempotency_key"` - IssuedAt time.Time `json:"issued_at"` - ExpiresAt time.Time `json:"expires_at"` - Work *WorkEvidence `json:"work,omitempty"` + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + PrescriptionID string `json:"prescription_id"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionVersion int `json:"transition_version"` + ExpectedStateRevision uint64 `json:"expected_state_revision"` + ExpectedProgramFingerprint string `json:"expected_program_fingerprint"` + PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` + ExpectedSnapshotFingerprint string `json:"expected_snapshot_fingerprint"` + ExpectedObjectiveBindingFingerprint string `json:"expected_objective_binding_fingerprint"` + SourceRevision string `json:"source_revision,omitempty"` + WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` + SourcePhase model.ProtocolPhase `json:"source_phase"` + Invocation model.InvocationContext `json:"invocation"` + Objective model.Objective `json:"objective"` + ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` + ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` + Authority AuthorityBundle `json:"authority"` + AuthorityFingerprint string `json:"authority_fingerprint"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` + Parameters Parameters `json:"parameters,omitempty"` + Evidence []string `json:"evidence"` + IdempotencyKey string `json:"idempotency_key"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Work *WorkEvidence `json:"work,omitempty"` + ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` } func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, now time.Time, lifetime time.Duration) (Admission, error) { @@ -48,6 +50,10 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition } func NewAdmissionWithWork(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, work *WorkEvidence, now time.Time, lifetime time.Duration) (Admission, error) { + return NewAdmissionWithWorkAndBundle(snapshot, objective, transition, prescription, authority, parameters, work, nil, now, lifetime) +} + +func NewAdmissionWithWorkAndBundle(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, work *WorkEvidence, bundle *boatstackruntime.ControlBundleContract, now time.Time, lifetime time.Duration) (Admission, error) { var err error objective, err = ObjectiveForTransition(snapshot, objective, transition) if err != nil { @@ -69,6 +75,9 @@ func NewAdmissionWithWork(snapshot model.Snapshot, objective model.Objective, tr if err := prescription.ValidateWork(work); err != nil { return Admission{}, err } + if err := prescription.ValidateControlBundle(bundle, transition); err != nil { + return Admission{}, err + } if transition.Work != nil { if work == nil { return Admission{}, fmt.Errorf("transition %q requires foreground work evidence", transition.ID) @@ -95,6 +104,24 @@ func NewAdmissionWithWork(snapshot model.Snapshot, objective model.Objective, tr copy.Outputs = append([]WorkOutputEvidence(nil), work.Outputs...) a.Work = © } + if bundle != nil { + copy := *bundle + copy.Source.Files = append([]boatstackruntime.ControlBundleFile(nil), bundle.Source.Files...) + if bundle.SourceRuntimePin != nil { + pin := *bundle.SourceRuntimePin + copy.SourceRuntimePin = &pin + } + if bundle.Target != nil { + target := *bundle.Target + target.Files = append([]boatstackruntime.ControlBundleFile(nil), bundle.Target.Files...) + copy.Target = &target + } + if bundle.TargetRuntimePin != nil { + pin := *bundle.TargetRuntimePin + copy.TargetRuntimePin = &pin + } + a.ControlBundle = © + } if transition.Policy.ObjectiveScope == catalog.ObjectiveScopeOptionalPreserve { a.ObjectiveStatus = snapshot.Objective.Status } @@ -410,6 +437,11 @@ func (a Admission) ValidateIdentity() error { if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.PrescriptionID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.ExpectedStateRevision == 0 || len(a.ExpectedProgramFingerprint) != 64 || len(a.ExpectedSnapshotFingerprint) != 64 || len(a.ExpectedObjectiveBindingFingerprint) != 64 || a.AuthorityFingerprint == "" || len(a.RequiredCapabilities) == 0 || len(a.EffectiveCapabilities) == 0 || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { return fmt.Errorf("admission: invalid schema, identity, source, or lifetime") } + if a.ControlBundle != nil { + if err := a.ControlBundle.Validate(); err != nil { + return err + } + } fingerprint, err := a.Authority.Fingerprint() if err != nil || fingerprint != a.AuthorityFingerprint { return fmt.Errorf("admission has invalid authority identity") diff --git a/boatstack/internal/softwaredelivery/protocol/control_bundle.go b/boatstack/internal/softwaredelivery/protocol/control_bundle.go new file mode 100644 index 0000000..4db4c37 --- /dev/null +++ b/boatstack/internal/softwaredelivery/protocol/control_bundle.go @@ -0,0 +1,82 @@ +package protocol + +import ( + "fmt" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func RequiresControlBundle(transition catalog.Transition) bool { + if transition.ExecutionContext == "advance" { + return true + } + switch transition.ID { + case "runtime.hydrate", "runtime.replace", "runtime.reconcile", + "installation.initialize", "installation.update", "installation.reconcile-update", "catalog.reconcile": + return true + default: + return false + } +} + +func ProjectControlBundle(snapshot model.Snapshot, transition catalog.Transition, parameters Parameters, bundle *boatstackruntime.ControlBundleContract) (*boatstackruntime.ControlBundleContract, error) { + if bundle == nil || !RequiresControlBundle(transition) || transition.ExecutionContext == "advance" { + return bundle, nil + } + identity := boatstackruntime.Identity{} + if transition.ID == "catalog.reconcile" { + if bundle.SourceRuntimePin == nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: catalog reconciliation has no trusted runtime pin") + } + identity = bundle.SourceRuntimePin.Identity() + } else { + version, versionOK := parameters.Get("runtime_version") + sha256, shaOK := parameters.Get("runtime_sha256") + source, sourceOK := parameters.Get("source_revision") + if !versionOK || !shaOK || !sourceOK { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: runtime mutation lacks exact candidate identity") + } + identity = boatstackruntime.Identity{Version: version, SHA256: sha256, SourceRevision: source} + } + targetPin := boatstackruntime.NewPin( + identity, + snapshot.ProgramFingerprint, + durable.StateSchemaVersion, + ) + pinRaw, err := boatstackruntime.EncodePin(targetPin) + if err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: %w", err) + } + base := bundle.Source + if bundle.Target != nil { + base = *bundle.Target + } + target, err := boatstackruntime.ReplaceControlBundleFile(base, ".boatstack/runtime.json", pinRaw) + if err != nil { + return nil, err + } + projected, err := boatstackruntime.NewControlBundleContractWithPins(bundle.Source, &target, bundle.TargetRevision, bundle.SourceRuntimePin, &targetPin) + if err != nil { + return nil, err + } + return &projected, nil +} + +func ValidateControlBundleForTransition(bundle *boatstackruntime.ControlBundleContract, transition catalog.Transition) error { + if bundle == nil { + if RequiresControlBundle(transition) { + return fmt.Errorf("CONTROL_BUNDLE_REQUIRED: transition %q requires an exact repository control bundle", transition.ID) + } + return nil + } + if err := bundle.Validate(); err != nil { + return err + } + if RequiresControlBundle(transition) && bundle.Target == nil { + return fmt.Errorf("CONTROL_BUNDLE_REQUIRED: transition %q requires a target bundle", transition.ID) + } + return nil +} diff --git a/boatstack/internal/softwaredelivery/protocol/prescription.go b/boatstack/internal/softwaredelivery/protocol/prescription.go index 1562888..8a6c840 100644 --- a/boatstack/internal/softwaredelivery/protocol/prescription.go +++ b/boatstack/internal/softwaredelivery/protocol/prescription.go @@ -3,12 +3,13 @@ package protocol import ( "fmt" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const PrescriptionSchemaVersion = 5 +const PrescriptionSchemaVersion = 6 // Prescription is the immutable compare-and-swap binding emitted by // resolution and required by apply. It carries no reusable authority or @@ -18,9 +19,10 @@ type Prescription struct { ID string `json:"id"` TransitionID catalog.TransitionID `json:"transition_id"` general.Freshness - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` - WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` + WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` } func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection) (Prescription, error) { @@ -28,6 +30,10 @@ func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, cap } func NewPrescriptionWithWork(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection, work *WorkEvidence) (Prescription, error) { + return NewPrescriptionWithWorkAndBundle(snapshot, transition, capabilities, work, nil) +} + +func NewPrescriptionWithWorkAndBundle(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection, work *WorkEvidence, bundle *boatstackruntime.ControlBundleContract) (Prescription, error) { objectiveBindingFingerprint, err := ObjectiveBindingFingerprint(snapshot) if err != nil { return Prescription{}, err @@ -43,6 +49,12 @@ func NewPrescriptionWithWork(snapshot model.Snapshot, transition catalog.Transit RequiredCapabilities: append([]catalog.Capability(nil), capabilities.Required...), EffectiveCapabilities: append([]catalog.Capability(nil), capabilities.Effective...), } + if err := ValidateControlBundleForTransition(bundle, transition); err != nil { + return Prescription{}, err + } + if bundle != nil { + prescription.ControlBundleFingerprint = bundle.Fingerprint + } if work != nil { if err := work.ValidateCurrent(snapshot, transition); err != nil { return Prescription{}, err @@ -80,7 +92,7 @@ func (p Prescription) Validate() error { func (p Prescription) validateFields() error { if p.SchemaVersion != PrescriptionSchemaVersion || p.TransitionID == "" || p.Freshness.Validate() != nil || - len(p.RequiredCapabilities) == 0 || len(p.EffectiveCapabilities) == 0 { + len(p.RequiredCapabilities) == 0 || len(p.EffectiveCapabilities) == 0 || (p.ControlBundleFingerprint != "" && len(p.ControlBundleFingerprint) != 64) { return fmt.Errorf("prescription has invalid schema, transition, state revision, program, or snapshot identity") } return nil @@ -125,6 +137,20 @@ func (p Prescription) ValidateWork(work *WorkEvidence) error { return nil } +func (p Prescription) ValidateControlBundle(bundle *boatstackruntime.ControlBundleContract, transition catalog.Transition) error { + if err := ValidateControlBundleForTransition(bundle, transition); err != nil { + return err + } + fingerprint := "" + if bundle != nil { + fingerprint = bundle.Fingerprint + } + if p.ControlBundleFingerprint != fingerprint { + return fmt.Errorf("prescription is bound to a different repository control bundle") + } + return nil +} + // ObjectiveBindingFingerprint projects only the durable binding status and // value. Observation evidence may be refreshed without changing the binding. func ObjectiveBindingFingerprint(snapshot model.Snapshot) (string, error) { diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index d88cb56..d0b23cf 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -12,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ReceiptSchemaVersion = 11 +const ReceiptSchemaVersion = 12 type TransitionFactKind string @@ -90,51 +90,53 @@ func (v VerificationFact) Validate() error { // TransitionReceipt is the immutable fact for one committed transition. It is // not a request, prescription, admission, refusal, or recovery authorization. type TransitionReceipt struct { - SchemaVersion int `json:"schema_version"` - Kind TransitionFactKind `json:"kind"` - ID string `json:"id"` - FlowID string `json:"flow_id"` - Sequence uint64 `json:"sequence"` - Program ProgramIdentity `json:"program"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionVersion int `json:"transition_version"` - PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` - ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` - ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` - RuntimeVersion string `json:"runtime_version,omitempty"` - RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` - RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` - PrescriptionID string `json:"prescription_id"` - AdmissionID string `json:"admission_id"` - PriorStateRevision uint64 `json:"prior_state_revision"` - ResultingStateRevision uint64 `json:"resulting_state_revision"` - ObjectiveID string `json:"objective_id"` - TargetID model.TargetID `json:"target_id"` - TrustedClass model.TargetID `json:"trusted_class,omitempty"` - DeliveryID string `json:"delivery_id"` - ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` - ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` - ObjectiveBindingFingerprint string `json:"objective_binding_fingerprint"` - SourceFingerprint string `json:"source_fingerprint"` - TargetFingerprint string `json:"target_fingerprint"` - AuthorityFingerprint string `json:"authority_fingerprint"` - AuthoritySources []AuthoritySource `json:"authority_sources"` - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` - ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` - CommittedEffects []EffectFact `json:"committed_effects"` - ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` - Verification VerificationFact `json:"verification"` - IdempotencyKey string `json:"idempotency_key"` - Recovery catalog.TransitionID `json:"recovery,omitempty"` - Terminal model.TerminalStatus `json:"terminal"` - StartedAt time.Time `json:"started_at"` - CommittedAt time.Time `json:"committed_at"` - DurationNanoseconds int64 `json:"duration_nanoseconds"` - ExecutionContext string `json:"execution_context,omitempty"` - PriorInvocation *model.InvocationContext `json:"prior_invocation,omitempty"` - ResultingInvocation *model.InvocationContext `json:"resulting_invocation,omitempty"` - WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` + SchemaVersion int `json:"schema_version"` + Kind TransitionFactKind `json:"kind"` + ID string `json:"id"` + FlowID string `json:"flow_id"` + Sequence uint64 `json:"sequence"` + Program ProgramIdentity `json:"program"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionVersion int `json:"transition_version"` + PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` + ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` + RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` + RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` + PrescriptionID string `json:"prescription_id"` + AdmissionID string `json:"admission_id"` + PriorStateRevision uint64 `json:"prior_state_revision"` + ResultingStateRevision uint64 `json:"resulting_state_revision"` + ObjectiveID string `json:"objective_id"` + TargetID model.TargetID `json:"target_id"` + TrustedClass model.TargetID `json:"trusted_class,omitempty"` + DeliveryID string `json:"delivery_id"` + ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` + ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` + ObjectiveBindingFingerprint string `json:"objective_binding_fingerprint"` + SourceFingerprint string `json:"source_fingerprint"` + TargetFingerprint string `json:"target_fingerprint"` + AuthorityFingerprint string `json:"authority_fingerprint"` + AuthoritySources []AuthoritySource `json:"authority_sources"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` + CommittedEffects []EffectFact `json:"committed_effects"` + ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` + Verification VerificationFact `json:"verification"` + IdempotencyKey string `json:"idempotency_key"` + Recovery catalog.TransitionID `json:"recovery,omitempty"` + Terminal model.TerminalStatus `json:"terminal"` + StartedAt time.Time `json:"started_at"` + CommittedAt time.Time `json:"committed_at"` + DurationNanoseconds int64 `json:"duration_nanoseconds"` + ExecutionContext string `json:"execution_context,omitempty"` + PriorInvocation *model.InvocationContext `json:"prior_invocation,omitempty"` + ResultingInvocation *model.InvocationContext `json:"resulting_invocation,omitempty"` + WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` + ControlBundleSourceFingerprint string `json:"control_bundle_source_fingerprint,omitempty"` + ControlBundleTargetFingerprint string `json:"control_bundle_target_fingerprint,omitempty"` } type AuthoritySource struct { @@ -203,6 +205,14 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi if admission.Work != nil { receipt.WorkResultFingerprint = admission.Work.ResultFingerprint } + if admission.ControlBundle != nil { + receipt.ControlBundleSourceFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + receipt.ControlBundleTargetFingerprint = admission.ControlBundle.Target.Fingerprint + } else { + receipt.ControlBundleTargetFingerprint = admission.ControlBundle.Source.Fingerprint + } + } if transition.ExecutionContext == "advance" { prior, resulting := admission.Invocation, target.Invocation if err := prior.Validate(true); err != nil { @@ -239,6 +249,10 @@ func (r TransitionReceipt) Validate() error { if r.SchemaVersion != ReceiptSchemaVersion || r.Kind != TransitionCommitted || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision != r.PriorStateRevision+1 || !validSHA256(r.SourceFingerprint) || !validSHA256(r.TargetFingerprint) || !validSHA256(r.ObjectiveBindingFingerprint) || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || r.IdempotencyKey == "" || len(r.CommittedEffects) == 0 || len(r.ChangedStateFacets) == 0 { return fmt.Errorf("receipt has incomplete committed-transition identity or evidence") } + if (r.ControlBundleSourceFingerprint == "") != (r.ControlBundleTargetFingerprint == "") || + (r.ControlBundleSourceFingerprint != "" && (len(r.ControlBundleSourceFingerprint) != 64 || len(r.ControlBundleTargetFingerprint) != 64)) { + return fmt.Errorf("receipt has incomplete repository control-bundle identity") + } if r.ExecutionContext != "" { if r.ExecutionContext != "advance" || r.PriorInvocation == nil || r.ResultingInvocation == nil { return fmt.Errorf("receipt has invalid execution context lineage") diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index d9dd373..de791fe 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -9,6 +9,7 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -17,7 +18,7 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 9 +const SchemaVersion = 10 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -50,33 +51,35 @@ func (o Operation) Valid() bool { } type Request struct { - SchemaVersion int `json:"schema_version"` - Operation Operation `json:"operation"` - Repository string `json:"repository"` - Host string `json:"host"` - CorrelationID string `json:"correlation_id"` - ProgramID string `json:"program_id,omitempty"` - ProgramFingerprint string `json:"program_fingerprint,omitempty"` - EntryID string `json:"entry_id,omitempty"` - FlowID string `json:"flow_id,omitempty"` - Objective model.Objective `json:"objective,omitempty"` - TransitionID catalog.TransitionID `json:"transition_id,omitempty"` - Prescription protocol.Prescription `json:"prescription,omitempty"` - Authority protocol.AuthorityBundle `json:"authority,omitempty"` - RepositoryAuthority bool `json:"repository_authority,omitempty"` - Parameters protocol.Parameters `json:"parameters,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` - Command string `json:"command,omitempty"` - DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` - DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` - DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` - WorkInputs map[string]protocol.WorkInputValue `json:"work_inputs,omitempty"` - WorkID string `json:"work_id,omitempty"` - WorkQuestionPrompt string `json:"work_question_prompt,omitempty"` - WorkQuestionSchema []byte `json:"work_question_schema,omitempty"` - WorkQuestionID string `json:"work_question_id,omitempty"` - WorkAnswer []byte `json:"work_answer,omitempty"` - WorkBlockReason string `json:"work_block_reason,omitempty"` + SchemaVersion int `json:"schema_version"` + Operation Operation `json:"operation"` + Repository string `json:"repository"` + Host string `json:"host"` + CorrelationID string `json:"correlation_id"` + ProgramID string `json:"program_id,omitempty"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + EntryID string `json:"entry_id,omitempty"` + FlowID string `json:"flow_id,omitempty"` + Objective model.Objective `json:"objective,omitempty"` + TransitionID catalog.TransitionID `json:"transition_id,omitempty"` + Prescription protocol.Prescription `json:"prescription,omitempty"` + Authority protocol.AuthorityBundle `json:"authority,omitempty"` + RepositoryAuthority bool `json:"repository_authority,omitempty"` + Parameters protocol.Parameters `json:"parameters,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + Command string `json:"command,omitempty"` + DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` + DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` + DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` + WorkInputs map[string]protocol.WorkInputValue `json:"work_inputs,omitempty"` + WorkID string `json:"work_id,omitempty"` + WorkQuestionPrompt string `json:"work_question_prompt,omitempty"` + WorkQuestionSchema []byte `json:"work_question_schema,omitempty"` + WorkQuestionID string `json:"work_question_id,omitempty"` + WorkAnswer []byte `json:"work_answer,omitempty"` + WorkBlockReason string `json:"work_block_reason,omitempty"` + ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` } func (r Request) Validate(now time.Time) error { @@ -95,6 +98,16 @@ func (r Request) Validate(now time.Time) error { if r.ProgramID == "" && r.ProgramFingerprint != "" { return fmt.Errorf("surface request cannot carry a program fingerprint without a program") } + if r.ControlBundle != nil { + if err := r.ControlBundle.Validate(); err != nil { + return err + } + if r.ControlBundleFingerprint != r.ControlBundle.Source.Fingerprint { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: request fingerprint does not match trusted source bundle") + } + } else if r.ControlBundleFingerprint != "" { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: request fingerprint has no trusted bundle") + } if len(r.DelegatedAuthorities) != 0 && (r.ProgramID == "" || len(r.DelegationBindingFingerprint) != 64 || len(r.DelegationRequestFingerprint) != 64) { return fmt.Errorf("surface delegated Flow request requires exact binding and request fingerprints") } diff --git a/release-notes/2026-08-15-atomic-control-bundles.md b/release-notes/2026-08-15-atomic-control-bundles.md new file mode 100644 index 0000000..b0ab6bf --- /dev/null +++ b/release-notes/2026-08-15-atomic-control-bundles.md @@ -0,0 +1,11 @@ +### Bind execution contexts to one repository control bundle + +Boatstack now fingerprints the runtime pin, project configuration, host skills, +Flow sources, locks, assets, artifacts, and generated entry skills as one +control bundle. Runtime changes and worktree transfers verify the complete +source and target bundles before committing state or lineage. + +Workspace creation now resolves its base to one commit and checks out that +exact revision. Generated entry skills also use the repository-pinned release +tag, so changing only the Boatstack build version no longer changes skill +bytes. From 9fe4b50d01e64618283464b58c3b4c6038d9a220 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 18:40:38 +0100 Subject: [PATCH 6/7] fix(runtime): close control bundle review boundaries --- .../cmd/boatstack-helper/control_bundle.go | 9 +- boatstack/flow/softwaredelivery/definition.go | 5 + .../flow/softwaredelivery/planning_package.go | 34 +++- .../softwaredelivery/planning_package_test.go | 80 ++++++++ boatstack/internal/runtime/control_bundle.go | 181 +++++++++++++++++- .../internal/runtime/control_bundle_test.go | 34 ++++ .../softwaredelivery/durable/state.go | 12 +- .../durable/state_schema_test.go | 15 +- .../effects/command_boundary.go | 13 +- .../effects/command_boundary_test.go | 21 ++ .../effects/integration_test.go | 5 +- .../effects/planning_package_test.go | 2 +- .../softwaredelivery/effects/state_reducer.go | 2 +- .../softwaredelivery/engine/engine.go | 16 +- .../engine/maintenance_replay_test.go | 40 ++++ .../internal/softwaredelivery/model/state.go | 3 +- .../softwaredelivery/plant/observer.go | 2 +- .../softwaredelivery/plant/observer_test.go | 2 +- .../softwaredelivery/protocol/admission.go | 11 ++ 19 files changed, 448 insertions(+), 39 deletions(-) create mode 100644 boatstack/flow/softwaredelivery/planning_package_test.go diff --git a/boatstack/cmd/boatstack-helper/control_bundle.go b/boatstack/cmd/boatstack-helper/control_bundle.go index 8c996e9..3c95b24 100644 --- a/boatstack/cmd/boatstack-helper/control_bundle.go +++ b/boatstack/cmd/boatstack-helper/control_bundle.go @@ -93,6 +93,7 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep return boatstackruntime.ControlBundleSnapshot{}, err } sort.Strings(artifacts) + artifactPaths := make([]string, 0, len(artifacts)) resolver, err := softwareflow.NewResolver(ctx) if err != nil { return boatstackruntime.ControlBundleSnapshot{}, err @@ -113,7 +114,9 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep if relErr != nil { return boatstackruntime.ControlBundleSnapshot{}, relErr } - paths[filepath.ToSlash(relative)] = struct{}{} + relative = filepath.ToSlash(relative) + artifactPaths = append(artifactPaths, relative) + paths[relative] = struct{}{} paths[artifact.SourcePath] = struct{}{} paths[artifact.DependencyLockPath] = struct{}{} for path := range artifact.Assets { @@ -139,7 +142,9 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep } files[filepath.ToSlash(path)] = raw } - return boatstackruntime.NewControlBundleSnapshotWithAbsent(files, absent) + return boatstackruntime.NewControlBundleSnapshotWithMemberSets(files, absent, []boatstackruntime.ControlBundleMemberSet{{ + Root: ".boatstack/flows", Suffix: ".flow.ir.json", Paths: artifactPaths, + }}) } func controlBundleRequired(id catalog.TransitionID) bool { diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index 579d72c..8841f6d 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -102,6 +102,11 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim if err != nil { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) } + if transition.ID == PlanningPackageAdmit { + if err := validatePlanningPackageWorkContract(*transition.Work); err != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) + } + } transition.OwnedResources = append(transition.OwnedResources, "foreground-work-"+transition.Work.ID) } for _, authority := range declaration.Requires.Authorities { diff --git a/boatstack/flow/softwaredelivery/planning_package.go b/boatstack/flow/softwaredelivery/planning_package.go index accc4dc..95aca83 100644 --- a/boatstack/flow/softwaredelivery/planning_package.go +++ b/boatstack/flow/softwaredelivery/planning_package.go @@ -4,8 +4,10 @@ import ( "encoding/json" "fmt" "os" + "path" "path/filepath" "regexp" + "strings" "github.com/operatorstack/boatstack/boatstack/delivery" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -41,8 +43,8 @@ func planningPackageTransitions(transitions map[string]delivery.Transition) ([]d admit.SourcePredicate, admit.AdmissionPredicate, admit.TargetPredicate = "planning-package-admit-source", "exact-work-and-transition-admission", "planning-package-valid" admit.Verifier = "verifier:fresh-observation:" + PlanningPackageAdmit admit.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-admit"} - admit.TargetConditions = replacePlanCondition(admit.TargetConditions, model.PlanValid) - admit.OwnedResources = []string{"planning-package"} + admit.TargetConditions = replacePlanCondition(admit.TargetConditions, model.PlanPackageValid) + admit.OwnedResources = []string{"plan", "planning-package"} admit.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageAdmit approve, err := clone("plan.approve") @@ -57,9 +59,9 @@ func planningPackageTransitions(transitions map[string]delivery.Transition) ([]d approve.SourcePredicate, approve.AdmissionPredicate, approve.TargetPredicate = "planning-package-valid", "exact-package-approval", "planning-package-approved" approve.Verifier = "verifier:fresh-observation:" + PlanningPackageApprove approve.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-approve"} - approve.SourceConditions = replacePlanCondition(approve.SourceConditions, model.PlanValid) + approve.SourceConditions = replacePlanCondition(approve.SourceConditions, model.PlanPackageValid) approve.TargetConditions = replacePlanCondition(approve.TargetConditions, model.PlanPackageApproved) - approve.OwnedResources = []string{"planning-package-approval"} + approve.OwnedResources = []string{"plan", "planning-package-approval"} approve.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageApprove promote, err := clone("plan.activate") @@ -76,7 +78,7 @@ func planningPackageTransitions(transitions map[string]delivery.Transition) ([]d promote.SourceConditions = replacePlanCondition(promote.SourceConditions, model.PlanPackageApproved) promote.TargetConditions = replacePlanCondition(promote.TargetConditions, model.PlanApproved) promote.TargetIDs = append([]model.TargetID(nil), admit.TargetIDs...) - promote.OwnedResources = []string{"planning-package-promotion"} + promote.OwnedResources = []string{"plan", "planning-package-promotion"} promote.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackagePromote return []delivery.Transition{admit, approve, promote}, nil @@ -93,6 +95,28 @@ func replacePlanCondition(values []delivery.FacetCondition, state model.PlanStat return result } +func validatePlanningPackageWorkContract(work delivery.WorkContract) error { + var planOutput *delivery.WorkOutput + for index := range work.Outputs { + output := &work.Outputs[index] + for _, reserved := range []string{"manifest.json", "approval.json"} { + if output.Path == reserved || strings.HasPrefix(output.Path, reserved+"/") || strings.HasPrefix(reserved, output.Path+"/") { + return fmt.Errorf("output %q conflicts with runtime-owned planning-package metadata %q", output.ID, reserved) + } + } + if output.ID == "plan" { + planOutput = output + } + } + if planOutput == nil || !planOutput.Required { + return fmt.Errorf("planning-package admission requires a required output named %q", "plan") + } + if path.Clean(planOutput.Path) != planOutput.Path || planOutput.Path == "." { + return fmt.Errorf("planning-package plan output path is not canonical") + } + return nil +} + // PlanningPackageFingerprint reads the current repository package projection. // Effect preflight independently verifies the complete manifest before any // mutation; this helper only binds the candidate parameter for continuation. diff --git a/boatstack/flow/softwaredelivery/planning_package_test.go b/boatstack/flow/softwaredelivery/planning_package_test.go new file mode 100644 index 0000000..d91b62e --- /dev/null +++ b/boatstack/flow/softwaredelivery/planning_package_test.go @@ -0,0 +1,80 @@ +package softwaredelivery + +import ( + "context" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func TestPlanningPackageWorkRequiresOwnedPlanOutput(t *testing.T) { + tests := []struct { + name string + outputs []delivery.WorkOutput + want string + }{ + {name: "missing plan", outputs: []delivery.WorkOutput{{ID: "questions", Path: "questions.md", Required: true}}, want: `required output named "plan"`}, + {name: "optional plan", outputs: []delivery.WorkOutput{{ID: "plan", Path: "plan.md"}}, want: `required output named "plan"`}, + {name: "manifest collision", outputs: []delivery.WorkOutput{{ID: "plan", Path: "manifest.json", Required: true}}, want: "runtime-owned"}, + {name: "approval descendant collision", outputs: []delivery.WorkOutput{{ID: "plan", Path: "plan.md", Required: true}, {ID: "evidence", Path: "approval.json/evidence", Required: true}}, want: "runtime-owned"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validatePlanningPackageWorkContract(delivery.WorkContract{ID: "planning", Outputs: test.outputs}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want %q", err, test.want) + } + }) + } +} + +func TestPlanningPackageUsesDistinctPlanStateAndSharedPlanLease(t *testing.T) { + manifest, err := standard.Definition().RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + base := make(map[string]delivery.Transition, len(manifest.Transitions)) + for _, transition := range manifest.Transitions { + base[string(transition.ID)] = transition + } + transitions, err := planningPackageTransitions(base) + if err != nil { + t.Fatal(err) + } + for _, transition := range transitions { + if !containsString(transition.OwnedResources, "plan") { + t.Fatalf("%s does not serialize on the canonical plan resource: %v", transition.ID, transition.OwnedResources) + } + } + var got []string + for _, condition := range transitions[0].TargetConditions { + if condition.Facet == model.FacetPlan { + got = condition.Values + } + } + if !containsString(got, string(model.PlanPackageValid)) || containsString(got, string(model.PlanValid)) { + t.Fatalf("planning-package admission target = %v", got) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func TestPlanningPackageWorkAcceptsRequiredPlanAndDomainOutputs(t *testing.T) { + work := delivery.WorkContract{ID: "planning", Outputs: []delivery.WorkOutput{ + {ID: "plan", Path: "plan.md", Required: true}, + {ID: "questions", Path: "questions.md", Required: true}, + }} + if err := validatePlanningPackageWorkContract(work); err != nil { + t.Fatal(err) + } +} diff --git a/boatstack/internal/runtime/control_bundle.go b/boatstack/internal/runtime/control_bundle.go index b0a318a..3870494 100644 --- a/boatstack/internal/runtime/control_bundle.go +++ b/boatstack/internal/runtime/control_bundle.go @@ -24,11 +24,20 @@ type ControlBundleFile struct { Absent bool `json:"absent,omitempty"` } +// ControlBundleMemberSet binds the complete direct-child membership of one +// executable control directory, not only the files discovered at projection. +type ControlBundleMemberSet struct { + Root string `json:"root"` + Suffix string `json:"suffix"` + Paths []string `json:"paths"` +} + // ControlBundleSnapshot is the canonical executable control projection at one // repository root. type ControlBundleSnapshot struct { - Fingerprint string `json:"fingerprint"` - Files []ControlBundleFile `json:"files"` + Fingerprint string `json:"fingerprint"` + Files []ControlBundleFile `json:"files"` + MemberSets []ControlBundleMemberSet `json:"member_sets,omitempty"` } // ControlBundleContract binds the source projection and, for execution-context @@ -48,6 +57,10 @@ func NewControlBundleSnapshot(files map[string][]byte) (ControlBundleSnapshot, e } func NewControlBundleSnapshotWithAbsent(files map[string][]byte, absent []string) (ControlBundleSnapshot, error) { + return NewControlBundleSnapshotWithMemberSets(files, absent, nil) +} + +func NewControlBundleSnapshotWithMemberSets(files map[string][]byte, absent []string, memberSets []ControlBundleMemberSet) (ControlBundleSnapshot, error) { bindings := make([]ControlBundleFile, 0, len(files)) for path, raw := range files { if !safeProjectionRelative(path) { @@ -71,11 +84,15 @@ func NewControlBundleSnapshotWithAbsent(files map[string][]byte, absent []string return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: duplicate path %q", bindings[index].Path) } } - fingerprint, err := controlBundleDigest(bindings) + canonicalSets, err := canonicalControlBundleMemberSets(memberSets, bindings) if err != nil { return ControlBundleSnapshot{}, err } - return ControlBundleSnapshot{Fingerprint: fingerprint, Files: bindings}, nil + fingerprint, err := controlBundleSnapshotDigest(bindings, canonicalSets) + if err != nil { + return ControlBundleSnapshot{}, err + } + return ControlBundleSnapshot{Fingerprint: fingerprint, Files: bindings, MemberSets: canonicalSets}, nil } // ReplaceControlBundleFile derives a target snapshot without trusting a @@ -101,11 +118,15 @@ func ReplaceControlBundleFile(snapshot ControlBundleSnapshot, path string, raw [ files = append(files, binding) sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) } - fingerprint, err := controlBundleDigest(files) + memberSets, err := canonicalControlBundleMemberSets(snapshot.MemberSets, files) if err != nil { return ControlBundleSnapshot{}, err } - return ControlBundleSnapshot{Fingerprint: fingerprint, Files: files}, nil + fingerprint, err := controlBundleSnapshotDigest(files, memberSets) + if err != nil { + return ControlBundleSnapshot{}, err + } + return ControlBundleSnapshot{Fingerprint: fingerprint, Files: files, MemberSets: memberSets}, nil } func NewControlBundleContract(source ControlBundleSnapshot, target *ControlBundleSnapshot, targetRevision string) (ControlBundleContract, error) { @@ -210,13 +231,141 @@ func (s ControlBundleSnapshot) validate() error { } prior = file.Path } - fingerprint, err := controlBundleDigest(s.Files) + memberSets, err := canonicalControlBundleMemberSets(s.MemberSets, s.Files) + if err != nil || !equalControlBundleMemberSets(memberSets, s.MemberSets) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: member sets are not canonical") + } + fingerprint, err := controlBundleSnapshotDigest(s.Files, s.MemberSets) if err != nil || fingerprint != s.Fingerprint { return fmt.Errorf("CONTROL_BUNDLE_INVALID: snapshot fingerprint mismatch") } return nil } +func canonicalControlBundleMemberSets(values []ControlBundleMemberSet, files []ControlBundleFile) ([]ControlBundleMemberSet, error) { + sets := make([]ControlBundleMemberSet, len(values)) + copy(sets, values) + bound := make(map[string]bool, len(files)) + for _, file := range files { + if !file.Absent { + bound[file.Path] = true + } + } + for index := range sets { + memberSet := &sets[index] + if !safeProjectionRelative(memberSet.Root) || strings.Contains(memberSet.Suffix, "/") || strings.Contains(memberSet.Suffix, "\\") || memberSet.Suffix == "" { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe member set") + } + memberSet.Root = strings.TrimSuffix(memberSet.Root, "/") + memberSet.Paths = append([]string(nil), memberSet.Paths...) + sort.Strings(memberSet.Paths) + for pathIndex, memberPath := range memberSet.Paths { + if !safeProjectionRelative(memberPath) || filepath.ToSlash(filepath.Dir(filepath.FromSlash(memberPath))) != memberSet.Root || !strings.HasSuffix(memberPath, memberSet.Suffix) || !bound[memberPath] { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: member set path %q is not a bound direct child", memberPath) + } + if pathIndex > 0 && memberSet.Paths[pathIndex-1] == memberPath { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: duplicate member set path %q", memberPath) + } + } + } + sort.Slice(sets, func(i, j int) bool { + if sets[i].Root != sets[j].Root { + return sets[i].Root < sets[j].Root + } + return sets[i].Suffix < sets[j].Suffix + }) + for index := 1; index < len(sets); index++ { + if sets[index-1].Root == sets[index].Root && sets[index-1].Suffix == sets[index].Suffix { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: duplicate member set") + } + } + return sets, nil +} + +func controlBundleSnapshotDigest(files []ControlBundleFile, memberSets []ControlBundleMemberSet) (string, error) { + return controlBundleDigest(struct { + Files []ControlBundleFile `json:"files"` + MemberSets []ControlBundleMemberSet `json:"member_sets,omitempty"` + }{Files: files, MemberSets: memberSets}) +} + +func equalControlBundleMemberSets(left, right []ControlBundleMemberSet) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index].Root != right[index].Root || left[index].Suffix != right[index].Suffix || !equalStrings(left[index].Paths, right[index].Paths) { + return false + } + } + return true +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func rootMemberSet(repository string, memberSet ControlBundleMemberSet) ([]string, error) { + entries, err := os.ReadDir(filepath.Join(repository, filepath.FromSlash(memberSet.Root))) + if os.IsNotExist(err) { + return []string{}, nil + } + if err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: read member set %s: %w", memberSet.Root, err) + } + paths := []string{} + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), memberSet.Suffix) { + continue + } + info, infoErr := entry.Info() + if infoErr != nil || entry.Type()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: member set path %s/%s is not a regular file", memberSet.Root, entry.Name()) + } + paths = append(paths, memberSet.Root+"/"+entry.Name()) + } + sort.Strings(paths) + return paths, nil +} + +func revisionMemberSet(ctx context.Context, repository, revision string, memberSet ControlBundleMemberSet) ([]string, error) { + command := exec.CommandContext(ctx, "git", "ls-tree", "-r", "-z", revision, "--", memberSet.Root) + command.Dir = repository + output, err := command.Output() + if err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: inspect revision %s member set %s: %w", revision, memberSet.Root, err) + } + paths := []string{} + for _, record := range bytes.Split(output, []byte{0}) { + if len(record) == 0 { + continue + } + fields := bytes.SplitN(record, []byte{'\t'}, 2) + metadata := strings.Fields(string(fields[0])) + if len(fields) != 2 || len(metadata) < 1 { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: revision member set response is malformed") + } + memberPath := string(fields[1]) + if filepath.ToSlash(filepath.Dir(filepath.FromSlash(memberPath))) != memberSet.Root || !strings.HasSuffix(memberPath, memberSet.Suffix) { + continue + } + if metadata[0] != "100644" && metadata[0] != "100755" { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: revision member set path %s is not a regular file", memberPath) + } + paths = append(paths, memberPath) + } + sort.Strings(paths) + return paths, nil +} + func validControlDigest(value string) bool { if len(value) != 64 || strings.ToLower(value) != value { return false @@ -241,6 +390,15 @@ func VerifyControlBundleRoot(repository string, snapshot ControlBundleSnapshot) if err != nil || !filepath.IsAbs(repository) || filepath.Clean(repository) != repository { return fmt.Errorf("CONTROL_BUNDLE_INVALID: repository root is not exact") } + for _, memberSet := range snapshot.MemberSets { + observed, observeErr := rootMemberSet(repository, memberSet) + if observeErr != nil { + return observeErr + } + if !equalStrings(observed, memberSet.Paths) { + return fmt.Errorf("CONTROL_BUNDLE_STALE: member set %s/*%s does not match the admitted bundle", memberSet.Root, memberSet.Suffix) + } + } for _, file := range snapshot.Files { if file.Absent { if _, statErr := os.Lstat(filepath.Join(repository, filepath.FromSlash(file.Path))); !os.IsNotExist(statErr) { @@ -267,6 +425,15 @@ func VerifyControlBundleRevision(ctx context.Context, repository, revision strin if repository == "" || !filepath.IsAbs(repository) || revision == "" { return fmt.Errorf("CONTROL_BUNDLE_INVALID: revision verification is incomplete") } + for _, memberSet := range snapshot.MemberSets { + observed, observeErr := revisionMemberSet(ctx, repository, revision, memberSet) + if observeErr != nil { + return observeErr + } + if !equalStrings(observed, memberSet.Paths) { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s member set %s/*%s does not match the admitted bundle", revision, memberSet.Root, memberSet.Suffix) + } + } for _, file := range snapshot.Files { if file.Absent { command := exec.CommandContext(ctx, "git", "cat-file", "-e", revision+":"+file.Path) diff --git a/boatstack/internal/runtime/control_bundle_test.go b/boatstack/internal/runtime/control_bundle_test.go index b25ce93..e70f1fb 100644 --- a/boatstack/internal/runtime/control_bundle_test.go +++ b/boatstack/internal/runtime/control_bundle_test.go @@ -73,6 +73,40 @@ func TestControlBundleVerifiesRootRevisionAndExactHead(t *testing.T) { } } +func TestControlBundleBindsCompleteExecutableDirectoryMembership(t *testing.T) { + repository := t.TempDir() + runBundleGit(t, repository, "init", "-q") + runBundleGit(t, repository, "config", "user.email", "bundle@example.invalid") + runBundleGit(t, repository, "config", "user.name", "Bundle Test") + flows := filepath.Join(repository, ".boatstack", "flows") + if err := os.MkdirAll(flows, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(flows, "primary.flow.ir.json"), []byte("primary\n"), 0o644); err != nil { + t.Fatal(err) + } + snapshot, err := NewControlBundleSnapshotWithMemberSets(map[string][]byte{ + ".boatstack/flows/primary.flow.ir.json": []byte("primary\n"), + }, nil, []ControlBundleMemberSet{{Root: ".boatstack/flows", Suffix: ".flow.ir.json", Paths: []string{".boatstack/flows/primary.flow.ir.json"}}}) + if err != nil { + t.Fatal(err) + } + runBundleGit(t, repository, "add", ".boatstack/flows") + runBundleGit(t, repository, "commit", "-q", "-m", "primary") + if err := os.WriteFile(filepath.Join(flows, "secondary.flow.ir.json"), []byte("secondary\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleRoot(repository, snapshot); err == nil || !strings.Contains(err.Error(), "member set") { + t.Fatalf("extra executable member was accepted at root: %v", err) + } + runBundleGit(t, repository, "add", ".boatstack/flows/secondary.flow.ir.json") + runBundleGit(t, repository, "commit", "-q", "-m", "secondary") + revision := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + if err := VerifyControlBundleRevision(context.Background(), repository, revision, snapshot); err == nil || !strings.Contains(err.Error(), "member set") { + t.Fatalf("extra executable member was accepted at revision: %v", err) + } +} + func runBundleGit(t *testing.T, directory string, arguments ...string) string { t.Helper() command := exec.Command("git", arguments...) diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index 95b0700..f40abed 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -14,7 +14,7 @@ import ( const ( StateSchemaVersion = 6 - priorStateSchemaVersion = 5 + priorStateSchemaVersion = 4 ) // CanReadStateSchema reports the current schema and its single supported @@ -212,10 +212,12 @@ func DecodeState(value []byte) (State, error) { return State{}, fmt.Errorf("durable state contains trailing JSON") } if state.SchemaVersion == priorStateSchemaVersion { - // Schema 6 adds only the optional control-bundle identity. The prior - // bytes remain the journal rollback source until a transition commits. - if state.ControlBundleFingerprint != "" { - return State{}, fmt.Errorf("durable state schema %d contains schema %d control-bundle fields", priorStateSchemaVersion, StateSchemaVersion) + // The released predecessor is schema 4. Schemas 5 and 6 add planning + // package and control-bundle identity; neither may be smuggled into + // predecessor bytes. The original bytes remain the journal rollback + // source until a transition commits. + if state.PlanningPackageFingerprint != "" || state.ControlBundleFingerprint != "" { + return State{}, fmt.Errorf("durable state schema %d contains later identity fields", priorStateSchemaVersion) } state.SchemaVersion = StateSchemaVersion } diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index cc4f0f5..96b7def 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -37,7 +37,7 @@ func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *test } } -func TestDecodeStatePromotesSchemaFiveWithoutChangingPriorBytes(t *testing.T) { +func TestDecodeStatePromotesReleasedSchemaFourWithoutChangingPriorBytes(t *testing.T) { // control-law: forward state migration is read-only until a transaction commits state := State{ SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 7, @@ -75,9 +75,16 @@ func TestDecodeStatePromotesSchemaFiveWithoutChangingPriorBytes(t *testing.T) { } } -func TestDecodeStateRejectsSchemaFiveWithSchemaSixControlBundle(t *testing.T) { - raw := []byte(`{"schema_version":5,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","control_bundle_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) +func TestDecodeStateRejectsSchemaFourWithLaterIdentityFields(t *testing.T) { + raw := []byte(`{"schema_version":4,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planning_package_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","control_bundle_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) if _, err := DecodeState(raw); err == nil { - t.Fatal("schema-5 state smuggled schema-6 control-bundle fields") + t.Fatal("schema-4 state smuggled later identity fields") + } +} + +func TestDecodeStateRejectsUnreleasedSchemaFive(t *testing.T) { + raw := []byte(`{"schema_version":5,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) + if _, err := DecodeState(raw); err == nil { + t.Fatal("unreleased schema 5 was accepted as a migration predecessor") } } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index ae3fdea..ac0ed43 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -12,6 +12,7 @@ import ( "runtime" "strings" "time" + "unicode" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" @@ -64,9 +65,9 @@ type githubAuthorityObservation struct { // ResolveGitHubProviderAuthority derives short-lived provider capability from // the trusted GitHub CLI boundary. It is capability evidence, not human // approval; run delegation remains the independent approval source. -func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repository, previewFingerprint string, now time.Time) (protocol.AuthorityReceipt, error) { - if len(previewFingerprint) != 64 || strings.Trim(previewFingerprint, "0123456789abcdef") != "" { - return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: preview fingerprint must be lowercase SHA-256") +func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repository, authorityBinding string, now time.Time) (protocol.AuthorityReceipt, error) { + if authorityBinding == "" || len(authorityBinding) > 256 || strings.TrimSpace(authorityBinding) != authorityBinding || strings.IndexFunc(authorityBinding, unicode.IsControl) >= 0 { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: authority binding must be non-empty, bounded, and free of control characters") } output, err := b.runner.CombinedOutput(ctx, repository, "gh", "repo", "view", "--json", "nameWithOwner,viewerPermission,url") if err != nil { @@ -92,10 +93,10 @@ func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repo } issued := now.UTC() subject := "github:" + observed.NameWithOwner - digest := sha256.Sum256([]byte(subject + "\x00" + previewFingerprint)) + digest := sha256.Sum256([]byte(subject + "\x00" + authorityBinding)) return protocol.AuthorityReceipt{ ID: "provider-" + fmt.Sprintf("%x", digest[:8]), Class: catalog.AuthorityProvider, - Subject: subject, Fingerprint: previewFingerprint, IssuedAt: issued, ExpiresAt: issued.Add(2 * time.Minute), + Subject: subject, Fingerprint: authorityBinding, IssuedAt: issued, ExpiresAt: issued.Add(2 * time.Minute), }, nil } @@ -250,7 +251,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "check-ref-format", "--branch", branch); err != nil { return settled, fmt.Errorf("invalid workspace branch: %s: %w", strings.TrimSpace(string(output)), err) } - arguments := []string{"worktree", "add", "-b", branch, absolute, baseRevision} + arguments := []string{"-c", "core.autocrlf=false", "-c", "core.eol=lf", "worktree", "add", "-b", branch, absolute, baseRevision} if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", arguments...); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index 50bf1ad..65371f9 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -87,6 +87,27 @@ func TestGitHubProviderAuthorityIsDerivedFromWriteCapability(t *testing.T) { } } +func TestGitHubProviderAuthorityAcceptsOpaqueTrustedTransitionBinding(t *testing.T) { + runner := &boundaryRunner{output: []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`)} + boundary, _ := NewNativeBoundaryWithRunner(runner) + receipt, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), "123", time.Unix(100, 0).UTC()) + if err != nil { + t.Fatal(err) + } + if receipt.Fingerprint != "123" { + t.Fatalf("provider binding = %q", receipt.Fingerprint) + } +} + +func TestGitHubProviderAuthorityRejectsUnsafeBinding(t *testing.T) { + boundary, _ := NewNativeBoundaryWithRunner(&boundaryRunner{}) + for _, binding := range []string{"", " publication", "publication\n"} { + if _, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), binding, time.Unix(100, 0).UTC()); err == nil || !strings.Contains(err.Error(), "PROVIDER_AUTHORITY_INVALID") { + t.Fatalf("unsafe binding %q error = %v", binding, err) + } + } +} + func TestGitHubProviderAuthorityRejectsReadOnlyIdentity(t *testing.T) { runner := &boundaryRunner{output: []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"READ"}`)} boundary, _ := NewNativeBoundaryWithRunner(runner) diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 8ff964a..d2eacd1 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -646,7 +646,8 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err := json.Unmarshal(afterSuccess, &legacyState); err != nil { t.Fatal(err) } - legacyState["schema_version"] = float64(durable.StateSchemaVersion - 1) + legacyState["schema_version"] = float64(durable.StateSchemaVersion - 2) + delete(legacyState, "planning_package_fingerprint") delete(legacyState, "control_bundle_fingerprint") legacyRaw, err := json.MarshalIndent(legacyState, "", " ") if err != nil { @@ -665,7 +666,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err != nil { t.Fatal(err) } - legacyPin.StateSchemaVersion = durable.StateSchemaVersion - 1 + legacyPin.StateSchemaVersion = durable.StateSchemaVersion - 2 legacyPinRaw, err = boatstackruntime.EncodePin(legacyPin) if err != nil { t.Fatal(err) diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index f724ac2..9b788c9 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -44,7 +44,7 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactWorkEvidence(t *testing.T) { if err != nil { t.Fatal(err) } - if state.Plan != model.PlanValid || state.PlanFingerprint != sha256Bytes([]byte(plan)) || manifest.WorkResultFingerprint != work.ResultFingerprint { + if state.Plan != model.PlanPackageValid || state.PlanFingerprint != sha256Bytes([]byte(plan)) || manifest.WorkResultFingerprint != work.ResultFingerprint { t.Fatalf("admitted state=%#v manifest=%#v", state, manifest) } diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index 81dec9a..a31048e 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -390,7 +390,7 @@ func applyPlanApprove(state *durable.State, admission protocol.Admission, _ cata } func applyPlanningPackageAdmit(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { - state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanValid, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal + state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanPackageValid, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal return nil } diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index 0f422be..f183312 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -624,11 +624,21 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques return fmt.Errorf("idempotency receipt belongs to a repository control bundle") } } else { - if prior.ControlBundleSourceFingerprint != bundle.Source.Fingerprint { + if prior.ControlBundleTargetFingerprint == "" { + if prior.ControlBundleSourceFingerprint != bundle.Source.Fingerprint || bundle.Target != nil { + return fmt.Errorf("idempotency receipt belongs to a different repository control bundle") + } + return nil + } + if prior.ControlBundleSourceFingerprint != bundle.Source.Fingerprint && prior.ControlBundleTargetFingerprint != bundle.Source.Fingerprint { return fmt.Errorf("idempotency receipt belongs to a different repository control bundle") } - if bundle.Target != nil && prior.ControlBundleTargetFingerprint != bundle.Target.Fingerprint { - return fmt.Errorf("idempotency receipt belongs to a different repository control-bundle target") + if bundle.Target == nil { + return nil + } + targetFingerprint := bundle.Target.Fingerprint + if prior.ControlBundleTargetFingerprint != targetFingerprint { + return fmt.Errorf("idempotency receipt belongs to a different repository control-bundle target: receipt %s request %s", prior.ControlBundleTargetFingerprint, targetFingerprint) } } return nil diff --git a/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go b/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go index c38ca96..3b4b0ea 100644 --- a/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go +++ b/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go @@ -3,11 +3,51 @@ package engine import ( "testing" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) +func TestMaintenanceReplayAcceptsCurrentCommittedControlBundleTarget(t *testing.T) { + before, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": []byte("before")}) + if err != nil { + t.Fatal(err) + } + after, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": []byte("after")}) + if err != nil { + t.Fatal(err) + } + committed, err := boatstackruntime.NewControlBundleContract(before, &after, "") + if err != nil { + t.Fatal(err) + } + rebuilt, err := boatstackruntime.NewControlBundleContract(after, &after, "") + if err != nil { + t.Fatal(err) + } + request := ApplyRequest{FlowID: "flow"} + receipt := protocol.TransitionReceipt{ + FlowID: "flow", Program: syntheticProgram, + ControlBundleSourceFingerprint: committed.Source.Fingerprint, + ControlBundleTargetFingerprint: committed.Target.Fingerprint, + } + if err := validateReplayRequest(receipt, request, syntheticProgramFingerprint, &rebuilt); err != nil { + t.Fatalf("current committed bundle could not replay prior source-to-target receipt: %v", err) + } + other, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": []byte("other")}) + if err != nil { + t.Fatal(err) + } + drifted, err := boatstackruntime.NewControlBundleContract(other, &other, "") + if err != nil { + t.Fatal(err) + } + if err := validateReplayRequest(receipt, request, syntheticProgramFingerprint, &drifted); err == nil { + t.Fatal("unrelated bundle replay was accepted") + } +} + func TestMaintenanceReplayBindsDurableObjectiveState(t *testing.T) { // control-law: maintenance-replay-preserves-verified-objective-state configured := model.Objective{ID: "configured", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} diff --git a/boatstack/internal/softwaredelivery/model/state.go b/boatstack/internal/softwaredelivery/model/state.go index e41b308..d2fa472 100644 --- a/boatstack/internal/softwaredelivery/model/state.go +++ b/boatstack/internal/softwaredelivery/model/state.go @@ -132,6 +132,7 @@ const ( PlanAbsent PlanState = "absent" PlanDraft PlanState = "draft" PlanValid PlanState = "valid" + PlanPackageValid PlanState = "package-valid" PlanPackageApproved PlanState = "package-approved" PlanApproved PlanState = "approved" PlanLocked PlanState = "locked" @@ -142,7 +143,7 @@ const ( func (s PlanState) Valid() bool { switch s { - case PlanAbsent, PlanDraft, PlanValid, PlanPackageApproved, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: + case PlanAbsent, PlanDraft, PlanValid, PlanPackageValid, PlanPackageApproved, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: return true default: return false diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 14aaf52..34bac4d 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -559,7 +559,7 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta return plan, verification, terminal, planEvidence, verificationEvidence, nil } deliveryID := state.Objective.DeliveryID - packagePlan := (state.Plan == model.PlanValid && state.PlanningPackageFingerprint != "") || state.Plan == model.PlanPackageApproved + packagePlan := state.Plan == model.PlanPackageValid || state.Plan == model.PlanPackageApproved if packagePlan { evidence, valid, err := observePlanningPackage(layout, state, now) if err != nil { diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index 6767f52..3d86e14 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -60,7 +60,7 @@ func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t t.Fatal(err) } state := durable.State{ - Plan: model.PlanValid, PlanFingerprint: planFingerprint, PlanningPackageFingerprint: manifest.Fingerprint, + Plan: model.PlanPackageValid, PlanFingerprint: planFingerprint, PlanningPackageFingerprint: manifest.Fingerprint, Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: deliveryID}, } evidence, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(100, 0).UTC()) diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 0d38b51..1c862af 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -107,6 +107,7 @@ func NewAdmissionWithWorkAndBundle(snapshot model.Snapshot, objective model.Obje if bundle != nil { copy := *bundle copy.Source.Files = append([]boatstackruntime.ControlBundleFile(nil), bundle.Source.Files...) + copy.Source.MemberSets = cloneControlBundleMemberSets(bundle.Source.MemberSets) if bundle.SourceRuntimePin != nil { pin := *bundle.SourceRuntimePin copy.SourceRuntimePin = &pin @@ -114,6 +115,7 @@ func NewAdmissionWithWorkAndBundle(snapshot model.Snapshot, objective model.Obje if bundle.Target != nil { target := *bundle.Target target.Files = append([]boatstackruntime.ControlBundleFile(nil), bundle.Target.Files...) + target.MemberSets = cloneControlBundleMemberSets(bundle.Target.MemberSets) copy.Target = &target } if bundle.TargetRuntimePin != nil { @@ -154,6 +156,15 @@ func NewAdmissionWithWorkAndBundle(snapshot model.Snapshot, objective model.Obje return a, nil } +func cloneControlBundleMemberSets(values []boatstackruntime.ControlBundleMemberSet) []boatstackruntime.ControlBundleMemberSet { + result := make([]boatstackruntime.ControlBundleMemberSet, len(values)) + for index := range values { + result[index] = values[index] + result[index].Paths = append([]string(nil), values[index].Paths...) + } + return result +} + // ObjectiveForTransition binds maintenance to verified durable objective state. // Command-scoped product intent is deliberately irrelevant to maintenance. func ObjectiveForTransition(snapshot model.Snapshot, requested model.Objective, transition catalog.Transition) (model.Objective, error) { From d6a0735cbfa991c2cd51a6814c6395ad52efc68e Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 18:56:27 +0100 Subject: [PATCH 7/7] fix(runtime): bind final review identities --- .../effects/command_boundary.go | 34 +++++++++++++++++- .../effects/command_boundary_test.go | 21 ++++++++--- .../softwaredelivery/effects/journal.go | 14 ++++++++ .../effects/journal_work_test.go | 35 +++++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 boatstack/internal/softwaredelivery/effects/journal_work_test.go diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index ac0ed43..3f01277 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "os/exec" "path/filepath" @@ -69,7 +70,15 @@ func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repo if authorityBinding == "" || len(authorityBinding) > 256 || strings.TrimSpace(authorityBinding) != authorityBinding || strings.IndexFunc(authorityBinding, unicode.IsControl) >= 0 { return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: authority binding must be non-empty, bounded, and free of control characters") } - output, err := b.runner.CombinedOutput(ctx, repository, "gh", "repo", "view", "--json", "nameWithOwner,viewerPermission,url") + remoteOutput, err := b.runner.CombinedOutput(ctx, repository, "git", "remote", "get-url", "--push", "origin") + if err != nil { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_UNAVAILABLE: origin push repository identity is unavailable") + } + remoteRepository, err := githubRepositoryFromRemote(strings.TrimSpace(string(remoteOutput))) + if err != nil { + return protocol.AuthorityReceipt{}, err + } + output, err := b.runner.CombinedOutput(ctx, repository, "gh", "repo", "view", remoteRepository, "--json", "nameWithOwner,viewerPermission,url") if err != nil { return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_UNAVAILABLE: GitHub repository identity or authenticated access is unavailable") } @@ -91,6 +100,9 @@ func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repo if observed.NameWithOwner == "" || observed.URL == "" { return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub repository identity is incomplete") } + if !strings.EqualFold(observed.NameWithOwner, remoteRepository) { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub authority does not match the origin push repository") + } issued := now.UTC() subject := "github:" + observed.NameWithOwner digest := sha256.Sum256([]byte(subject + "\x00" + authorityBinding)) @@ -100,6 +112,26 @@ func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repo }, nil } +func githubRepositoryFromRemote(remote string) (string, error) { + path := "" + switch { + case strings.HasPrefix(remote, "git@github.com:"): + path = strings.TrimPrefix(remote, "git@github.com:") + default: + parsed, err := url.Parse(remote) + if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") || (parsed.Scheme != "https" && parsed.Scheme != "ssh" && parsed.Scheme != "git") { + return "", fmt.Errorf("PROVIDER_AUTHORITY_INVALID: origin push remote is not an exact GitHub repository") + } + path = strings.TrimPrefix(parsed.Path, "/") + } + path = strings.TrimSuffix(path, ".git") + segments := strings.Split(path, "/") + if len(segments) != 2 || segments[0] == "" || segments[1] == "" || strings.ContainsAny(path, "\x00\r\n\t ") { + return "", fmt.Errorf("PROVIDER_AUTHORITY_INVALID: origin push remote is not an exact GitHub repository") + } + return path, nil +} + func (b NativeBoundary) PrepareObservation(ctx context.Context, admission protocol.Admission, transition catalog.Transition, layout ports.ControllerLayout, state *durable.State) error { if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { return err diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index 65371f9..19116ee 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -65,7 +65,8 @@ func boundaryAdmission(transition catalog.Transition) protocol.Admission { func TestGitHubProviderAuthorityIsDerivedFromWriteCapability(t *testing.T) { now := time.Unix(100, 0).UTC() - runner := &boundaryRunner{output: []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`)} + observation := []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`) + runner := &boundaryRunner{outputs: [][]byte{[]byte("git@github.com:owner/repository.git\n"), observation, []byte("git@github.com:owner/repository.git\n"), observation}} boundary, err := NewNativeBoundaryWithRunner(runner) if err != nil { t.Fatal(err) @@ -78,7 +79,7 @@ func TestGitHubProviderAuthorityIsDerivedFromWriteCapability(t *testing.T) { if receipt.Class != catalog.AuthorityProvider || receipt.Subject != "github:owner/repository" || receipt.Fingerprint != fingerprint || !receipt.ExpiresAt.Equal(now.Add(2*time.Minute)) { t.Fatalf("provider receipt = %#v", receipt) } - if runner.name != "gh" || strings.Join(runner.arguments, " ") != "repo view --json nameWithOwner,viewerPermission,url" { + if runner.name != "gh" || strings.Join(runner.arguments, " ") != "repo view owner/repository --json nameWithOwner,viewerPermission,url" || strings.Join(runner.history[0], " ") != "git remote get-url --push origin" { t.Fatalf("provider observation command = %s %v", runner.name, runner.arguments) } renewed, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), fingerprint, now.Add(time.Minute)) @@ -88,7 +89,7 @@ func TestGitHubProviderAuthorityIsDerivedFromWriteCapability(t *testing.T) { } func TestGitHubProviderAuthorityAcceptsOpaqueTrustedTransitionBinding(t *testing.T) { - runner := &boundaryRunner{output: []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`)} + runner := &boundaryRunner{outputs: [][]byte{[]byte("https://github.com/owner/repository.git\n"), []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`)}} boundary, _ := NewNativeBoundaryWithRunner(runner) receipt, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), "123", time.Unix(100, 0).UTC()) if err != nil { @@ -108,8 +109,20 @@ func TestGitHubProviderAuthorityRejectsUnsafeBinding(t *testing.T) { } } +func TestGitHubProviderAuthorityRejectsRepositoryDifferentFromOrigin(t *testing.T) { + runner := &boundaryRunner{outputs: [][]byte{ + []byte("git@github.com:owner/destination.git\n"), + []byte(`{"nameWithOwner":"owner/ambient","url":"https://github.com/owner/ambient","viewerPermission":"WRITE"}`), + }} + boundary, _ := NewNativeBoundaryWithRunner(runner) + _, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), "binding", time.Unix(100, 0).UTC()) + if err == nil || !strings.Contains(err.Error(), "does not match the origin") || runner.calls != 2 { + t.Fatalf("mismatched provider authority error=%v calls=%d", err, runner.calls) + } +} + func TestGitHubProviderAuthorityRejectsReadOnlyIdentity(t *testing.T) { - runner := &boundaryRunner{output: []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"READ"}`)} + runner := &boundaryRunner{outputs: [][]byte{[]byte("git@github.com:owner/repository.git\n"), []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"READ"}`)}} boundary, _ := NewNativeBoundaryWithRunner(runner) _, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), strings.Repeat("a", 64), time.Unix(100, 0).UTC()) if err == nil || !strings.Contains(err.Error(), "PROVIDER_AUTHORITY_DENIED") { diff --git a/boatstack/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go index 3b9d12d..ef11793 100644 --- a/boatstack/internal/softwaredelivery/effects/journal.go +++ b/boatstack/internal/softwaredelivery/effects/journal.go @@ -144,6 +144,9 @@ func readJournal(path string) (journalRecord, error) { receipt.DeliveryID != admission.Objective.DeliveryID || receipt.ObjectiveScope != admission.ObjectiveScope || receipt.ObjectiveStatus != admission.ObjectiveStatus { return journalRecord{}, fmt.Errorf("committed transition fact in %s does not match its exact admission", path) } + if err := validateReceiptWorkRelation(admission, *receipt); err != nil { + return journalRecord{}, fmt.Errorf("committed transition fact in %s: %w", path, err) + } if admission.ControlBundle != nil { targetFingerprint := admission.ControlBundle.Source.Fingerprint if admission.ControlBundle.Target != nil { @@ -165,6 +168,17 @@ func readJournal(path string) (journalRecord, error) { return record, nil } +func validateReceiptWorkRelation(admission protocol.Admission, receipt protocol.TransitionReceipt) error { + admittedFingerprint := "" + if admission.Work != nil { + admittedFingerprint = admission.Work.ResultFingerprint + } + if receipt.WorkResultFingerprint != admittedFingerprint { + return fmt.Errorf("foreground-work identity does not match its exact admission") + } + return nil +} + func validateCommittedMutationFacts(class catalog.EventClass, mutations []ports.ResourceMutation, receiptFacets []model.StateFacet, facts []protocol.EffectFact) error { var mutationFacets []model.StateFacet for _, mutation := range mutations { diff --git a/boatstack/internal/softwaredelivery/effects/journal_work_test.go b/boatstack/internal/softwaredelivery/effects/journal_work_test.go new file mode 100644 index 0000000..f2445ed --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/journal_work_test.go @@ -0,0 +1,35 @@ +package effects + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestCommittedReceiptWorkIdentityMatchesExactAdmission(t *testing.T) { + one := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + two := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + tests := []struct { + name string + admission protocol.Admission + receipt protocol.TransitionReceipt + wantErr bool + }{ + {name: "neither carries work"}, + {name: "exact work result", admission: protocol.Admission{Work: &protocol.WorkEvidence{ResultFingerprint: one}}, receipt: protocol.TransitionReceipt{WorkResultFingerprint: one}}, + {name: "receipt invents work", receipt: protocol.TransitionReceipt{WorkResultFingerprint: one}, wantErr: true}, + {name: "receipt omits admitted work", admission: protocol.Admission{Work: &protocol.WorkEvidence{ResultFingerprint: one}}, wantErr: true}, + {name: "receipt substitutes work", admission: protocol.Admission{Work: &protocol.WorkEvidence{ResultFingerprint: one}}, receipt: protocol.TransitionReceipt{WorkResultFingerprint: two}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateReceiptWorkRelation(test.admission, test.receipt) + if test.wantErr && err == nil { + t.Fatal("mismatched work identity was accepted") + } + if !test.wantErr && err != nil { + t.Fatal(err) + } + }) + } +}