From 5f2e52e7f51984a5749460297f66d282699bf1d4 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 13:15:05 +0100 Subject: [PATCH 1/4] Make fresh Flow runs self-initializing --- .../boatstack-helper/delegation_command.go | 6 +- .../cmd/boatstack-helper/flow_runtime.go | 84 ++++++++++++++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 57 ++++++++++++- boatstack/cmd/boatstack-helper/main.go | 40 +++++---- .../product_delivery_flow_e2e_test.go | 63 +++++++++----- boatstack/flow/softwaredelivery/skills.go | 18 +++- .../flow/softwaredelivery/skills_test.go | 8 +- ...026-08-16-canonical-flow-initialization.md | 3 + 8 files changed, 234 insertions(+), 45 deletions(-) create mode 100644 release-notes/2026-08-16-canonical-flow-initialization.md diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 3639404..6f0e095 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -63,6 +63,10 @@ func runFlowAuthorize(arguments []string) error { if expiresIn < 0 { return fmt.Errorf("flow authorize --expires-in cannot be negative") } + // Authorization reconstructs the exact request surfaced after candidate + // selection. Ordinary unbound resolution must not create product delegation + // before installation has established the control bundle. + options.delegationRequestProjection = true bound, err := bindFlowEntry(context.Background(), options) if err != nil { return err @@ -528,7 +532,7 @@ func bindContinuationCandidate(ctx context.Context, bound commandOptions, respon if err != nil { return commandOptions{}, false, err } - if rebound.inputRequest == nil && len(rebound.parameters) <= len(bound.parameters) { + if rebound.inputRequest == nil && len(rebound.parameters) <= len(bound.parameters) && rebound.delegationRequestFingerprint == "" { 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 3de2ddd..6ec3475 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -138,11 +138,25 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } options.controlBundle = bundle options.controlBundleFingerprint = bundleFingerprint + repositoryTransition := false + if options.transitionID != "" { + _, repositoryTransition = findCompiledTransition(compiled.Document.Transitions, options.transitionID) + } // Installation authority transitions must not consume or create product // delegation. Their accepted effects establish or change the exact bundle // to which later product delegation is bound. installationAuthority := options.transitionID == "installation.initialize" || options.transitionID == "installation.update" || options.transitionID == "installation.reconcile-update" - if entry.Delegation != nil && !installationAuthority { + delegationRecordPresent := false + if entry.Delegation != nil && options.transitionID == "" && !options.delegationRequestProjection { + delegationRecordPresent, err = flowDelegationRecordPresent(ctx, repository, options) + if err != nil { + return commandOptions{}, err + } + } + // Resolve an unbound frontier before creating product delegation. This lets + // installation authority establish the exact control bundle first; the + // subsequent product candidate then receives delegation bound to that bundle. + if entry.Delegation != nil && (repositoryTransition || options.delegationRequestProjection || delegationRecordPresent) && !installationAuthority { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { return commandOptions{}, resolverErr @@ -205,7 +219,6 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, options.delegationRequest = delegationRequest } if options.transitionID != "" { - _, repositoryTransition := findCompiledTransition(compiled.Document.Transitions, options.transitionID) if repositoryTransition { options, err = materializeFlowInvocation(ctx, compiled, entry, options, options.controlBundle) if err != nil || options.inputRequest != nil { @@ -237,6 +250,36 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return options, nil } +func flowDelegationRecordPresent(ctx context.Context, repository string, options commandOptions) (bool, error) { + resolver, err := plant.NewResolver("") + if err != nil { + return false, err + } + host := options.host + if host == "" { + host = "cli" + } + invoking, err := resolver.ResolveInvocation(ctx, repository, host, "flow-delegation-presence") + if err != nil { + return false, err + } + layout, _, err := resolver.ResolveLayout(ctx, invoking) + if err != nil { + return false, err + } + path, err := delegation.Path(layout.FlowRoot, options.runID) + if err != nil { + return false, err + } + if _, err := os.Lstat(path); err == nil { + return true, nil + } else if os.IsNotExist(err) { + return false, nil + } else { + return false, err + } +} + func bindInternalFlowContextParameters(ctx context.Context, options *commandOptions) error { manifest, err := core.System().CoreManifest(ctx) if err != nil { @@ -246,6 +289,13 @@ func bindInternalFlowContextParameters(ctx context.Context, options *commandOpti if err != nil { return err } + if err := bindCanonicalInternalFlowParameters(options, parameters); err != nil { + return err + } + parameters, err = parseParameters(options.parameters) + if err != nil { + return err + } for _, transition := range manifest.Transitions { if string(transition.ID) != options.transitionID { continue @@ -276,6 +326,36 @@ func bindInternalFlowContextParameters(ctx context.Context, options *commandOpti return fmt.Errorf("FLOW_TRANSITION_UNKNOWN: %s", options.transitionID) } +// bindCanonicalInternalFlowParameters materializes deterministic inputs owned +// by Boatstack's internal transition adapter. These values are observations of +// the selected repository and executing runtime, not host-provided Flow input +// and not installation authority. +func bindCanonicalInternalFlowParameters(options *commandOptions, parameters protocol.Parameters) error { + if options.transitionID != "installation.initialize" { + return nil + } + configPath := filepath.Join(options.repository, ".boatstack", "project.json") + configRaw, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: read canonical project configuration: %w", err) + } + _, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + if err != nil { + return fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: fingerprint canonical project configuration: %w", err) + } + currentRuntime, err := currentRuntimeParameters() + if err != nil { + return err + } + canonical := append(currentRuntime, protocol.Parameter{Name: "config_path", Value: configPath}, protocol.Parameter{Name: "config_sha256", Value: configFingerprint}) + for _, parameter := range canonical { + if err := bindFlowContextParameter(options, parameters, parameter.Name, parameter.Value); err != nil { + return err + } + } + return nil +} + func bindFlowContextParameter(options *commandOptions, parameters protocol.Parameters, name, value string) error { if actual, exists := parameters.Get(name); exists { if actual != value { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 3a9d7bd..c83b0f6 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -116,6 +116,61 @@ func TestFlowEntryCanonicalizesRepositoryRoot(t *testing.T) { } } +func TestInternalFlowInitializationMaterializesCanonicalInputs(t *testing.T) { + // control-law: a Flow-selected internal initialization is complete without + // accepting host-supplied deterministic parameters. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + repository := flowRepository(t) + runFlowGit(t, repository, "init", "-q", "-b", "main") + runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") + runFlowGit(t, repository, "config", "user.name", "Fixture") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + + bound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", transitionID: "installation.initialize", humanActor: "operator", host: "codex", + }) + if err != nil { + t.Fatal(err) + } + parameters, err := parseParameters(bound.parameters) + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(bound.repository, ".boatstack", "project.json") + configRaw, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + _, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + if err != nil { + t.Fatal(err) + } + wantRuntime, err := currentRuntimeParameters() + if err != nil { + t.Fatal(err) + } + want := append(wantRuntime, protocol.Parameter{Name: "config_path", Value: configPath}, protocol.Parameter{Name: "config_sha256", Value: configFingerprint}) + for _, parameter := range want { + if actual, ok := parameters.Get(parameter.Name); !ok || actual != parameter.Value { + t.Fatalf("parameter %s = %q, want %q", parameter.Name, actual, parameter.Value) + } + } + if len(parameters) != len(want) || bound.inputRequest != nil { + t.Fatalf("materialized parameters = %#v, input request = %#v", parameters, bound.inputRequest) + } + + writeFixture(t, repository, "alternate.json", configRaw) + _, err = bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", transitionID: "installation.initialize", humanActor: "operator", host: "codex", + maintenanceParameterSurface: true, parameters: []string{"config_path=" + filepath.Join(repository, "alternate.json")}, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + t.Fatalf("noncanonical internal initialization input error = %v", err) + } +} + func runFlowGit(t *testing.T, repository string, arguments ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) @@ -2260,7 +2315,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) runFlowGit(t, repository, "commit", "-q", "-m", "fixture") t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) - bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex", delegationRequestProjection: true}) if err != nil { t.Fatal(err) } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 557ddc0..a7ec417 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -81,6 +81,7 @@ type commandOptions struct { delegationDescription string delegationRequest delegation.Request delegationReprojection bool + delegationRequestProjection bool workInputs map[string]protocol.WorkInputValue workID string workQuestionPrompt string @@ -609,26 +610,37 @@ func populateRuntimeParameters(options *commandOptions) error { if err != nil { return err } - if _, ok := parameters.Get("runtime_version"); !ok { - options.parameters = append(options.parameters, "runtime_version="+buildinfo.Version) + current, err := currentRuntimeParameters() + if err != nil { + return err } - if _, ok := parameters.Get("runtime_sha256"); !ok { - runtimePath, executableErr := os.Executable() - if executableErr != nil { - return executableErr + for _, parameter := range current { + if _, ok := parameters.Get(parameter.Name); !ok { + options.parameters = append(options.parameters, parameter.Name+"="+parameter.Value) } - runtimeRaw, readErr := os.ReadFile(runtimePath) - if readErr != nil { - return readErr - } - options.parameters = append(options.parameters, "runtime_sha256="+hash(runtimeRaw)) - } - if _, ok := parameters.Get("source_revision"); !ok { - options.parameters = append(options.parameters, "source_revision="+buildRevision()) } return nil } +// currentRuntimeParameters observes the exact process requesting admission. +// It does not grant installation authority; it only supplies deterministic +// evidence that the installation verifier already requires. +func currentRuntimeParameters() (protocol.Parameters, error) { + runtimePath, err := os.Executable() + if err != nil { + return nil, err + } + runtimeRaw, err := os.ReadFile(runtimePath) + if err != nil { + return nil, err + } + return protocol.Parameters{ + {Name: "source_revision", Value: buildRevision()}, + {Name: "runtime_version", Value: buildinfo.Version}, + {Name: "runtime_sha256", Value: hash(runtimeRaw)}, + }, nil +} + func populateFileFingerprint(options *commandOptions, pathName, fingerprintName string) error { parameters, err := parseParameters(options.parameters) if err != nil { diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index d7e6c10..59f6b73 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/distribution" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" @@ -47,7 +48,8 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if err != nil { t.Fatal(err) } - if _, err := boatstackruntime.InstallExecutable(executable, runtimeHome, boatstackruntime.Identity{Version: buildinfo.Version, SHA256: hash(runtimeRaw), SourceRevision: buildRevision()}); err != nil { + runtimeIdentity := boatstackruntime.Identity{Version: buildinfo.Version, SHA256: hash(runtimeRaw), SourceRevision: buildRevision()} + if _, err := boatstackruntime.InstallExecutable(executable, runtimeHome, runtimeIdentity); err != nil { t.Fatal(err) } @@ -66,6 +68,38 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T writeFixture(t, repository, sourcePath, sourceRaw) writeFixture(t, repository, lockPath, lockRaw) writeFlowArtifact(t, repository, document, sourcePath, sourceRaw, lockPath, lockRaw) + hostFiles, hostManifest, err := effects.ProjectedHostSkillFiles([]string{"cli", "codex", "claude"}) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/host-skills.json", hostManifest) + for path, content := range hostFiles { + writeFixture(t, repository, path, content) + } + definition, err := loadFlowDefinition(context.Background(), repository, "product-delivery") + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(repository, ".boatstack", "project.json") + configRaw, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + _, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + if err != nil { + t.Fatal(err) + } + program, err := distribution.ProgramForRepository(context.Background(), distribution.RepositoryProgramRequest{ + Repository: repository, Host: "codex", CorrelationID: "fixture-program", ConfigurationPath: configPath, ConfigurationFingerprint: configFingerprint, + }, definition) + if err != nil { + t.Fatal(err) + } + pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin(runtimeIdentity, program.Fingerprint(), durable.StateSchemaVersion)) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/runtime.json", pinRaw) runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "commit", "-q", "-m", "fixture") @@ -74,26 +108,9 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T runFlowGit(t, repository, "remote", "add", "origin", bare) runFlowGit(t, repository, "push", "-q", "-u", "origin", "main") installFakePublicationProvider(t) - initialize, err := captureRunOutput(t, - "init", "--repo", repository, "--flow", "product-delivery", "--entry", "run", - "--param", "config_path="+filepath.Join(repository, ".boatstack", "project.json"), "--human", "operator", "--host", "codex", "--format", "json", - ) - if err != nil { - t.Fatalf("initialize: %v\n%s", err, initialize) - } - var initialized surfaces.Response - if err := json.Unmarshal(initialize, &initialized); err != nil { - t.Fatal(err) - } - if initialized.Receipt == nil || initialized.Receipt.TransitionID != "installation.initialize" { - t.Fatalf("initialization response = %#v", initialized) - } - runFlowGit(t, repository, "add", ".") - runFlowGit(t, repository, "commit", "-q", "-m", "install Boatstack control bundle") - runFlowGit(t, repository, "push", "-q", "origin", "main") first, err := captureStdout(t, func() error { - return runFlowContinuation([]string{"--repo", repository, "--flow", "product-delivery", "--entry", "run", "--host", "codex", "--format", "json"}) + return runFlowContinuation([]string{"--repo", repository, "--flow", "product-delivery", "--entry", "run", "--repository-authority", "--human", "operator", "--host", "codex", "--format", "json"}) }) if err != nil { t.Fatalf("delegation suspension: %v\n%s", err, first) @@ -103,10 +120,14 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T t.Fatal(err) } if delegated.Delegation == nil || delegated.Delegation.RunID == "" { - t.Fatalf("delegation response = %#v", delegated) + t.Fatalf("delegation response = %#v\n%s", delegated, first) } runID := delegated.Delegation.RunID t.Logf("SUSPENSION delegation run=%s request=%s authorities=%v", runID, delegated.Delegation.RequestFingerprint, delegated.Delegation.Authorities) + prerequisites := committedFlowReceipts(t, repository, runID) + if len(prerequisites) != 3 || prerequisites[0].TransitionID != "installation.initialize" || prerequisites[1].TransitionID != "objective.bind" || prerequisites[2].TransitionID != "engagement.begin" || delegated.Work != nil { + t.Fatalf("pre-delegation trace = %#v, work = %#v", prerequisites, delegated.Work) + } if _, err := captureStdout(t, func() error { return runFlowAuthorize([]string{ "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, @@ -118,7 +139,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T t.Logf("AUTHORITY accepted class=autonomy actor=operator request=%s", delegated.Delegation.RequestFingerprint) workOutput, err := captureStdout(t, func() error { - return runFlowContinuation([]string{"--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, "--repository-authority", "--host", "codex", "--format", "json"}) + return runFlowContinuation([]string{"--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, "--repository-authority", "--human", "operator", "--host", "codex", "--format", "json"}) }) if err != nil { t.Fatalf("planning suspension: %v\n%s", err, workOutput) diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index d2f3847..d749d47 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -63,6 +63,9 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s programReconciliation := "" publication := "" startCommand := fmt.Sprintf("boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) + if entry.Delegation != nil { + startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) + } if declarativeProgram(compiled.Document.Operators) { startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --host %s --format json", compiled.Document.Program.ID, entry.ID, host) if len(entry.Inputs) != 0 { @@ -210,10 +213,17 @@ run ID, reconstruct the transition graph, or act on a rejected candidate. } if entry.Delegation != nil { delegation = fmt.Sprintf(` -The first `+"`next`"+` returns a typed `+"`DELEGATION_REQUIRED`"+` response before -managed state changes. Display its exact run ID, request fingerprint, requested -authorities, and description. Obtain one explicit human approval for that exact -request, then run: +Before product delegation, Boatstack may select `+"`installation.initialize`"+` +for an installed repository whose controller state is fresh. Display that exact +installation-authority question and obtain explicit human approval. Resume the +same Flow command with `+"`--human `"+`; do not invoke an update operation +or supply installation values with `+"`--param`"+`. Boatstack derives those values +from the committed project configuration and the executing runtime. + +After internal preconditions are committed, Boatstack returns a typed +`+"`DELEGATION_REQUIRED`"+` response bound to the resulting control bundle. +Display its exact run ID, request fingerprint, requested authorities, and +description. Obtain one explicit human approval for that exact request, then run: `+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human --host %s`"+` diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 3606c76..a503269 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -15,7 +15,10 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ Program: controlprogram.Program{ID: "product-delivery"}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{True: &truth}}}, - Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr", Description: "Publish the reviewed change"}}, + Entries: []controlprogram.Entry{{ + ID: "run", Target: "published-pr", Description: "Publish the reviewed change", + Delegation: &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"}, + }}, }} files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) if err != nil { @@ -36,9 +39,10 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } value := string(codex) for _, contract := range []string{ - "--flow product-delivery --entry run", "--repository-authority", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", + "boatstack flow run --repo . --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", + "Before product delegation", "do not invoke an update operation", "committed project configuration", "installation-authority\nsuspension before product work", "installation.reconcile-update", "--accept-program-change", "boatstack reconcile-update --repo . --flow product-delivery --entry run --run-id ", "do not request or reuse product delegation before reconciliation", "commit\nthose exact files separately before product work", diff --git a/release-notes/2026-08-16-canonical-flow-initialization.md b/release-notes/2026-08-16-canonical-flow-initialization.md new file mode 100644 index 0000000..67db2f2 --- /dev/null +++ b/release-notes/2026-08-16-canonical-flow-initialization.md @@ -0,0 +1,3 @@ +### Make fresh Flow runs self-initializing + +Repository Flow runs now derive installation inputs from the committed project configuration and executing runtime, then request product delegation only after installation prerequisites are complete. From f28e2d7398761c3d0f35827ef4aaa671996b9e35 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 14:51:32 +0100 Subject: [PATCH 2/4] Preserve compiler projection compatibility --- boatstack/flow/softwaredelivery/skills.go | 18 ++++-------------- boatstack/flow/softwaredelivery/skills_test.go | 8 ++------ 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index d749d47..d2f3847 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -63,9 +63,6 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s programReconciliation := "" publication := "" startCommand := fmt.Sprintf("boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) - if entry.Delegation != nil { - startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) - } if declarativeProgram(compiled.Document.Operators) { startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --host %s --format json", compiled.Document.Program.ID, entry.ID, host) if len(entry.Inputs) != 0 { @@ -213,17 +210,10 @@ run ID, reconstruct the transition graph, or act on a rejected candidate. } if entry.Delegation != nil { delegation = fmt.Sprintf(` -Before product delegation, Boatstack may select `+"`installation.initialize`"+` -for an installed repository whose controller state is fresh. Display that exact -installation-authority question and obtain explicit human approval. Resume the -same Flow command with `+"`--human `"+`; do not invoke an update operation -or supply installation values with `+"`--param`"+`. Boatstack derives those values -from the committed project configuration and the executing runtime. - -After internal preconditions are committed, Boatstack returns a typed -`+"`DELEGATION_REQUIRED`"+` response bound to the resulting control bundle. -Display its exact run ID, request fingerprint, requested authorities, and -description. Obtain one explicit human approval for that exact request, then run: +The first `+"`next`"+` returns a typed `+"`DELEGATION_REQUIRED`"+` response before +managed state changes. Display its exact run ID, request fingerprint, requested +authorities, and description. Obtain one explicit human approval for that exact +request, then run: `+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human --host %s`"+` diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index a503269..3606c76 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -15,10 +15,7 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ Program: controlprogram.Program{ID: "product-delivery"}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{True: &truth}}}, - Entries: []controlprogram.Entry{{ - ID: "run", Target: "published-pr", Description: "Publish the reviewed change", - Delegation: &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"}, - }}, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr", Description: "Publish the reviewed change"}}, }} files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) if err != nil { @@ -39,10 +36,9 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } value := string(codex) for _, contract := range []string{ - "boatstack flow run --repo . --flow product-delivery --entry run --repository-authority", "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", - "Before product delegation", "do not invoke an update operation", "committed project configuration", "installation-authority\nsuspension before product work", "installation.reconcile-update", "--accept-program-change", "boatstack reconcile-update --repo . --flow product-delivery --entry run --run-id ", "do not request or reuse product delegation before reconciliation", "commit\nthose exact files separately before product work", From d58ee1cbf581489b5778e7f153ec737f34390fda Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 17:13:56 +0100 Subject: [PATCH 3/4] Complete fresh Flow initialization --- .../cmd/boatstack-helper/control_bundle.go | 22 ++- .../boatstack-helper/delegation_command.go | 22 ++- .../boatstack-helper/delegation_runtime.go | 5 +- .../cmd/boatstack-helper/flow_command.go | 2 +- .../cmd/boatstack-helper/flow_runtime.go | 158 ++++++++++++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 164 +++++++++++++++++- .../boatstack-helper/input_command_test.go | 11 +- boatstack/cmd/boatstack-helper/main.go | 49 +++++- .../product_delivery_flow_e2e_test.go | 133 ++++++++++---- .../cmd/boatstack-helper/work_command.go | 9 +- boatstack/flow/softwaredelivery/skills.go | 24 ++- .../flow/softwaredelivery/skills_test.go | 9 +- .../softwaredelivery/surfaces/protocol.go | 67 ++++--- .../surfaces/protocol_test.go | 29 ++++ ...026-08-16-canonical-flow-initialization.md | 2 +- 15 files changed, 625 insertions(+), 81 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/control_bundle.go b/boatstack/cmd/boatstack-helper/control_bundle.go index d79ee4a..d5c0829 100644 --- a/boatstack/cmd/boatstack-helper/control_bundle.go +++ b/boatstack/cmd/boatstack-helper/control_bundle.go @@ -303,6 +303,7 @@ func bindTrustedRequestControlBundle(ctx context.Context, request *surfaces.Requ } request.ControlBundle = nil request.ControlBundleFingerprint = "" + request.ControlBundleRevision = "" if request.ProgramID == "" && !controlBundleRequired(request.TransitionID) { return nil } @@ -315,12 +316,31 @@ func bindTrustedRequestControlBundle(ctx context.Context, request *surfaces.Requ return nil } -func verifyTrustedRequestControlBundle(request surfaces.Request) error { +func verifyTrustedRequestControlBundle(ctx context.Context, 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 } + if request.ControlBundleRevision != "" { + revision, err := boatstackruntime.ResolveCommitRevision(ctx, request.Repository, "HEAD") + if err != nil { + return err + } + if revision != request.ControlBundleRevision { + return &flowCommitRequiredError{ + programID: request.ProgramID, entryID: request.EntryID, runID: request.FlowID, + revision: revision, controlBundleFingerprint: request.ControlBundleFingerprint, operation: request.Operation, + cause: fmt.Errorf("CONTROL_BUNDLE_REVISION_DRIFT: expected revision %s", request.ControlBundleRevision), + } + } + if err := boatstackruntime.VerifyControlBundleRevision(ctx, request.Repository, revision, request.ControlBundle.Source); err != nil { + return &flowCommitRequiredError{ + programID: request.ProgramID, entryID: request.EntryID, runID: request.FlowID, + revision: revision, controlBundleFingerprint: request.ControlBundleFingerprint, operation: request.Operation, cause: err, + } + } + } 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 6f0e095..20a6462 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -69,6 +69,9 @@ func runFlowAuthorize(arguments []string) error { options.delegationRequestProjection = true bound, err := bindFlowEntry(context.Background(), options) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, surfaces.OperationResolve); ok { + return renderResponse(suspended, "json") + } return err } if bound.delegationRequestFingerprint == "" || requestFingerprint != bound.delegationRequestFingerprint || bound.runID != options.runID { @@ -255,6 +258,10 @@ func runFlowContinuation(arguments []string) error { if err != nil { return err } + return runFlowContinuationOptions(options) +} + +func runFlowContinuationOptions(options commandOptions) error { if options.programID == "" || options.entryID == "" { return fmt.Errorf("flow run requires --flow and --entry") } @@ -265,9 +272,13 @@ func runFlowContinuation(arguments []string) error { return fmt.Errorf("FLOW_INPUT_INVALID: --input is available only to declarative Flow entries") } var response surfaces.Response + var err error for step := 0; step < 256; step++ { response, err = executeContinuationStep(context.Background(), options) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, surfaces.OperationResolve); ok { + return renderResponse(suspended, options.format) + } return err } if response.RunID != "" { @@ -282,7 +293,7 @@ func runFlowContinuation(arguments []string) error { if err := advanceContinuation(&options, response); err != nil { return err } - if response.Delegation != nil || response.Prescription == nil || response.Receipt == nil || (response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal) { + if response.Delegation != nil || response.CommitRequired != nil || response.Prescription == nil || response.Receipt == nil || (response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal) { return renderResponse(response, options.format) } } @@ -320,7 +331,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } - if err := verifyTrustedRequestControlBundle(resolveRequest); err != nil { + if err := verifyTrustedRequestControlBundle(ctx, resolveRequest); err != nil { resolveLease.Release() return surfaces.Response{}, err } @@ -375,7 +386,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } - if err := verifyTrustedRequestControlBundle(resolveRequest); err != nil { + if err := verifyTrustedRequestControlBundle(ctx, resolveRequest); err != nil { resolveLease.Release() return surfaces.Response{}, err } @@ -418,7 +429,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if delegationResponse != nil { return *delegationResponse, nil } - if err := verifyTrustedRequestControlBundle(applyRequest); err != nil { + if err := verifyTrustedRequestControlBundle(ctx, applyRequest); err != nil { return surfaces.Response{}, err } applied, err := kernel.Handle(ctx, applyRequest) @@ -466,7 +477,7 @@ func stabilizeRepositoryPrescription(ctx context.Context, request surfaces.Reque return surfaces.Request{}, surfaces.Response{}, true, err } defer lease.Release() - if err := verifyTrustedRequestControlBundle(rebound); err != nil { + if err := verifyTrustedRequestControlBundle(ctx, rebound); err != nil { return surfaces.Request{}, surfaces.Response{}, true, err } kernel, err := standardKernel(ctx, rebound) @@ -567,5 +578,6 @@ func advanceContinuation(options *commandOptions, response surfaces.Response) er options.trustedAuthorityReceipts = nil options.invocationEvidence = nil options.inputRequest = nil + options.controlBundleRevision = "" return nil } diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 010c92f..4efbc3d 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -161,6 +161,7 @@ func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Reque return nil, nil } probe := request + requestedOperation := request.Operation probe.Operation = surfaces.OperationExplain probe.Prescription = protocol.Prescription{} probe.IdempotencyKey = "" @@ -171,8 +172,8 @@ func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Reque return nil, err } defer lease.Release() - if err := verifyTrustedRequestControlBundle(probe); err != nil { - return nil, err + if err := verifyTrustedRequestControlBundle(ctx, probe); err != nil { + return nil, bindFlowCommitRequiredOperation(err, requestedOperation) } kernel, err := standardKernel(ctx, probe) if err != nil { diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index de04d46..9fe46fc 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.4" +const flowCompilerVersion = "control-program.compiler.5" type flowCommandOptions struct { repository string diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 6ec3475..30da1ad 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "path/filepath" @@ -32,6 +33,61 @@ import ( var flowSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +const controlBundleCommitRequiredCode = "CONTROL_BUNDLE_COMMIT_REQUIRED" + +type flowCommitRequiredError struct { + programID string + entryID string + runID string + revision string + controlBundleFingerprint string + description string + operation surfaces.Operation + cause error +} + +func (e *flowCommitRequiredError) Error() string { + return fmt.Sprintf("%s: commit the exact Boatstack control bundle at revision %s before product delegation or repository work: %v", controlBundleCommitRequiredCode, e.revision, e.cause) +} + +func flowCommitRequiredResponse(err error, operation surfaces.Operation) (surfaces.Response, bool) { + var required *flowCommitRequiredError + if !errors.As(err, &required) { + return surfaces.Response{}, false + } + if required.operation.Valid() { + operation = required.operation + } + description := required.description + if description == "" { + description = "In the source repository, commit the exact installed Boatstack control bundle, including generated runtime and host projection files, then resume this exact run." + } + return surfaces.Response{ + SchemaVersion: surfaces.SchemaVersion, + Operation: operation, + ProgramID: required.programID, + EntryID: required.entryID, + RunID: required.runID, + CommitRequired: &surfaces.CommitRequired{ + Code: controlBundleCommitRequiredCode, + RunID: required.runID, + Revision: required.revision, + ControlBundleFingerprint: required.controlBundleFingerprint, + Description: description, + }, + }, true +} + +func bindFlowCommitRequiredOperation(err error, operation surfaces.Operation) error { + var required *flowCommitRequiredError + if !errors.As(err, &required) { + return err + } + bound := *required + bound.operation = operation + return &bound +} + func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, error) { if options.programID == "" && options.entryID == "" { return options, nil @@ -138,14 +194,48 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } options.controlBundle = bundle options.controlBundleFingerprint = bundleFingerprint + // Installation authority establishes the bundle. Every other Flow phase + // must bind the accepted bundle to the exact current Git revision. + installationAuthority := options.transitionID == "installation.initialize" || options.transitionID == "installation.update" || options.transitionID == "installation.reconcile-update" + acceptedBundleRevision := "" + if options.transitionID == "" || !installationAuthority { + acceptedBundleRevision, err = requireCommittedAcceptedFlowBundle(ctx, repository, options, bundle.Source, bundleFingerprint) + if err != nil { + return commandOptions{}, err + } + if options.controlBundleRevision != "" && acceptedBundleRevision != options.controlBundleRevision { + currentRevision, revisionErr := boatstackruntime.ResolveCommitRevision(ctx, repository, "HEAD") + if revisionErr != nil { + return commandOptions{}, revisionErr + } + return commandOptions{}, newFlowCommitRequiredError(options, currentRevision, bundleFingerprint, fmt.Errorf("CONTROL_BUNDLE_REVISION_DRIFT: expected revision %s", options.controlBundleRevision)) + } + options.controlBundleRevision = acceptedBundleRevision + } repositoryTransition := false if options.transitionID != "" { _, repositoryTransition = findCompiledTransition(compiled.Document.Transitions, options.transitionID) } + if repositoryTransition || options.controlBundleRevision != "" || options.transitionID == "installation.initialize" { + revision, revisionErr := boatstackruntime.ResolveCommitRevision(ctx, repository, "HEAD") + if revisionErr != nil { + return commandOptions{}, revisionErr + } + if options.controlBundleRevision != "" && revision != options.controlBundleRevision { + return commandOptions{}, newFlowCommitRequiredError(options, revision, bundleFingerprint, fmt.Errorf("CONTROL_BUNDLE_REVISION_DRIFT: expected revision %s", options.controlBundleRevision)) + } + if commitErr := boatstackruntime.VerifyControlBundleRevision(ctx, repository, revision, bundle.Source); commitErr != nil { + required := newFlowCommitRequiredError(options, revision, bundleFingerprint, commitErr) + if options.transitionID == "installation.initialize" { + required.description = "In the source repository, commit the exact authored Boatstack control bundle before initialization, then resume this exact run." + } + return commandOptions{}, required + } + options.controlBundleRevision = revision + } // Installation authority transitions must not consume or create product // delegation. Their accepted effects establish or change the exact bundle // to which later product delegation is bound. - installationAuthority := options.transitionID == "installation.initialize" || options.transitionID == "installation.update" || options.transitionID == "installation.reconcile-update" delegationRecordPresent := false if entry.Delegation != nil && options.transitionID == "" && !options.delegationRequestProjection { delegationRecordPresent, err = flowDelegationRecordPresent(ctx, repository, options) @@ -156,7 +246,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, // Resolve an unbound frontier before creating product delegation. This lets // installation authority establish the exact control bundle first; the // subsequent product candidate then receives delegation bound to that bundle. - if entry.Delegation != nil && (repositoryTransition || options.delegationRequestProjection || delegationRecordPresent) && !installationAuthority { + if entry.Delegation != nil && (repositoryTransition || acceptedBundleRevision != "" || options.delegationRequestProjection || delegationRecordPresent) && !installationAuthority { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { return commandOptions{}, resolverErr @@ -250,6 +340,64 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return options, nil } +// requireCommittedAcceptedFlowBundle makes an accepted installation result a +// hard suspension boundary. Once durable state binds the exact current bundle, +// no unbound resolution may select or create product state until those bytes +// exist at the current Git revision. A candidate bundle that differs from +// durable state remains available to the explicit reconciliation path. +func requireCommittedAcceptedFlowBundle(ctx context.Context, repository string, options commandOptions, bundle boatstackruntime.ControlBundleSnapshot, bundleFingerprint string) (string, error) { + if _, err := os.Lstat(boatstackruntime.PinPath(repository)); os.IsNotExist(err) { + return "", nil + } else if err != nil { + return "", err + } + resolver, err := plant.NewResolver("") + if err != nil { + return "", err + } + host := options.host + if host == "" { + host = "cli" + } + invoking, err := resolver.ResolveInvocation(ctx, repository, host, "flow-control-bundle-commit") + if err != nil { + return "", err + } + layout, _, err := resolver.ResolveLayout(ctx, invoking) + if err != nil { + return "", err + } + raw, err := os.ReadFile(layout.StatePath) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + state, err := durable.DecodeState(raw) + if err != nil { + return "", err + } + if state.ControlBundleFingerprint == "" || state.ControlBundleFingerprint != bundleFingerprint { + return "", nil + } + revision, err := boatstackruntime.ResolveCommitRevision(ctx, repository, "HEAD") + if err != nil { + return "", err + } + if err := boatstackruntime.VerifyControlBundleRevision(ctx, repository, revision, bundle); err != nil { + return "", newFlowCommitRequiredError(options, revision, bundleFingerprint, err) + } + return revision, nil +} + +func newFlowCommitRequiredError(options commandOptions, revision, bundleFingerprint string, cause error) *flowCommitRequiredError { + return &flowCommitRequiredError{ + programID: options.programID, entryID: options.entryID, runID: options.runID, + revision: revision, controlBundleFingerprint: bundleFingerprint, cause: cause, + } +} + func flowDelegationRecordPresent(ctx context.Context, repository string, options commandOptions) (bool, error) { resolver, err := plant.NewResolver("") if err != nil { @@ -756,7 +904,7 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque } else if replay { request.Repository = canonicalRepository request.Parameters, request.InvocationEvidence, request.InputRequest = nil, nil, nil - request.ControlBundle, request.ControlBundleFingerprint = nil, "" + request.ControlBundle, request.ControlBundleFingerprint, request.ControlBundleRevision = nil, "", "" return request, nil } repositoryTransition, err := repositoryFlowDeclaresTransition(request.Repository, request.ProgramID, string(request.TransitionID)) @@ -772,9 +920,10 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque flowProgramFingerprint: request.ProgramFingerprint, runID: request.FlowID, objectiveID: request.Objective.ID, targetID: string(request.Objective.TargetID), trustedObjectiveClass: string(request.Objective.TrustedObjectiveClass()), deliveryID: request.Objective.DeliveryID, transitionID: string(request.TransitionID), parameters: parameterFlags, maintenanceParameterSurface: maintenanceParameterSurface && request.TransitionID != "" && !repositoryTransition, + controlBundleRevision: request.ControlBundleRevision, }) if err != nil { - return surfaces.Request{}, err + return surfaces.Request{}, bindFlowCommitRequiredOperation(err, request.Operation) } parameters, err := parseParameters(bound.parameters) if err != nil { @@ -794,6 +943,7 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque request.WorkInputs = bound.workInputs request.ControlBundle = bound.controlBundle request.ControlBundleFingerprint = bound.controlBundleFingerprint + request.ControlBundleRevision = bound.controlBundleRevision request.InvocationEvidence = bound.invocationEvidence request.InputRequest = bound.inputRequest return request, nil diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index c83b0f6..5365ba6 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "os" "os/exec" @@ -97,6 +98,71 @@ func flowRepositoryWithHumanSlice(t *testing.T) string { return repository } +func TestCommitRequiredResponsePreservesRequestedOperation(t *testing.T) { + // control-law: typed suspension cannot change apply or recovery into resolve + required := &flowCommitRequiredError{ + programID: "product-delivery", entryID: "run", runID: "run-example", + revision: strings.Repeat("a", 40), controlBundleFingerprint: strings.Repeat("b", 64), + cause: fmt.Errorf("bundle is not committed"), + } + for _, operation := range []surfaces.Operation{surfaces.OperationApply, surfaces.OperationRecover} { + response, ok := flowCommitRequiredResponse(required, operation) + if !ok || response.Operation != operation || response.CommitRequired == nil { + t.Fatalf("%s commit suspension = %#v, ok=%v", operation, response, ok) + } + } + bound := bindFlowCommitRequiredOperation(required, surfaces.OperationApply) + response, ok := flowCommitRequiredResponse(bound, surfaces.OperationResolve) + if !ok || response.Operation != surfaces.OperationApply { + t.Fatalf("bound apply suspension = %#v, ok=%v", response, ok) + } +} + +func TestTrustedControlBundleRejectsHeadDriftWithMatchingWorkingTree(t *testing.T) { + // control-law: root bytes cannot substitute for the exact committed revision + repository := t.TempDir() + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.email", "flow@example.invalid") + runFlowGit(t, repository, "config", "user.name", "Flow Test") + writeFixture(t, repository, "README.md", []byte("base\n")) + runFlowGit(t, repository, "add", "README.md") + runFlowGit(t, repository, "commit", "-q", "-m", "base") + baseRevision := runFlowGitOutput(t, repository, "rev-parse", "HEAD") + + bundleBytes := []byte("{}\n") + writeFixture(t, repository, ".boatstack/project.json", bundleBytes) + snapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{ + ".boatstack/project.json": bundleBytes, + }) + if err != nil { + t.Fatal(err) + } + contract, err := boatstackruntime.NewControlBundleContract(snapshot, nil, "") + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "add", ".boatstack/project.json") + runFlowGit(t, repository, "commit", "-q", "-m", "bundle") + bundleRevision := runFlowGitOutput(t, repository, "rev-parse", "HEAD") + request := surfaces.Request{ + Operation: surfaces.OperationApply, Repository: repository, + ProgramID: "product-delivery", EntryID: "run", FlowID: "run-example", + ControlBundle: &contract, ControlBundleFingerprint: snapshot.Fingerprint, ControlBundleRevision: bundleRevision, + } + + // A mixed reset preserves the exact working-tree bytes while moving HEAD to + // a revision that does not contain the admitted bundle. + runFlowGit(t, repository, "reset", "--mixed", baseRevision) + if err := boatstackruntime.VerifyControlBundleRoot(repository, snapshot); err != nil { + t.Fatalf("fixture no longer demonstrates root-only acceptance: %v", err) + } + err = verifyTrustedRequestControlBundle(context.Background(), request) + response, ok := flowCommitRequiredResponse(err, surfaces.OperationResolve) + if !ok || response.Operation != surfaces.OperationApply || response.CommitRequired == nil || response.CommitRequired.Code != controlBundleCommitRequiredCode { + t.Fatalf("HEAD drift suspension = %#v, err=%v", response, err) + } +} + func TestFlowEntryCanonicalizesRepositoryRoot(t *testing.T) { repository := flowRepository(t) writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) @@ -171,6 +237,76 @@ func TestInternalFlowInitializationMaterializesCanonicalInputs(t *testing.T) { } } +func TestFreshFlowInitializationRejectsDirtyCanonicalConfigurationBeforeEffects(t *testing.T) { + // control-law: initialization may derive inputs only from an exact committed + // source bundle; a dirty canonical configuration cannot become durable state. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + repository := flowRepository(t) + runFlowGit(t, repository, "init", "-q", "-b", "main") + runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") + runFlowGit(t, repository, "config", "user.name", "Fixture") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"dirty","default_branch":"main","commands":{"test":"false"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) + questionRaw, err := captureRunOutput(t, + "flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", + "--repository-authority", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("installation authority question: %v\n%s", err, questionRaw) + } + var question surfaces.Response + if err := json.Unmarshal(questionRaw, &question); err != nil { + t.Fatal(err) + } + if question.Question == nil || question.Question.TransitionID != "installation.initialize" || question.RunID == "" { + t.Fatalf("installation authority response = %#v", question) + } + + suspendedRaw, err := captureRunOutput(t, + "flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", question.RunID, + "--repository-authority", "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("dirty source suspension: %v\n%s", err, suspendedRaw) + } + var suspended surfaces.Response + if err := json.Unmarshal(suspendedRaw, &suspended); err != nil { + t.Fatal(err) + } + if suspended.CommitRequired == nil || suspended.CommitRequired.Code != controlBundleCommitRequiredCode || suspended.CommitRequired.RunID != question.RunID || !strings.Contains(suspended.CommitRequired.Description, "authored Boatstack control bundle before initialization") { + t.Fatalf("dirty source suspension = %#v", suspended) + } + + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invoking, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "dirty-initialization-check") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{ + layout.StatePath, + layout.ReceiptPath, + filepath.Join(repository, ".boatstack", "runtime.json"), + filepath.Join(repository, ".boatstack", "host-skills.json"), + } { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("dirty initialization wrote %s: %v", path, statErr) + } + } + if status := runFlowGitOutput(t, repository, "status", "--short"); status != "M .boatstack/project.json" { + t.Fatalf("dirty initialization changed repository:\n%s", status) + } +} + func runFlowGit(t *testing.T, repository string, arguments ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) @@ -191,6 +327,10 @@ func runFlowGitOutput(t *testing.T, repository string, arguments ...string) stri func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint string) { t.Helper() + _, bundleFingerprint, err := bindControlBundle(context.Background(), repository, "", nil) + if err != nil { + t.Fatal(err) + } resolver, err := plant.NewResolver("") if err != nil { t.Fatal(err) @@ -205,6 +345,7 @@ func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint } state := durable.Default(invoking, time.Now().UTC()) state.ProgramFingerprint = programFingerprint + state.ControlBundleFingerprint = bundleFingerprint raw, err := durable.EncodeState(state) if err != nil { t.Fatal(err) @@ -1179,6 +1320,8 @@ func TestAcceptedProgramReconciliationReprojectsSameFlowRun(t *testing.T) { if err != nil { t.Fatalf("initialize old program: %v\n%s", err, output) } + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "commit initialized control bundle") bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) if err != nil { t.Fatal(err) @@ -1215,8 +1358,25 @@ func TestAcceptedProgramReconciliationReprojectsSameFlowRun(t *testing.T) { "next", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", bound.runID, "--host", "codex", "--format", "json", ) - if err != nil && strings.Contains(err.Error(), "INVOCATION_DRIFT") { - t.Fatalf("post-reconciliation resolution reused stale invocation: %v\n%s", err, output) + if err != nil { + t.Fatalf("post-reconciliation commit suspension: %v\n%s", err, output) + } + var commitRequired surfaces.Response + if err := json.Unmarshal(output, &commitRequired); err != nil { + t.Fatal(err) + } + if commitRequired.CommitRequired == nil || commitRequired.RunID != bound.runID { + t.Fatalf("post-reconciliation commit suspension = %#v", commitRequired) + } + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "commit reconciled control bundle") + + output, err = captureRunOutput(t, + "next", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", bound.runID, + "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("post-commit resolution: %v\n%s", err, output) } var projected surfaces.Response if decodeErr := json.Unmarshal(output, &projected); decodeErr != nil { diff --git a/boatstack/cmd/boatstack-helper/input_command_test.go b/boatstack/cmd/boatstack-helper/input_command_test.go index 94f0ede..a4ec202 100644 --- a/boatstack/cmd/boatstack-helper/input_command_test.go +++ b/boatstack/cmd/boatstack-helper/input_command_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" @@ -202,6 +204,14 @@ func TestApplyAndRecoveryDiscardRevokedInvocationEvidence(t *testing.T) { repository := flowRepositoryWithHumanSlice(t) runFlowGit(t, repository, "init", "-q") writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin( + boatstackruntime.Identity{Version: "v-test", SHA256: strings.Repeat("a", 64), SourceRevision: "test-revision"}, + strings.Repeat("f", 64), durable.StateSchemaVersion, + )) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/runtime.json", pinRaw) runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") writeAdmittedFlowProgramState(t, repository, strings.Repeat("f", 64)) @@ -256,7 +266,6 @@ func TestApplyAndRecoveryDiscardRevokedInvocationEvidence(t *testing.T) { if removed != 1 { t.Fatalf("removed %d input receipts, want 1", removed) } - for _, operation := range []surfaces.Operation{surfaces.OperationApply, surfaces.OperationRecover} { fresh, _, err := refreshFlowInvocation(context.Background(), operation, prior, resumed) if err != nil { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index a7ec417..731b187 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -92,6 +92,7 @@ type commandOptions struct { workResultFingerprint string controlBundle *boatstackruntime.ControlBundleContract controlBundleFingerprint string + controlBundleRevision string invocationEvidence *invocation.Evidence inputRequest *invocation.InputRequest } @@ -142,8 +143,12 @@ func run(arguments []string) error { if err != nil { return err } + requestedFormat := options.format options, err = bindFlowEntry(context.Background(), options) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, operation); ok { + return renderResponse(suspended, requestedFormat) + } return err } request, err := buildRequest(operation, options) @@ -157,6 +162,9 @@ func run(arguments []string) error { } programChangeResponse, err := preflightDelegatedProgramChange(context.Background(), request) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, operation); ok { + return renderResponse(suspended, options.format) + } return err } if programChangeResponse != nil { @@ -180,10 +188,16 @@ func run(arguments []string) error { if (operation == surfaces.OperationApply || operation == surfaces.OperationRecover) && request.ProgramID != "" { request, options, err = refreshFlowInvocation(context.Background(), operation, request, options) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, operation); ok { + return renderResponse(suspended, options.format) + } return err } } - if err := verifyTrustedRequestControlBundle(request); err != nil { + if err := verifyTrustedRequestControlBundle(context.Background(), request); err != nil { + if suspended, ok := flowCommitRequiredResponse(err, operation); ok { + return renderResponse(suspended, options.format) + } return err } kernel, err := standardKernel(context.Background(), request) @@ -216,6 +230,9 @@ func run(arguments []string) error { if handleErr == nil && operation == surfaces.OperationResolve { request, response, _, handleErr = stabilizeRepositoryPrescription(context.Background(), request, response) } + if suspended, ok := flowCommitRequiredResponse(handleErr, operation); ok { + response, handleErr = suspended, nil + } if operation != surfaces.OperationExplain { if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { handleErr = settleErr @@ -250,6 +267,11 @@ func runRPC() error { } request, err := bindRPCFlowEntry(context.Background(), request) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, request.Operation); ok { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(suspended) + } return err } if request.ProgramID == "" { @@ -259,6 +281,11 @@ func runRPC() error { } programChangeResponse, err := preflightDelegatedProgramChange(context.Background(), request) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, request.Operation); ok { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(suspended) + } return err } if programChangeResponse != nil { @@ -286,10 +313,20 @@ func runRPC() error { if (request.Operation == surfaces.OperationApply || request.Operation == surfaces.OperationRecover) && request.ProgramID != "" { request, err = refreshRPCFlowInvocation(context.Background(), request) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, request.Operation); ok { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(suspended) + } return err } } - if err := verifyTrustedRequestControlBundle(request); err != nil { + if err := verifyTrustedRequestControlBundle(context.Background(), request); err != nil { + if suspended, ok := flowCommitRequiredResponse(err, request.Operation); ok { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(suspended) + } return err } kernel, err := standardKernel(context.Background(), request) @@ -300,6 +337,9 @@ func runRPC() error { if handleErr == nil && request.Operation == surfaces.OperationResolve { request, response, _, handleErr = stabilizeRepositoryPrescription(context.Background(), request, response) } + if suspended, ok := flowCommitRequiredResponse(handleErr, request.Operation); ok { + response, handleErr = suspended, nil + } if request.Operation != surfaces.OperationExplain { if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { handleErr = settleErr @@ -749,6 +789,7 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface WorkBlockReason: options.workBlockReason, ControlBundle: options.controlBundle, ControlBundleFingerprint: options.controlBundleFingerprint, + ControlBundleRevision: options.controlBundleRevision, InvocationEvidence: options.invocationEvidence, InputRequest: options.inputRequest, }, nil @@ -924,6 +965,10 @@ func renderResponse(response surfaces.Response, format string) error { } return nil } + if response.CommitRequired != nil { + fmt.Printf("SUSPENDED: %s run=%s revision=%s bundle=%s\n%s\n", response.CommitRequired.Code, response.CommitRequired.RunID, response.CommitRequired.Revision, response.CommitRequired.ControlBundleFingerprint, response.CommitRequired.Description) + return nil + } if response.Decision != nil { fmt.Printf("%s: %s\n", response.Decision.Kind, response.Decision.Reason) if response.Decision.Transition != nil { diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index 59f6b73..c4bd26d 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -16,7 +16,6 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/controlprogram" - "github.com/operatorstack/boatstack/boatstack/distribution" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" @@ -68,65 +67,139 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T writeFixture(t, repository, sourcePath, sourceRaw) writeFixture(t, repository, lockPath, lockRaw) writeFlowArtifact(t, repository, document, sourcePath, sourceRaw, lockPath, lockRaw) - hostFiles, hostManifest, err := effects.ProjectedHostSkillFiles([]string{"cli", "codex", "claude"}) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + + bare := filepath.Join(t.TempDir(), "todo.git") + runFlowGit(t, repository, "init", "--bare", bare) + runFlowGit(t, repository, "remote", "add", "origin", bare) + runFlowGit(t, repository, "push", "-q", "-u", "origin", "main") + installFakePublicationProvider(t) + + generatedSkill, err := os.ReadFile(filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md")) if err != nil { t.Fatal(err) } - writeFixture(t, repository, ".boatstack/host-skills.json", hostManifest) - for path, content := range hostFiles { - writeFixture(t, repository, path, content) + generatedStart := "boatstack flow run --repo . --flow product-delivery --entry run --repository-authority --host codex --format json" + if !strings.Contains(string(generatedSkill), generatedStart) { + t.Fatalf("generated skill lacks exact start command %q", generatedStart) } - definition, err := loadFlowDefinition(context.Background(), repository, "product-delivery") + + // `next` remains a read-only resolver even when scoped to a Flow entry. + // The generated execution skill must use `flow run` instead of changing + // the observation contract of `next`. + if _, err := captureRunOutput(t, "next", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--repository-authority", "--host", "codex", "--format", "json"); err != nil { + t.Fatalf("read-only Flow next: %v", err) + } + resolver, err := plant.NewResolver("") if err != nil { t.Fatal(err) } - configPath := filepath.Join(repository, ".boatstack", "project.json") - configRaw, err := os.ReadFile(configPath) + invoking, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "read-only-next") if err != nil { t.Fatal(err) } - _, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw) + layout, _, err := resolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{layout.StatePath, layout.ReceiptPath} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("read-only Flow next wrote %s: %v", path, statErr) + } + } + + // The exact generated command supplies no actor. It must return a typed, + // run-bound installation authority question instead of a zero-progress + // candidate or a premature product delegation request. + first, err := captureStdout(t, func() error { + return run([]string{"flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--repository-authority", "--host", "codex", "--format", "json"}) + }) if err != nil { + t.Fatalf("installation authority suspension: %v\n%s", err, first) + } + var installationQuestion surfaces.Response + if err := json.Unmarshal(first, &installationQuestion); err != nil { t.Fatal(err) } - program, err := distribution.ProgramForRepository(context.Background(), distribution.RepositoryProgramRequest{ - Repository: repository, Host: "codex", CorrelationID: "fixture-program", ConfigurationPath: configPath, ConfigurationFingerprint: configFingerprint, - }, definition) + if installationQuestion.Question == nil || installationQuestion.Question.TransitionID != "installation.initialize" || len(installationQuestion.Question.Authority) != 2 || installationQuestion.RunID == "" || installationQuestion.Delegation != nil || installationQuestion.Receipt != nil { + t.Fatalf("installation authority response = %#v\n%s", installationQuestion, first) + } + runID := installationQuestion.RunID + t.Logf("SUSPENSION question run=%s transition=%s authority=%v", runID, installationQuestion.Question.TransitionID, installationQuestion.Question.Authority) + + // Accepting installation may write the control bundle, but product authority + // cannot bind to those bytes until an explicit Git commit establishes the + // revision boundary. + install, err := captureStdout(t, func() error { + return run([]string{"flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, "--repository-authority", "--human", "operator", "--host", "codex", "--format", "json"}) + }) if err != nil { + t.Fatalf("automatic initialization: %v\n%s", err, install) + } + var commitSuspension surfaces.Response + if err := json.Unmarshal(install, &commitSuspension); err != nil { t.Fatal(err) } - pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin(runtimeIdentity, program.Fingerprint(), durable.StateSchemaVersion)) + if commitSuspension.CommitRequired == nil || commitSuspension.CommitRequired.Code != controlBundleCommitRequiredCode || commitSuspension.CommitRequired.RunID != runID || commitSuspension.CommitRequired.ControlBundleFingerprint == "" || commitSuspension.Delegation != nil || commitSuspension.Work != nil { + t.Fatalf("control-bundle commit suspension = %#v\n%s", commitSuspension, install) + } + if !strings.Contains(string(generatedSkill), controlBundleCommitRequiredCode) || !strings.Contains(string(generatedSkill), "stay in the source\nrepository") { + t.Fatalf("generated skill does not route the installation commit suspension to the source repository") + } + prerequisites := committedFlowReceipts(t, repository, runID) + if len(prerequisites) != 1 || prerequisites[0].TransitionID != "installation.initialize" { + t.Fatalf("pre-commit trace = %#v", prerequisites) + } + if _, statErr := os.Stat(filepath.Join(layout.FlowRoot, "work", runID)); !os.IsNotExist(statErr) { + t.Fatalf("product work exists before bundle commit and delegation: %v", statErr) + } + workShowRaw, err := captureStdout(t, func() error { + return runFlowWork([]string{ + "show", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, + "--work-id", "planning-package", "--host", "codex", "--format", "json", + }) + }) if err != nil { + t.Fatalf("work show commit suspension: %v\n%s", err, workShowRaw) + } + var workShowSuspension surfaces.Response + if err := json.Unmarshal(workShowRaw, &workShowSuspension); err != nil { t.Fatal(err) } - writeFixture(t, repository, ".boatstack/runtime.json", pinRaw) + if workShowSuspension.Operation != surfaces.OperationWorkShow || workShowSuspension.CommitRequired == nil || workShowSuspension.CommitRequired.Code != controlBundleCommitRequiredCode { + t.Fatalf("work show commit suspension = %#v", workShowSuspension) + } + if status := runFlowGitOutput(t, repository, "status", "--short"); !strings.Contains(status, ".boatstack/runtime.json") || !strings.Contains(status, ".boatstack/host-skills.json") { + t.Fatalf("automatic installation did not leave the exact bundle for explicit commit:\n%s", status) + } runFlowGit(t, repository, "add", ".") - runFlowGit(t, repository, "commit", "-q", "-m", "fixture") - - bare := filepath.Join(t.TempDir(), "todo.git") - runFlowGit(t, repository, "init", "--bare", bare) - runFlowGit(t, repository, "remote", "add", "origin", bare) - runFlowGit(t, repository, "push", "-q", "-u", "origin", "main") - installFakePublicationProvider(t) + runFlowGit(t, repository, "commit", "-q", "-m", "install Boatstack control bundle") + runFlowGit(t, repository, "push", "-q", "origin", "main") - first, err := captureStdout(t, func() error { - return runFlowContinuation([]string{"--repo", repository, "--flow", "product-delivery", "--entry", "run", "--repository-authority", "--human", "operator", "--host", "codex", "--format", "json"}) + delegationOutput, err := captureStdout(t, func() error { + return run([]string{"flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, "--repository-authority", "--human", "operator", "--host", "codex", "--format", "json"}) }) if err != nil { - t.Fatalf("delegation suspension: %v\n%s", err, first) + t.Fatalf("delegation suspension: %v\n%s", err, delegationOutput) } var delegated surfaces.Response - if err := json.Unmarshal(first, &delegated); err != nil { + if err := json.Unmarshal(delegationOutput, &delegated); err != nil { t.Fatal(err) } if delegated.Delegation == nil || delegated.Delegation.RunID == "" { - t.Fatalf("delegation response = %#v\n%s", delegated, first) + t.Fatalf("delegation response = %#v\n%s", delegated, delegationOutput) + } + if delegated.Delegation.RunID != runID { + t.Fatalf("delegation run = %s, want %s", delegated.Delegation.RunID, runID) } - runID := delegated.Delegation.RunID t.Logf("SUSPENSION delegation run=%s request=%s authorities=%v", runID, delegated.Delegation.RequestFingerprint, delegated.Delegation.Authorities) - prerequisites := committedFlowReceipts(t, repository, runID) - if len(prerequisites) != 3 || prerequisites[0].TransitionID != "installation.initialize" || prerequisites[1].TransitionID != "objective.bind" || prerequisites[2].TransitionID != "engagement.begin" || delegated.Work != nil { - t.Fatalf("pre-delegation trace = %#v, work = %#v", prerequisites, delegated.Work) + if delegated.Work != nil { + t.Fatalf("product work crossed delegation: %#v", delegated.Work) + } + preDelegation := committedFlowReceipts(t, repository, runID) + if len(preDelegation) != 1 || preDelegation[0].TransitionID != "installation.initialize" { + t.Fatalf("product transition crossed delegation: %#v", preDelegation) } if _, err := captureStdout(t, func() error { return runFlowAuthorize([]string{ diff --git a/boatstack/cmd/boatstack-helper/work_command.go b/boatstack/cmd/boatstack-helper/work_command.go index d9dbfa1..44c2ccb 100644 --- a/boatstack/cmd/boatstack-helper/work_command.go +++ b/boatstack/cmd/boatstack-helper/work_command.go @@ -29,8 +29,12 @@ func runFlowWork(arguments []string) error { if options.programID == "" || options.entryID == "" || options.runID == "" || options.workID == "" { return fmt.Errorf("flow work %s requires --flow, --entry, --run-id, and --work-id", action) } + requestedFormat := options.format bound, err := bindFlowEntry(context.Background(), options) if err != nil { + if suspended, ok := flowCommitRequiredResponse(err, operation); ok { + return renderResponse(suspended, requestedFormat) + } return err } request, err := buildRequest(operation, bound) @@ -57,7 +61,10 @@ func runFlowWork(arguments []string) error { return err } defer lease.Release() - if err := verifyTrustedRequestControlBundle(request); err != nil { + if err := verifyTrustedRequestControlBundle(context.Background(), request); err != nil { + if suspended, ok := flowCommitRequiredResponse(err, operation); ok { + return renderResponse(suspended, options.format) + } return err } kernel, err := standardKernel(context.Background(), request) diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index d2f3847..3004dfd 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -63,6 +63,9 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s programReconciliation := "" publication := "" startCommand := fmt.Sprintf("boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) + if entry.Delegation != nil { + startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) + } if declarativeProgram(compiled.Document.Operators) { startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --host %s --format json", compiled.Document.Program.ID, entry.ID, host) if len(entry.Inputs) != 0 { @@ -210,10 +213,23 @@ run ID, reconstruct the transition graph, or act on a rejected candidate. } if entry.Delegation != nil { delegation = fmt.Sprintf(` -The first `+"`next`"+` returns a typed `+"`DELEGATION_REQUIRED`"+` response before -managed state changes. Display its exact run ID, request fingerprint, requested -authorities, and description. Obtain one explicit human approval for that exact -request, then run: +Before product delegation, Boatstack may select `+"`installation.initialize`"+` +for an installed repository whose controller state is fresh. Display that exact +installation-authority question and obtain explicit human approval. Resume the +same Flow command with `+"`--human `"+`; do not invoke an update operation +or supply installation values with `+"`--param`"+`. Boatstack derives those values +from the committed project configuration and the executing runtime. + +If Boatstack returns `+"`CONTROL_BUNDLE_COMMIT_REQUIRED`"+`, stay in the source +repository and current run. Commit the exact installed Boatstack control bundle, +including the generated runtime and host projection files named by the response, +then resume the same Flow command. This is an installation boundary, not managed +product-workspace work; do not switch worktrees or exclude generated bundle files. + +After internal preconditions are committed, Boatstack returns a typed +`+"`DELEGATION_REQUIRED`"+` response bound to the resulting control bundle. +Display its exact run ID, request fingerprint, requested authorities, and +description. Obtain one explicit human approval for that exact request, then run: `+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human --host %s`"+` diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 3606c76..af01310 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -15,7 +15,10 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ Program: controlprogram.Program{ID: "product-delivery"}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{True: &truth}}}, - Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr", Description: "Publish the reviewed change"}}, + Entries: []controlprogram.Entry{{ + ID: "run", Target: "published-pr", Description: "Publish the reviewed change", + Delegation: &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"}, + }}, }} files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) if err != nil { @@ -36,9 +39,11 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } value := string(codex) for _, contract := range []string{ - "--flow product-delivery --entry run", "--repository-authority", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", + "boatstack flow run --repo . --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", + "Before product delegation", "do not invoke an update operation", "committed project configuration", + "CONTROL_BUNDLE_COMMIT_REQUIRED", "stay in the source\nrepository", "do not switch worktrees or exclude generated bundle files", "installation-authority\nsuspension before product work", "installation.reconcile-update", "--accept-program-change", "boatstack reconcile-update --repo . --flow product-delivery --entry run --run-id ", "do not request or reuse product delegation before reconciliation", "commit\nthose exact files separately before product work", diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 5abcf08..b94921a 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -19,9 +19,10 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 12 +const SchemaVersion = 13 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +var gitObjectIdentity = regexp.MustCompile(`^[0-9a-f]{40,64}$`) type Operation string @@ -81,6 +82,7 @@ type Request struct { WorkBlockReason string `json:"work_block_reason,omitempty"` ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + ControlBundleRevision string `json:"control_bundle_revision,omitempty"` InvocationEvidence *invocation.Evidence `json:"invocation_evidence,omitempty"` InputRequest *invocation.InputRequest `json:"input_request,omitempty"` } @@ -124,6 +126,9 @@ func (r Request) Validate(now time.Time) error { } else if r.ControlBundleFingerprint != "" { return fmt.Errorf("CONTROL_BUNDLE_INVALID: request fingerprint has no trusted bundle") } + if r.ControlBundleRevision != "" && (r.ControlBundle == nil || !gitObjectIdentity.MatchString(r.ControlBundleRevision)) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: request revision has no trusted bundle or is not a Git object identity") + } 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") } @@ -231,30 +236,31 @@ type ProgramChange struct { } type Response struct { - SchemaVersion int `json:"schema_version"` - Operation Operation `json:"operation"` - ProgramID string `json:"program_id,omitempty"` - EntryID string `json:"entry_id,omitempty"` - RunID string `json:"run_id,omitempty"` - Objective model.Objective `json:"objective,omitempty"` - Snapshot *model.Snapshot `json:"snapshot,omitempty"` - Decision *supervisor.Decision `json:"decision,omitempty"` - Trace *general.DecisionTrace `json:"trace,omitempty"` - Question *Question `json:"question,omitempty"` - Prescription *protocol.Prescription `json:"prescription,omitempty"` - Admission *protocol.Admission `json:"admission,omitempty"` - Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"` - Replayed bool `json:"replayed,omitempty"` - Catalog []catalog.Transition `json:"catalog,omitempty"` - Events []map[string]any `json:"events,omitempty"` - Doctor *DoctorReport `json:"doctor,omitempty"` - ProgramChange *ProgramChange `json:"program_change,omitempty"` - Guard *supervisor.GuardDecision `json:"guard,omitempty"` - Error string `json:"error,omitempty"` - Delegation *DelegationRequired `json:"delegation,omitempty"` - Work *foregroundwork.Record `json:"work,omitempty"` - InputRequest *invocation.InputRequest `json:"input_request,omitempty"` - Invocation *invocation.Evidence `json:"invocation_evidence,omitempty"` + SchemaVersion int `json:"schema_version"` + Operation Operation `json:"operation"` + ProgramID string `json:"program_id,omitempty"` + EntryID string `json:"entry_id,omitempty"` + RunID string `json:"run_id,omitempty"` + Objective model.Objective `json:"objective,omitempty"` + Snapshot *model.Snapshot `json:"snapshot,omitempty"` + Decision *supervisor.Decision `json:"decision,omitempty"` + Trace *general.DecisionTrace `json:"trace,omitempty"` + Question *Question `json:"question,omitempty"` + Prescription *protocol.Prescription `json:"prescription,omitempty"` + Admission *protocol.Admission `json:"admission,omitempty"` + Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"` + Replayed bool `json:"replayed,omitempty"` + Catalog []catalog.Transition `json:"catalog,omitempty"` + Events []map[string]any `json:"events,omitempty"` + Doctor *DoctorReport `json:"doctor,omitempty"` + ProgramChange *ProgramChange `json:"program_change,omitempty"` + Guard *supervisor.GuardDecision `json:"guard,omitempty"` + Error string `json:"error,omitempty"` + Delegation *DelegationRequired `json:"delegation,omitempty"` + CommitRequired *CommitRequired `json:"commit_required,omitempty"` + Work *foregroundwork.Record `json:"work,omitempty"` + InputRequest *invocation.InputRequest `json:"input_request,omitempty"` + Invocation *invocation.Evidence `json:"invocation_evidence,omitempty"` } type DelegationRequired struct { @@ -265,6 +271,17 @@ type DelegationRequired struct { Description string `json:"description"` } +// CommitRequired is a typed suspension at a repository revision boundary. +// Boatstack preserves the run and installed bytes but does not mint Git commit +// authority; the caller must commit the exact control bundle and resume. +type CommitRequired struct { + Code string `json:"code"` + RunID string `json:"run_id"` + Revision string `json:"revision"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + Description string `json:"description"` +} + // Question is a typed suspension, not a background task. Supplying its // required evidence and resolving again with the same run identity resumes the // existing command context. diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go index db79106..69787cd 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go @@ -32,6 +32,35 @@ func TestSurfaceSchemaIsFlagDayAndApplyRequiresPrescription(t *testing.T) { } } +func TestCommitRequiredHasANewSurfaceSchemaIdentity(t *testing.T) { + // control-law: a v12 consumer must reject the new suspension by schema + // identity instead of accepting it while discarding commit_required. + if SchemaVersion == 12 { + t.Fatal("commit_required reused the prior surface schema") + } + encoded, err := json.Marshal(Response{ + SchemaVersion: SchemaVersion, + CommitRequired: &CommitRequired{ + Code: "CONTROL_BUNDLE_COMMIT_REQUIRED", RunID: "run-1", + Revision: "0123456789012345678901234567890123456789", + ControlBundleFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Description: "Commit the exact bundle.", + }, + }) + if err != nil { + t.Fatal(err) + } + var envelope struct { + SchemaVersion int `json:"schema_version"` + } + if err := json.Unmarshal(encoded, &envelope); err != nil { + t.Fatal(err) + } + if envelope.SchemaVersion != SchemaVersion || envelope.SchemaVersion == 12 { + t.Fatalf("commit suspension schema = %d, want current non-v12 identity", envelope.SchemaVersion) + } +} + func TestForegroundWorkSurfaceRejectsAmbiguousMutationPayloads(t *testing.T) { // control-law: each foreground-work mutation crosses one typed operation boundary base := Request{ diff --git a/release-notes/2026-08-16-canonical-flow-initialization.md b/release-notes/2026-08-16-canonical-flow-initialization.md index 67db2f2..d34006b 100644 --- a/release-notes/2026-08-16-canonical-flow-initialization.md +++ b/release-notes/2026-08-16-canonical-flow-initialization.md @@ -1,3 +1,3 @@ ### Make fresh Flow runs self-initializing -Repository Flow runs now derive installation inputs from the committed project configuration and executing runtime, then request product delegation only after installation prerequisites are complete. +Repository Flow runs now derive installation inputs from the committed project configuration and executing runtime. They use a distinct control-bundle commit suspension, verify its exact Git revision at the effect boundary, and request product delegation only after installation prerequisites are complete. From ebafd5be531e5d77ac0cbe9da07e106da1ca9ebe Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 17:47:04 +0100 Subject: [PATCH 4/4] Enforce control bundle revision at effect boundary --- boatstack/delivery_controller.go | 16 +- boatstack/internal/runtime/control_bundle.go | 14 ++ .../softwaredelivery/effects/driver.go | 35 +++- .../effects/integration_test.go | 154 ++++++++++++++++++ .../softwaredelivery/engine/engine.go | 34 ++-- .../softwaredelivery/protocol/admission.go | 20 ++- boatstack/program_effects.go | 5 + boatstack/program_effects_test.go | 59 ++++++- 8 files changed, 305 insertions(+), 32 deletions(-) diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index a1565bc..611d4ca 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -10,6 +10,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/delivery" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" + 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/engine" @@ -120,6 +121,17 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request response.Error = err.Error() return response, err } + if request.ControlBundleRevision != "" { + layout, _, layoutErr := k.resolver.ResolveLayout(ctx, invocation) + if layoutErr != nil { + response.Error = layoutErr.Error() + return response, layoutErr + } + if revisionErr := boatstackruntime.VerifyCurrentControlBundleRevision(ctx, layout.RepositoryRoot, request.ControlBundleRevision, request.ControlBundle.Source); revisionErr != nil { + response.Error = revisionErr.Error() + return response, revisionErr + } + } if request.RepositoryAuthority { request.Authority, err = k.deriveRepositoryAuthority(ctx, invocation, request.Authority) if err != nil { @@ -130,7 +142,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, ControlBundle: request.ControlBundle, InvocationEvidence: request.InvocationEvidence} + resolveRequest := engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain, ControlBundle: request.ControlBundle, ControlBundleRevision: request.ControlBundleRevision, InvocationEvidence: request.InvocationEvidence} 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) @@ -187,7 +199,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, ControlBundle: request.ControlBundle, InvocationEvidence: request.InvocationEvidence}, + ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID, Work: work, ControlBundle: request.ControlBundle, ControlBundleRevision: request.ControlBundleRevision, InvocationEvidence: request.InvocationEvidence}, FlowID: request.FlowID, Prescription: request.Prescription, Parameters: request.Parameters, IdempotencyKey: request.IdempotencyKey, AdmissionLifetime: 2 * time.Minute, }) response.Prescription = &request.Prescription diff --git a/boatstack/internal/runtime/control_bundle.go b/boatstack/internal/runtime/control_bundle.go index 46e584a..43ee7ba 100644 --- a/boatstack/internal/runtime/control_bundle.go +++ b/boatstack/internal/runtime/control_bundle.go @@ -523,6 +523,20 @@ func VerifyControlBundleRevision(ctx context.Context, repository, revision strin return nil } +// VerifyCurrentControlBundleRevision binds the admitted bundle to the exact +// commit currently checked out by the repository. Matching working-tree bytes +// cannot substitute for the committed revision that supplied authority. +func VerifyCurrentControlBundleRevision(ctx context.Context, repository, expectedRevision string, snapshot ControlBundleSnapshot) error { + currentRevision, err := ResolveCommitRevision(ctx, repository, "HEAD") + if err != nil { + return err + } + if currentRevision != expectedRevision { + return fmt.Errorf("CONTROL_BUNDLE_REVISION_DRIFT: expected revision %s, observed %s", expectedRevision, currentRevision) + } + return VerifyControlBundleRevision(ctx, repository, currentRevision, snapshot) +} + func ResolveCommitRevision(ctx context.Context, repository, reference string) (string, error) { command := exec.CommandContext(ctx, "git", "rev-parse", "--verify", reference+"^{commit}") command.Dir = repository diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index 38f05a5..353e2f5 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -51,6 +51,31 @@ func NewProgramDriver(resolver ports.InvocationResolver, clock ports.Clock, boun return driver, nil } +// VerifyControlBundleBoundary re-observes the invocation and any explicit +// commit-bound bundle at the final side-effect-free software-delivery +// boundary. Program and extension runtimes use the same guard even when they +// plan effects without delegating preparation to Driver. +func VerifyControlBundleBoundary(ctx context.Context, resolver ports.InvocationResolver, admission protocol.Admission) (ports.ControllerLayout, error) { + layout, currentInvocation, err := resolver.ResolveLayout(ctx, admission.Invocation) + if err != nil { + return ports.ControllerLayout{}, err + } + if currentInvocation.RepositoryID != admission.Invocation.RepositoryID || currentInvocation.GitCommonID != admission.Invocation.GitCommonID || currentInvocation.WorktreeID != admission.Invocation.WorktreeID { + return ports.ControllerLayout{}, fmt.Errorf("effect invocation identity changed before preparation") + } + if admission.ControlBundle != nil { + if admission.ControlBundleRevision != "" { + if err := boatstackruntime.VerifyCurrentControlBundleRevision(ctx, layout.RepositoryRoot, admission.ControlBundleRevision, admission.ControlBundle.Source); err != nil { + return ports.ControllerLayout{}, err + } + } + if err := boatstackruntime.VerifyControlBundleRoot(layout.RepositoryRoot, admission.ControlBundle.Source); err != nil { + return ports.ControllerLayout{}, err + } + } + return layout, nil +} + func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { return nil, err @@ -62,18 +87,10 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans } } } - layout, currentInvocation, err := d.resolver.ResolveLayout(ctx, admission.Invocation) + layout, err := VerifyControlBundleBoundary(ctx, d.resolver, admission) if err != nil { return nil, err } - if currentInvocation.RepositoryID != admission.Invocation.RepositoryID || currentInvocation.GitCommonID != admission.Invocation.GitCommonID || currentInvocation.WorktreeID != admission.Invocation.WorktreeID { - return nil, fmt.Errorf("effect invocation identity changed before preparation") - } - if 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 { diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index d2eacd1..021373a 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -34,6 +34,31 @@ import ( type fixedClock struct{ value time.Time } +type stagedPrepareDriver struct { + base ports.EffectDriver + calls int + trigger int + beforePrepare func() +} + +func (d *stagedPrepareDriver) Prepare(ctx context.Context, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { + d.calls++ + if d.calls == d.trigger && d.beforePrepare != nil { + d.beforePrepare() + } + return d.base.Prepare(ctx, admission, transition) +} + +type countingJournal struct { + ports.Journal + begun int +} + +func (j *countingJournal) Begin(ctx context.Context, admission protocol.Admission, transition catalog.Transition) error { + j.begun++ + return j.Journal.Begin(ctx, admission, transition) +} + const testProgramFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" var testProgramIdentity = protocol.ProgramIdentity{ID: "standard", Version: "test", Fingerprint: testProgramFingerprint} @@ -254,6 +279,135 @@ func TestStaleControlBundleStopsBeforeManagedStateOrRuntimePin(t *testing.T) { } } +func TestControllerRejectsExactBundleRevisionDriftWithMatchingWorkingBytes(t *testing.T) { + // control-law: public SDK requests cannot substitute matching working bytes for the admitted commit + ctx := context.Background() + repository := testRepository(t) + baseRevision := strings.TrimSpace(commandOutput(t, repository, "git", "rev-parse", "HEAD")) + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("candidate bundle\n"), 0o644); err != nil { + t.Fatal(err) + } + run(t, repository, "git", "add", "README.md") + run(t, repository, "git", "commit", "-q", "-m", "candidate bundle") + acceptedRevision := strings.TrimSpace(commandOutput(t, repository, "git", "rev-parse", "HEAD")) + + externalRoot := t.TempDir() + kernel, err := boatstack.NewDeliveryController(externalRoot, testProgram()) + if 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\":\"revision\",\"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.OperationApply, Repository: repository, Host: "cli", CorrelationID: "exact-bundle-revision", + FlowID: "flow-exact-bundle-revision", TransitionID: "installation.initialize", ControlBundleRevision: acceptedRevision, + 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)}, + }, + } + request = prescribeSurface(t, ctx, kernel, request) + run(t, repository, "git", "reset", "--mixed", baseRevision) + if raw, readErr := os.ReadFile(filepath.Join(repository, "README.md")); readErr != nil || string(raw) != "candidate bundle\n" { + t.Fatalf("reset fixture lost matching working bytes: value=%q error=%v", raw, readErr) + } + + response, handleErr := kernel.Handle(ctx, request) + if handleErr == nil || !strings.Contains(handleErr.Error(), "CONTROL_BUNDLE_REVISION_DRIFT") || response.Error == "" { + t.Fatalf("stale exact revision crossed controller boundary: response=%+v error=%v", response, handleErr) + } + resolver, err := plant.NewResolver(externalRoot) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "exact-bundle-revision-check") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{layout.StatePath, layout.ReceiptPath, layout.EventPath, boatstackruntime.PinPath(repository)} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("stale exact revision created managed artifact %s: %v", path, statErr) + } + } +} + +func TestEffectPreparationRechecksBundleRevisionBeforeJournalMutation(t *testing.T) { + // control-law: a HEAD move after resolution is refused by final effect preparation + ctx := context.Background() + repository := testRepository(t) + baseRevision := strings.TrimSpace(commandOutput(t, repository, "git", "rev-parse", "HEAD")) + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("prepared candidate\n"), 0o644); err != nil { + t.Fatal(err) + } + run(t, repository, "git", "add", "README.md") + run(t, repository, "git", "commit", "-q", "-m", "prepared candidate") + + clock := fixedClock{value: time.Unix(1000, 0).UTC()} + externalRoot := t.TempDir() + resolver, err := plant.NewResolver(externalRoot) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "prepared-revision") + if err != nil { + t.Fatal(err) + } + observer, _ := plant.NewObserver(resolver, clock) + locker, _ := effects.NewLocker(resolver) + baseJournal, _ := effects.NewJournal(resolver, clock) + journal := &countingJournal{Journal: baseJournal} + receipts, _ := effects.NewReceiptStore(resolver, clock) + baseDriver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary()) + driver := &stagedPrepareDriver{base: baseDriver} + kernel, err := engine.New(testprogram.StandardRegistry(), testObjectiveContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts) + if err != nil { + t.Fatal(err) + } + objective := model.Objective{ID: "prepared-revision", TargetID: model.ObjectiveVerified, DeliveryID: "prepared-revision"} + authority := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ + ID: "prepared-revision-human", Class: catalog.AuthorityHuman, Subject: invocation.RepositoryID, Fingerprint: "human-fingerprint", + IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour), + }}} + request := engine.ApplyRequest{ + ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: objective, Authority: authority, Requested: "repository.attach", ControlBundleRevision: strings.TrimSpace(commandOutput(t, repository, "git", "rev-parse", "HEAD"))}, + FlowID: "flow-prepared-revision", Parameters: protocol.Parameters{{Name: "topology", Value: string(model.TopologyDetached)}, {Name: "config_authority", Value: "repository"}}, AdmissionLifetime: time.Minute, + } + request = prescribeEngine(t, ctx, kernel, request) + driver.trigger = driver.calls + 2 // apply re-resolve, then locked final preparation + driver.beforePrepare = func() { run(t, repository, "git", "reset", "--mixed", baseRevision) } + + _, applyErr := kernel.Apply(ctx, request) + if applyErr == nil || !strings.Contains(applyErr.Error(), "CONTROL_BUNDLE_REVISION_DRIFT") { + t.Fatalf("effect preparation accepted stale revision: %v", applyErr) + } + if driver.calls != driver.trigger || journal.begun != 0 { + t.Fatalf("revision refusal crossed mutation boundary: prepares=%d trigger=%d journal begins=%d", driver.calls, driver.trigger, journal.begun) + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{layout.StatePath, layout.ReceiptPath, layout.EventPath} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("effect-boundary revision drift created managed artifact %s: %v", path, statErr) + } + } +} + func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) { // control-law: request-to-boundary-to-effect-to-verified-receipt ctx := context.Background() diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index f561fc4..0a9e8e7 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -41,15 +41,16 @@ 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 - ControlBundle *boatstackruntime.ControlBundleContract - InvocationEvidence *invocation.Evidence + Invocation model.InvocationContext + Objective model.Objective + Authority protocol.AuthorityBundle + Parameters protocol.Parameters + Requested catalog.TransitionID + Trace bool + Work *protocol.WorkEvidence + ControlBundle *boatstackruntime.ControlBundleContract + ControlBundleRevision string + InvocationEvidence *invocation.Evidence } type Resolution struct { @@ -181,7 +182,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.NewAdmissionWithWorkAndBundle(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, request.Work, bundle, now, 2*time.Minute) + admission, admissionErr := protocol.NewAdmissionWithWorkBundleAndRevision(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, request.Work, bundle, request.ControlBundleRevision, now, 2*time.Minute) if admissionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = admissionErr.Error() @@ -433,7 +434,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe 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) + admission, err := protocol.NewAdmissionWithWorkBundleAndRevision(resolution.Snapshot, request.Objective, transition, request.Prescription, request.Authority, request.Parameters, request.Work, bundle, request.ControlBundleRevision, now, request.AdmissionLifetime) if err != nil { return result, err } @@ -515,6 +516,13 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { return result, err } + // Prepare is the final side-effect-free plant preflight. Run it after the + // locked observation but before receipt binding or transaction state so a + // changed external boundary leaves no managed mutation to recover. + prepared, err := e.effects.Prepare(ctx, admission, transition) + if err != nil { + return result, err + } if err := e.receipts.Bind(ctx, request.FlowID, admission); err != nil { return result, err } @@ -545,10 +553,6 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err := e.journal.Mark(ctx, admission.ID, "executing"); err != nil { return result, abort("journal mark failed", err) } - prepared, err := e.effects.Prepare(ctx, admission, transition) - if err != nil { - return result, abort("effect preparation failed", err) - } if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { return result, abort("effect capability check failed", err) } diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index a9aea51..2b78518 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -48,6 +48,7 @@ type Admission struct { ExpiresAt time.Time `json:"expires_at"` Work *WorkEvidence `json:"work,omitempty"` ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` + ControlBundleRevision string `json:"control_bundle_revision,omitempty"` InvocationFingerprint string `json:"invocation_fingerprint,omitempty"` } @@ -60,6 +61,10 @@ func NewAdmissionWithWork(snapshot model.Snapshot, objective model.Objective, tr } 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) { + return NewAdmissionWithWorkBundleAndRevision(snapshot, objective, transition, prescription, authority, parameters, work, bundle, "", now, lifetime) +} + +func NewAdmissionWithWorkBundleAndRevision(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, work *WorkEvidence, bundle *boatstackruntime.ControlBundleContract, controlBundleRevision string, now time.Time, lifetime time.Duration) (Admission, error) { var err error objective, err = ObjectiveForTransition(snapshot, objective, transition) if err != nil { @@ -131,6 +136,12 @@ func NewAdmissionWithWorkAndBundle(snapshot model.Snapshot, objective model.Obje } a.ControlBundle = © } + if controlBundleRevision != "" { + if a.ControlBundle == nil || controlBundleRevision != sourceRevision { + return Admission{}, fmt.Errorf("CONTROL_BUNDLE_REVISION_DRIFT: exact bundle revision does not match the observed source revision") + } + a.ControlBundleRevision = controlBundleRevision + } if transition.Policy.ObjectiveScope == catalog.ObjectiveScopeOptionalPreserve { a.ObjectiveStatus = snapshot.Objective.Status } @@ -463,8 +474,8 @@ func (a Admission) ValidateCommittedHistoryIdentity() error { case AdmissionSchemaVersion: return a.ValidateIdentity() case PreviousAdmissionSchemaVersion: - if a.InvocationFingerprint != "" { - return fmt.Errorf("legacy admission invents a current-schema invocation identity") + if a.InvocationFingerprint != "" || a.ControlBundleRevision != "" { + return fmt.Errorf("legacy admission invents a current-schema invocation or control-bundle revision identity") } return a.validateIdentity(PreviousAdmissionSchemaVersion) default: @@ -486,6 +497,11 @@ func (a Admission) validateIdentity(schemaVersion int) error { if err != nil { return err } + if a.ControlBundleRevision != "" && (a.ControlBundleRevision != a.SourceRevision || (len(a.ControlBundleRevision) != 40 && len(a.ControlBundleRevision) != 64) || strings.Trim(a.ControlBundleRevision, "0123456789abcdef") != "") { + return fmt.Errorf("admission has invalid control-bundle revision identity") + } + } else if a.ControlBundleRevision != "" { + return fmt.Errorf("admission control-bundle revision has no admitted bundle") } fingerprint, err := a.Authority.Fingerprint() if err != nil || fingerprint != a.AuthorityFingerprint { diff --git a/boatstack/program_effects.go b/boatstack/program_effects.go index bf6a897..3f9ff9c 100644 --- a/boatstack/program_effects.go +++ b/boatstack/program_effects.go @@ -30,6 +30,11 @@ func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Adm return nil, err } } + if transition.Origin.Kind != catalog.OriginCoreSystem { + if _, err := effects.VerifyControlBundleBoundary(ctx, d.resolver, admission); err != nil { + return nil, err + } + } if transition.Origin.Kind == catalog.OriginCoreSystem { return d.base.Prepare(ctx, admission, transition) } diff --git a/boatstack/program_effects_test.go b/boatstack/program_effects_test.go index ac0bc2e..f1d810e 100644 --- a/boatstack/program_effects_test.go +++ b/boatstack/program_effects_test.go @@ -8,11 +8,13 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" "github.com/operatorstack/boatstack/boatstack/core" "github.com/operatorstack/boatstack/boatstack/delivery" + 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" @@ -120,6 +122,7 @@ func (c protocolStateClock) Now() time.Time { return c.now } type protocolStateObserver struct { path string + repository string invocation model.InvocationContext program string now time.Time @@ -136,12 +139,24 @@ func (o protocolStateObserver) Observe(context.Context, ports.ObservationRequest return model.Observation{}, err } evidence := model.Evidence{Source: "fixture", Fingerprint: "fixture-state", ObservedAt: o.now} + gitEvidence := evidence + if o.repository != "" { + command := exec.Command("git", "rev-parse", "--verify", "HEAD^{commit}") + command.Dir = o.repository + head, headErr := command.Output() + if headErr != nil { + return model.Observation{}, headErr + } + revision := strings.TrimSpace(string(head)) + digest := sha256.Sum256([]byte(revision)) + gitEvidence = model.Evidence{Source: "git:" + o.repository, Fingerprint: hex.EncodeToString(digest[:]), Revision: revision, ObservedAt: o.now} + } return model.Observation{ SchemaVersion: model.SnapshotSchemaVersion, StateRevision: state.Revision, RecordedProgramFingerprint: state.ProgramFingerprint, Invocation: o.invocation, Phase: model.Known(state.Phase, evidence), Engagement: model.Known(state.Engagement, evidence), - Delivery: model.Known(state.Delivery, evidence), Workspace: model.Known(state.Workspace, evidence), Plan: model.Known(state.Plan, evidence), + Delivery: model.Known(state.Delivery, gitEvidence), Workspace: model.Known(state.Workspace, evidence), Plan: model.Known(state.Plan, evidence), Configuration: model.Known(state.Configuration, o.configProof), ConfigurationPolicy: model.Known(state.ConfigurationPolicy(), o.configProof), - Runtime: model.Known(state.Runtime, evidence), Publication: model.Known(state.Publication, evidence), Verification: model.Known(state.Verification, evidence), + Runtime: model.Known(state.Runtime, evidence), Publication: model.Known(state.Publication, evidence), Verification: model.Known(state.Verification, gitEvidence), Recovery: model.Known(state.Recovery, evidence), Transaction: model.Known(state.Transaction, evidence), RecoveryInfo: model.Absent[model.RecoveryContext]("none", evidence), TransactionInfo: model.Absent[model.TransactionContext]("none", evidence), Terminal: model.Known(state.Terminal, evidence), Objective: model.Known(state.Objective, evidence), ObservedAt: o.now, @@ -171,6 +186,25 @@ func TestProgramRuntimeProtocolCommitsDeclaredStateEffectBeforeReceipt(t *testin } runGit("add", "README.md") runGit("commit", "-q", "-m", "fixture") + baseRevisionCommand := exec.Command("git", "rev-parse", "HEAD") + baseRevisionCommand.Dir = repository + baseRevisionRaw, err := baseRevisionCommand.Output() + if err != nil { + t.Fatal(err) + } + baseRevision := strings.TrimSpace(string(baseRevisionRaw)) + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("protocol candidate\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit("add", "README.md") + runGit("commit", "-q", "-m", "protocol candidate") + acceptedRevisionCommand := exec.Command("git", "rev-parse", "HEAD") + acceptedRevisionCommand.Dir = repository + acceptedRevisionRaw, err := acceptedRevisionCommand.Output() + if err != nil { + t.Fatal(err) + } + acceptedRevision := strings.TrimSpace(string(acceptedRevisionRaw)) now := time.Unix(1200, 0).UTC() clock := protocolStateClock{now: now} @@ -203,7 +237,7 @@ func TestProgramRuntimeProtocolCommitsDeclaredStateEffectBeforeReceipt(t *testin t.Fatal(err) } configurationEvidence := model.Evidence{Source: "configuration:fixture", Fingerprint: state.ConfigFingerprint, ObservedAt: now} - observer := protocolStateObserver{path: layout.StatePath, invocation: invocation, program: program.Fingerprint(), now: now, configProof: configurationEvidence} + observer := protocolStateObserver{path: layout.StatePath, repository: repository, invocation: invocation, program: program.Fingerprint(), now: now, configProof: configurationEvidence} locker, _ := effects.NewLocker(resolver) journal, _ := effects.NewJournal(resolver, clock) receipts, _ := effects.NewReceiptStore(resolver, clock) @@ -221,8 +255,20 @@ func TestProgramRuntimeProtocolCommitsDeclaredStateEffectBeforeReceipt(t *testin {ID: "human", Class: catalog.AuthorityHuman, Subject: invocation.RepositoryID, Fingerprint: "human", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}, {ID: "repository", Class: catalog.AuthorityRepository, Subject: configurationEvidence.Source, Fingerprint: configurationEvidence.Fingerprint, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}, }} + readme, err := os.ReadFile(filepath.Join(repository, "README.md")) + if err != nil { + t.Fatal(err) + } + bundleSnapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{"README.md": readme}) + if err != nil { + t.Fatal(err) + } + bundle, err := boatstackruntime.NewControlBundleContract(bundleSnapshot, nil, "") + if err != nil { + t.Fatal(err) + } request := engine.ApplyRequest{ - ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: state.Objective, Authority: authority, Requested: "fixture.state.publish"}, + ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: state.Objective, Authority: authority, Requested: "fixture.state.publish", ControlBundle: &bundle, ControlBundleRevision: acceptedRevision}, FlowID: "protocol-state-flow", AdmissionLifetime: time.Minute, } resolution, err := kernel.Resolve(ctx, request.ResolveRequest) @@ -232,6 +278,11 @@ func TestProgramRuntimeProtocolCommitsDeclaredStateEffectBeforeReceipt(t *testin if resolution.Decision.Kind != supervisor.DecisionPrescribed { t.Fatalf("resolution = %#v", resolution.Decision) } + runGit("reset", "--mixed", baseRevision) + if _, prepareErr := driver.Prepare(ctx, resolution.Admission, *resolution.Decision.Transition); prepareErr == nil || !strings.Contains(prepareErr.Error(), "CONTROL_BUNDLE_REVISION_DRIFT") { + t.Fatalf("protocol runtime accepted stale commit-bound bundle: %v", prepareErr) + } + runGit("reset", "--mixed", acceptedRevision) request.Prescription = resolution.Prescription result, err := kernel.Apply(ctx, request) if err != nil {