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 3639404..20a6462 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -63,8 +63,15 @@ 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 { + if suspended, ok := flowCommitRequiredResponse(err, surfaces.OperationResolve); ok { + return renderResponse(suspended, "json") + } return err } if bound.delegationRequestFingerprint == "" || requestFingerprint != bound.delegationRequestFingerprint || bound.runID != options.runID { @@ -251,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") } @@ -261,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 != "" { @@ -278,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) } } @@ -316,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 } @@ -371,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 } @@ -414,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) @@ -462,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) @@ -528,7 +543,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 @@ -563,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 3de2ddd..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,11 +194,59 @@ 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" - 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 || acceptedBundleRevision != "" || options.delegationRequestProjection || delegationRecordPresent) && !installationAuthority { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { return commandOptions{}, resolverErr @@ -205,7 +309,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 +340,94 @@ 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 { + 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 +437,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 +474,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 { @@ -676,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)) @@ -692,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 { @@ -714,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 3a9d7bd..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")) @@ -116,6 +182,131 @@ 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 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...)...) @@ -136,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) @@ -150,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) @@ -1124,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) @@ -1160,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 { @@ -2260,7 +2475,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/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 557ddc0..731b187 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 @@ -91,6 +92,7 @@ type commandOptions struct { workResultFingerprint string controlBundle *boatstackruntime.ControlBundleContract controlBundleFingerprint string + controlBundleRevision string invocationEvidence *invocation.Evidence inputRequest *invocation.InputRequest } @@ -141,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) @@ -156,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 { @@ -179,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) @@ -215,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 @@ -249,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 == "" { @@ -258,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 { @@ -285,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) @@ -299,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 @@ -609,26 +650,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 - } - runtimeRaw, readErr := os.ReadFile(runtimePath) - if readErr != nil { - return readErr + for _, parameter := range current { + if _, ok := parameters.Get(parameter.Name); !ok { + options.parameters = append(options.parameters, parameter.Name+"="+parameter.Value) } - 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 { @@ -737,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 @@ -912,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 d7e6c10..c4bd26d 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -47,7 +47,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) } @@ -74,39 +75,132 @@ 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", - ) + + generatedSkill, err := os.ReadFile(filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + 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) + } + + // `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) + } + invoking, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "read-only-next") + 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} { + 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("initialize: %v\n%s", err, initialize) + t.Fatalf("installation authority suspension: %v\n%s", err, first) } - var initialized surfaces.Response - if err := json.Unmarshal(initialize, &initialized); err != nil { + var installationQuestion surfaces.Response + if err := json.Unmarshal(first, &installationQuestion); err != nil { t.Fatal(err) } - if initialized.Receipt == nil || initialized.Receipt.TransitionID != "installation.initialize" { - t.Fatalf("initialization response = %#v", initialized) + 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) + } + 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) + } + 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", "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"}) + 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", delegated) + 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) + 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{ "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, @@ -118,7 +212,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/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/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/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/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/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/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 { 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..d34006b --- /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. 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.