diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 30026b3..a25bcaf 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -657,6 +657,25 @@ def test_kernel_runtime_has_no_production_consumer_yet(self) -> None: "migration T5 has not connected the generic kernel runtime to production code", ) + def test_invocation_runtime_is_domain_neutral(self) -> None: + invocation = REPO / "boatstack" / "invocation" + files = sorted(invocation.glob("*.go")) + self.assertTrue(files) + self.assertEqual([], domain_vocabulary_hits(files)) + boatstack_packages = "github.com/operatorstack/boatstack/boatstack/" + allowed = { + boatstack_packages + "controlprogram", + boatstack_packages + "invocation", + } + for path, metadata in zip(files, go_source_metadata(files), strict=True): + invalid = [ + import_path + for import_path in metadata["imports"] + if import_path.startswith(boatstack_packages) + and import_path not in allowed + ] + self.assertEqual([], invalid, f"invocation dependency direction: {path}") + def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: documents = [ REPO / "README.md", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87820d3..ac3cd6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: working-directory: boatstack env: BOATSTACK_REQUIRE_FLOW_FRONTEND: '1' - run: go test ./controlprogram -run TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint + run: go test ./controlprogram -run 'TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint|TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime' component: name: component-${{ matrix.name }} diff --git a/boatstack/cmd/boatstack-helper/control_bundle.go b/boatstack/cmd/boatstack-helper/control_bundle.go index 3c95b24..d79ee4a 100644 --- a/boatstack/cmd/boatstack-helper/control_bundle.go +++ b/boatstack/cmd/boatstack-helper/control_bundle.go @@ -251,7 +251,7 @@ func bindControlBundle(ctx context.Context, repository string, transitionID cata if !exists { return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace.cut requires an exact base_ref") } - resolvedRevision, resolveErr := boatstackruntime.ResolveCommitRevision(ctx, repository, baseRef) + resolvedRevision, resolveErr := boatstackruntime.ResolveWorkspaceBaseRevision(ctx, repository, baseRef) if resolveErr != nil { return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: resolve base_ref %q: %w", baseRef, resolveErr) } diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go new file mode 100644 index 0000000..e285dc6 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -0,0 +1,589 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/invocation" +) + +const declarativeRunSchemaRevision = 3 + +type declarativeTransitionReceipt struct { + ID string `json:"id"` + TransitionID string `json:"transition_id"` + InvocationFingerprint string `json:"invocation_fingerprint"` + PriorStateRevision uint64 `json:"prior_state_revision"` + ResultStateRevision uint64 `json:"result_state_revision"` + PriorReceiptFingerprint string `json:"prior_receipt_fingerprint,omitempty"` + Parameters []invocation.ResolvedParameter `json:"parameters"` + HumanActor string `json:"human_actor,omitempty"` + Fingerprint string `json:"fingerprint"` +} + +type declarativeRunState struct { + SchemaRevision int `json:"schema_revision"` + RunID string `json:"run_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ExecutionScopeFingerprint string `json:"execution_scope_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + StateRevision uint64 `json:"state_revision"` + EntryInputs map[string]string `json:"entry_inputs"` + Facts map[string]string `json:"facts"` + Receipts []declarativeTransitionReceipt `json:"receipts"` + Fingerprint string `json:"fingerprint"` +} + +type declarativeRuntimeContext struct { + compiled controlprogram.Compiled + entry controlprogram.Entry + state declarativeRunState + statePath string + store invocation.Store + executionScopeFingerprint string +} + +func tryRunDeclarativeFlow(ctx context.Context, options commandOptions) (bool, error) { + compiled, err := loadCurrentFlowArtifact(ctx, options.repository, options.programID) + if err != nil { + return false, err + } + declarative, err := declarativeFlow(compiled.Document) + if err != nil || !declarative { + return false, err + } + if err := validateDeclarativeFlow(compiled); err != nil { + return true, err + } + return true, runDeclarativeFlow(ctx, compiled, options) +} + +func loadCurrentFlowArtifact(ctx context.Context, repository, programID string) (controlprogram.Compiled, error) { + repository, err := filepath.Abs(repository) + if err != nil { + return controlprogram.Compiled{}, err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return controlprogram.Compiled{}, err + } + raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "flows", programID+".flow.ir.json")) + if err != nil { + return controlprogram.Compiled{}, fmt.Errorf("FLOW_ARTIFACT_REQUIRED: %w", err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + return controlprogram.Compiled{}, err + } + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return controlprogram.Compiled{}, err + } + return controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) +} + +func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, options commandOptions) error { + if len(options.parameters) != 0 { + return fmt.Errorf("FLOW_PARAMETER_BYPASS: repository Flow parameters must come from compiled producer declarations") + } + entry, ok := findEntry(compiled.Document.Entries, options.entryID) + if !ok { + return fmt.Errorf("FLOW_ENTRY_UNKNOWN: %s", options.entryID) + } + repository, err := filepath.Abs(options.repository) + if err != nil { + return err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return err + } + lease, err := boatstackruntime.AcquireFlowProjectionLease(repository) + if err != nil { + return err + } + defer lease.Release() + runtimeContext, err := loadDeclarativeRuntimeContext(ctx, repository, compiled, entry, options) + if err != nil { + return err + } + if predicateSatisfied(targetPredicate(compiled.Document.Targets, entry.Target), runtimeContext.state.Facts) { + return encodeDeclarativeResult(map[string]any{ + "kind": "terminal", "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, + "entry_id": entry.ID, "target_id": entry.Target, "state_revision": runtimeContext.state.StateRevision, + "receipt": lastDeclarativeReceipt(runtimeContext.state), "receipts": runtimeContext.state.Receipts, + }, options.format) + } + frontier := selectDeclarativeTransitions(compiled.Document, runtimeContext.state.Facts) + if len(frontier) == 0 { + return encodeDeclarativeResult(map[string]any{ + "kind": "blocked", "code": "FLOW_NO_ADMISSIBLE_TRANSITION", "run_id": runtimeContext.state.RunID, + "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, + }, options.format) + } + if options.transitionID != "" { + var requested *controlprogram.Transition + for index := range frontier { + if frontier[index].ID == options.transitionID { + requested = &frontier[index] + break + } + } + if requested == nil { + ids := make([]string, len(frontier)) + for index := range frontier { + ids[index] = frontier[index].ID + } + return encodeDeclarativeResult(map[string]any{ + "kind": "blocked", "code": "FLOW_TRANSITION_NOT_ADMISSIBLE", "run_id": runtimeContext.state.RunID, + "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, + "state_revision": runtimeContext.state.StateRevision, "transition_id": options.transitionID, "transitions": ids, + }, options.format) + } + frontier = []controlprogram.Transition{*requested} + } + if len(frontier) != 1 { + ids := make([]string, len(frontier)) + for index := range frontier { + ids[index] = frontier[index].ID + } + return encodeDeclarativeResult(map[string]any{ + "kind": "blocked", "code": "FLOW_SELECTION_AMBIGUOUS", "run_id": runtimeContext.state.RunID, + "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, + "state_revision": runtimeContext.state.StateRevision, "transitions": ids, + }, options.format) + } + transition := frontier[0] + operator, ok := findCompiledOperator(compiled.Document.Operators, transition.Operator) + if !ok { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q has no executable operator", transition.ID) + } + result, materializationContext, err := materializeDeclarativeInvocation(runtimeContext, transition, operator) + if err != nil { + return err + } + if result.Blocker != nil { + return encodeDeclarativeResult(map[string]any{ + "kind": "blocked", "code": result.Blocker.Code, "detail": result.Blocker.Detail, + "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, + }, options.format) + } + if result.Request != nil { + if err := runtimeContext.store.SaveRequest(*result.Request); err != nil { + return err + } + return encodeDeclarativeResult(map[string]any{ + "kind": "suspended", "code": result.Request.Code, "run_id": runtimeContext.state.RunID, + "program_fingerprint": compiled.Fingerprint, "entry_id": entry.ID, "target_id": entry.Target, + "transition_id": transition.ID, "request": result.Request, + }, options.format) + } + if result.Ready == nil { + return fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: declarative materialization produced no evidence") + } + if err := requireDeclarativeAuthority(transition, operator, options.humanActor); err != nil { + return encodeDeclarativeResult(map[string]any{ + "kind": "blocked", "code": "AUTHORITY_REQUIRED", "detail": err.Error(), + "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, + "entry_id": entry.ID, "target_id": entry.Target, "transition_id": transition.ID, + }, options.format) + } + + // Re-read receipts and rematerialize while the run lock is held. The first + // result is a candidate; only this current evidence may cross the state + // mutation boundary. + materializationContext.InputReceipts, err = runtimeContext.store.LoadReceipts(runtimeContext.state.RunID, transition.ID) + if err != nil { + return err + } + fresh, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil) + if err != nil { + return err + } + if fresh.Ready == nil || fresh.Ready.InvocationFingerprint != result.Ready.InvocationFingerprint { + return fmt.Errorf("INVOCATION_DRIFT: invocation changed before declarative state effect") + } + priorRevision := runtimeContext.state.StateRevision + candidate := runtimeContext.state + if err := applyDeclarativeAssignments(&candidate, *operator.StateEffect, fresh.Ready.Parameters); err != nil { + return err + } + if !predicateSatisfied(transition.Target, candidate.Facts) { + return fmt.Errorf("DECLARATIVE_VERIFICATION_FAILED: transition %q did not establish its compiled target", transition.ID) + } + candidate.StateRevision++ + receipt := declarativeTransitionReceipt{ + TransitionID: transition.ID, InvocationFingerprint: fresh.Ready.InvocationFingerprint, + PriorStateRevision: priorRevision, ResultStateRevision: candidate.StateRevision, + Parameters: append([]invocation.ResolvedParameter(nil), fresh.Ready.Parameters...), HumanActor: strings.TrimSpace(options.humanActor), + } + if previous := lastDeclarativeReceipt(candidate); previous != nil { + receipt.PriorReceiptFingerprint = previous.Fingerprint + } + receipt.ID = "receipt-" + digestDeclarative(receipt)[:24] + receipt.Fingerprint = digestDeclarative(receipt) + candidate.Receipts = append(append([]declarativeTransitionReceipt(nil), candidate.Receipts...), receipt) + if err := saveDeclarativeRun(runtimeContext.statePath, candidate); err != nil { + return err + } + runtimeContext.state = candidate + terminal := predicateSatisfied(targetPredicate(compiled.Document.Targets, entry.Target), runtimeContext.state.Facts) + kind := "continued" + if terminal { + kind = "terminal" + } + return encodeDeclarativeResult(map[string]any{ + "kind": kind, "run_id": runtimeContext.state.RunID, "program_fingerprint": compiled.Fingerprint, + "entry_id": entry.ID, "target_id": entry.Target, "transition_id": transition.ID, + "state_revision": runtimeContext.state.StateRevision, "invocation": fresh.Ready, + "receipt": lastDeclarativeReceipt(runtimeContext.state), "receipts": runtimeContext.state.Receipts, + }, options.format) +} + +func requireDeclarativeAuthority(transition controlprogram.Transition, operator controlprogram.Operator, humanActor string) error { + providedHuman := strings.TrimSpace(humanActor) != "" + if len(operator.Authority.AnyOf) != 0 && !providedHuman { + return fmt.Errorf("operator %q requires one of %s", operator.ID, strings.Join(operator.Authority.AnyOf, ", ")) + } + if containsString(operator.Authority.AllOf, "human") && !providedHuman { + return fmt.Errorf("operator %q requires human authority", operator.ID) + } + if containsString(transition.Requires.Authorities, "human") && !providedHuman { + return fmt.Errorf("transition %q requires human authority", transition.ID) + } + return nil +} + +func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions) (declarativeRuntimeContext, error) { + resolver, err := plant.NewResolver("") + if err != nil { + return declarativeRuntimeContext{}, err + } + host := options.host + if host == "" { + host = "cli" + } + invoking, err := resolver.ResolveInvocation(ctx, repository, host, "declarative-flow") + if err != nil { + return declarativeRuntimeContext{}, err + } + layout, invoking, err := resolver.ResolveLayout(ctx, invoking) + if err != nil { + return declarativeRuntimeContext{}, err + } + scope, err := flowExecutionScopeFingerprint(invoking) + if err != nil { + return declarativeRuntimeContext{}, err + } + provided, err := parseNamedValues(options.entryInputs) + if err != nil { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) + } + runID := options.runID + if runID == "" { + if err := validateDeclarativeEntryInputs(entry, provided, true); err != nil { + return declarativeRuntimeContext{}, err + } + runID = declarativeRunID(scope, compiled.Fingerprint, entry.ID, provided) + } else if !flowSegment.MatchString(runID) { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: run identity is invalid") + } + statePath := filepath.Join(layout.FlowRoot, "declarative", compiled.Document.Program.ID, entry.ID, runID+".json") + state, err := loadDeclarativeRun(statePath) + if os.IsNotExist(err) { + if len(provided) == 0 { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_INPUT_REQUIRED: a new declarative run requires its entry inputs") + } + state = declarativeRunState{SchemaRevision: declarativeRunSchemaRevision, RunID: runID, ProgramFingerprint: compiled.Fingerprint, ExecutionScopeFingerprint: scope, EntryID: entry.ID, TargetID: entry.Target, StateRevision: 1, EntryInputs: provided, Facts: map[string]string{}, Receipts: []declarativeTransitionReceipt{}} + if err := saveDeclarativeRun(statePath, state); err != nil { + return declarativeRuntimeContext{}, err + } + } else if err != nil { + return declarativeRuntimeContext{}, err + } + if state.SchemaRevision != declarativeRunSchemaRevision || state.RunID != runID || state.ProgramFingerprint != compiled.Fingerprint || state.ExecutionScopeFingerprint != scope || len(state.ExecutionScopeFingerprint) != 64 || state.EntryID != entry.ID || state.TargetID != entry.Target || state.StateRevision == 0 || state.EntryInputs == nil || state.Facts == nil || state.Receipts == nil { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative run identity or schema changed") + } + expectedRevision := uint64(1) + priorReceiptFingerprint := "" + for index := range state.Receipts { + receipt := state.Receipts[index] + fingerprint := receipt.Fingerprint + receipt.Fingerprint = "" + if fingerprint == "" || fingerprint != digestDeclarative(receipt) || receipt.PriorReceiptFingerprint != priorReceiptFingerprint || receipt.PriorStateRevision != expectedRevision || receipt.ResultStateRevision != expectedRevision+1 { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative transition receipt is invalid") + } + expectedRevision = receipt.ResultStateRevision + priorReceiptFingerprint = fingerprint + } + if state.StateRevision != expectedRevision { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative receipt history does not match state revision") + } + if len(provided) != 0 && !equalStringMaps(provided, state.EntryInputs) { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: entry inputs changed across the run") + } + if err := validateDeclarativeEntryInputs(entry, state.EntryInputs, false); err != nil { + return declarativeRuntimeContext{}, err + } + return declarativeRuntimeContext{ + compiled: compiled, entry: entry, state: state, statePath: statePath, + store: invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, executionScopeFingerprint: scope, + }, nil +} + +func materializeDeclarativeInvocation(runtimeContext declarativeRuntimeContext, transition controlprogram.Transition, operator controlprogram.Operator) (invocation.Result, invocation.Context, error) { + entryInputs := map[string]invocation.Value{} + for _, input := range runtimeContext.entry.Inputs { + value, ok := runtimeContext.state.EntryInputs[input.ID] + if ok { + entryInputs[input.ID] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value, Provenance: "entry-input", ProducerFingerprint: digestDeclarative(input)} + } + } + stateValues := map[string]invocation.Value{} + for facet, value := range runtimeContext.state.Facts { + stateValues[facet] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value, Provenance: "state", ProducerFingerprint: digestDeclarative(map[string]string{"facet": facet, "value": value})} + } + contextFingerprint := digestDeclarative(struct { + RunID string `json:"run_id"` + Program string `json:"program"` + Entry string `json:"entry"` + Target string `json:"target"` + Transition string `json:"transition"` + StateRevision uint64 `json:"state_revision"` + Inputs map[string]string `json:"inputs"` + Facts map[string]string `json:"facts"` + }{runtimeContext.state.RunID, runtimeContext.compiled.Fingerprint, runtimeContext.entry.ID, runtimeContext.entry.Target, transition.ID, runtimeContext.state.StateRevision, runtimeContext.state.EntryInputs, runtimeContext.state.Facts}) + receipts, err := runtimeContext.store.LoadReceipts(runtimeContext.state.RunID, transition.ID) + if err != nil { + return invocation.Result{}, invocation.Context{}, err + } + materializationContext := invocation.Context{ + RunID: runtimeContext.state.RunID, ProgramFingerprint: runtimeContext.compiled.Fingerprint, + ExecutionProgramFingerprint: runtimeContext.compiled.Fingerprint, + EntryID: runtimeContext.entry.ID, TargetID: runtimeContext.entry.Target, TransitionID: transition.ID, + StateRevision: runtimeContext.state.StateRevision, ContextFingerprint: contextFingerprint, + ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, EntryInputs: entryInputs, + State: stateValues, Receipts: map[string]invocation.Value{}, WorkOutputs: map[string]invocation.Value{}, InputReceipts: receipts, + } + result, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil) + return result, materializationContext, err +} + +func selectDeclarativeTransitions(document controlprogram.Document, facts map[string]string) []controlprogram.Transition { + frontier := []controlprogram.Transition{} + bestPriority := 0 + for _, transition := range document.Transitions { + if !predicateSatisfied(transition.Guard, facts) || predicateSatisfied(transition.Target, facts) { + continue + } + if len(frontier) == 0 || transition.Priority < bestPriority { + frontier = []controlprogram.Transition{transition} + bestPriority = transition.Priority + continue + } + if transition.Priority == bestPriority { + frontier = append(frontier, transition) + } + } + sort.Slice(frontier, func(i, j int) bool { return frontier[i].ID < frontier[j].ID }) + return frontier +} + +func lastDeclarativeReceipt(state declarativeRunState) *declarativeTransitionReceipt { + if len(state.Receipts) == 0 { + return nil + } + receipt := state.Receipts[len(state.Receipts)-1] + return &receipt +} + +func predicateSatisfied(predicate controlprogram.Predicate, facts map[string]string) bool { + if predicate.True != nil { + return *predicate.True + } + if predicate.Fact != nil { + value, known := facts[predicate.Fact.Facet] + status := "absent" + if known { + status = "known" + } + if len(predicate.Fact.Statuses) != 0 && !containsString(predicate.Fact.Statuses, status) { + return false + } + return len(predicate.Fact.Values) == 0 || (known && containsString(predicate.Fact.Values, value)) + } + if len(predicate.All) != 0 { + for _, child := range predicate.All { + if !predicateSatisfied(child, facts) { + return false + } + } + return true + } + if len(predicate.Any) != 0 { + for _, child := range predicate.Any { + if predicateSatisfied(child, facts) { + return true + } + } + return false + } + return predicate.Not != nil && !predicateSatisfied(*predicate.Not, facts) +} + +func targetPredicate(targets []controlprogram.Target, targetID string) controlprogram.Predicate { + for _, target := range targets { + if target.ID == targetID { + return target.Predicate + } + } + return controlprogram.Predicate{} +} + +func applyDeclarativeAssignments(state *declarativeRunState, effect controlprogram.StateEffect, parameters []invocation.ResolvedParameter) error { + values := map[string]string{} + for _, parameter := range parameters { + values[parameter.Name] = parameter.Value + } + for _, precondition := range effect.Preconditions { + if !containsString(precondition.Values, state.Facts[precondition.Facet]) { + return fmt.Errorf("INVOCATION_DRIFT: state precondition %q changed before effect", precondition.Facet) + } + } + for _, assignment := range effect.Assignments { + if assignment.Value != nil { + state.Facts[assignment.Facet] = *assignment.Value + continue + } + if assignment.ValueFrom == nil || assignment.ValueFrom.Parameter == "" { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative assignment %q requires a literal or invocation parameter", assignment.Facet) + } + value, ok := values[assignment.ValueFrom.Parameter] + if !ok { + return fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: assignment parameter %q is absent", assignment.ValueFrom.Parameter) + } + state.Facts[assignment.Facet] = value + } + return nil +} + +func validateDeclarativeEntryInputs(entry controlprogram.Entry, values map[string]string, creating bool) error { + declared := map[string]controlprogram.EntryInput{} + for _, input := range entry.Inputs { + declared[input.ID] = input + if input.Required && strings.TrimSpace(values[input.ID]) == "" { + return fmt.Errorf("FLOW_INPUT_REQUIRED: entry %q requires input %q", entry.ID, input.ID) + } + } + for id, value := range values { + if _, ok := declared[id]; !ok || strings.TrimSpace(value) == "" { + return fmt.Errorf("FLOW_INPUT_INVALID: entry %q does not declare non-empty input %q", entry.ID, id) + } + } + _ = creating + return nil +} + +func parseNamedValues(values []string) (map[string]string, error) { + result := map[string]string{} + for _, item := range values { + name, value, ok := strings.Cut(item, "=") + if !ok || name == "" || value == "" || result[name] != "" { + return nil, fmt.Errorf("entry inputs require unique name=value pairs") + } + result[name] = value + } + return result, nil +} + +func declarativeRunID(scope, program, entry string, inputs map[string]string) string { + return "run-" + digestDeclarative(struct { + Scope string `json:"scope"` + Program string `json:"program"` + Entry string `json:"entry"` + Inputs map[string]string `json:"inputs"` + }{scope, program, entry, inputs})[:32] +} + +func loadDeclarativeRun(path string) (declarativeRunState, error) { + raw, err := os.ReadFile(path) + if err != nil { + return declarativeRunState{}, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var state declarativeRunState + if err := decoder.Decode(&state); err != nil { + return declarativeRunState{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return declarativeRunState{}, fmt.Errorf("declarative run contains trailing JSON") + } + identity := state + identity.Fingerprint = "" + if state.Fingerprint == "" || state.Fingerprint != digestDeclarative(identity) { + return declarativeRunState{}, fmt.Errorf("declarative run failed content identity verification") + } + return state, nil +} + +func saveDeclarativeRun(path string, state declarativeRunState) error { + state.Fingerprint = "" + state.Fingerprint = digestDeclarative(state) + raw, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return effects.NewRuntimeStore().WriteAtomic(path, append(raw, '\n'), 0o600) +} + +func encodeDeclarativeResult(value any, format string) error { + if format != "json" { + return fmt.Errorf("declarative Flow driver requires --format json") + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func digestDeclarative(value any) string { + raw, _ := json.Marshal(value) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func equalStringMaps(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + +func containsString(values []string, value string) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + return false +} diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go new file mode 100644 index 0000000..6941b9e --- /dev/null +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -0,0 +1,418 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" +) + +func declarativeInvocationDocument() controlprogram.Document { + truth := true + mitigated := "mitigated" + return controlprogram.Document{ + Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, + Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"}, + Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}}, + Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "mitigated"}}}, + Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}}, + Operators: []controlprogram.Operator{{ + ID: "restart", Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, Verifier: "state-effect", ExecutionContext: "preserve", + Parameters: []controlprogram.OperatorParameter{ + {ID: "incident", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceEntryInput}}, + {ID: "channel", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceHostInput}, Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}}, + }, + StateEffect: &controlprogram.StateEffect{Kind: "assignments", Assignments: []controlprogram.StateAssignment{{Facet: "incident", Value: &mitigated}}}, + }}, + Transitions: []controlprogram.Transition{{ + ID: "restart", Operator: "restart", Guard: controlprogram.Predicate{True: &truth}, Target: flowFact("incident", "mitigated"), Priority: 10, + Parameters: []controlprogram.TransitionParameterBinding{ + {Parameter: "incident", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "incident"}}, + {Parameter: "channel", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ID: "channel", Description: "Select the response channel.", Authorities: []string{"human"}, Scope: "transition"}}}, + }, + }}, + Targets: []controlprogram.Target{{ID: "mitigated", Predicate: flowFact("incident", "mitigated")}}, + Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated", Inputs: []controlprogram.EntryInput{{ID: "incident", Type: "string", Required: true}}}}, + } +} + +func declarativeFlowRepository(t *testing.T) string { + return declarativeFlowRepositoryWithDocument(t, declarativeInvocationDocument()) +} + +func declarativeFlowRepositoryWithDocument(t *testing.T, document controlprogram.Document) string { + t.Helper() + repository := t.TempDir() + runFlowGit(t, repository, "init", "-b", "main") + runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") + runFlowGit(t, repository, "config", "user.name", "Fixture") + runFlowGit(t, repository, "config", "core.autocrlf", "false") + sourcePath, lockPath := ".boatstack/flows/incident-response-invocation.flow.ts", "package-lock.json" + source, lock := []byte("declarative flow source\n"), []byte("lock\n") + writeFixture(t, repository, sourcePath, source) + writeFixture(t, repository, lockPath, lock) + writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, lock) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-m", "fixture") + return repository +} + +func twoStepDeclarativeDocument() controlprogram.Document { + truth := true + contained, mitigated := "contained", "mitigated" + return controlprogram.Document{ + Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, + Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"}, + Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}}, + Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "contained", "mitigated"}}}, + Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}}, + Operators: []controlprogram.Operator{ + {ID: "contain", Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, Verifier: "state-effect", ExecutionContext: "preserve", StateEffect: &controlprogram.StateEffect{Kind: "assignments", Assignments: []controlprogram.StateAssignment{{Facet: "incident", Value: &contained}}}}, + {ID: "mitigate", Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, Verifier: "state-effect", ExecutionContext: "preserve", StateEffect: &controlprogram.StateEffect{Kind: "assignments", Assignments: []controlprogram.StateAssignment{{Facet: "incident", Value: &mitigated}}}}, + }, + Transitions: []controlprogram.Transition{ + {ID: "contain", Operator: "contain", Guard: controlprogram.Predicate{True: &truth}, Target: flowFact("incident", "contained"), Priority: 10}, + {ID: "mitigate", Operator: "mitigate", Guard: flowFact("incident", "contained"), Target: flowFact("incident", "mitigated"), Priority: 20}, + }, + Targets: []controlprogram.Target{{ID: "mitigated", Predicate: flowFact("incident", "mitigated")}}, + Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated", Inputs: []controlprogram.EntryInput{{ID: "incident", Type: "string", Required: true}}}}, + } +} + +func decodeObject(t *testing.T, raw []byte) map[string]any { + t.Helper() + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode output: %v\n%s", err, raw) + } + return value +} + +func TestGeneratedDeclarativeDriverSuspendsAnswersRestartsAndExecutes(t *testing.T) { + // control-law: a non-domain adapter generated driver crosses the same + // typed invocation boundary and resumes one exact run after restart. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + repository := declarativeFlowRepository(t) + skillPath := filepath.Join(repository, ".agents", "skills", "incident-response-invocation-respond", "SKILL.md") + skill, err := os.ReadFile(skillPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(skill), "boatstack flow run --repo . --flow incident-response-invocation --entry respond --host codex --format json") || !strings.Contains(string(skill), "--input name=value") { + t.Fatalf("generated driver lacks declarative invocation protocol:\n%s", skill) + } + + suspendedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--input", "incident=INC-7", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + suspended := decodeObject(t, suspendedRaw) + if suspended["kind"] != "suspended" || suspended["code"] != "TRANSITION_INPUT_REQUIRED" { + t.Fatalf("first driver result = %s", suspendedRaw) + } + runID, _ := suspended["run_id"].(string) + request, _ := suspended["request"].(map[string]any) + requestFingerprint, _ := request["fingerprint"].(string) + if !strings.HasPrefix(runID, "run-") || len(requestFingerprint) != 64 { + t.Fatalf("suspension identity = %#v", suspended) + } + + answer := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answer, []byte(`{"channel":"incident-room"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { + return runFlowInput([]string{"answer", "--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--request-fingerprint", requestFingerprint, "--answer", answer, "--human", "boateng", "--host", "codex", "--format", "json"}) + }); err != nil { + t.Fatal(err) + } + + blockedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + blocked := decodeObject(t, blockedRaw) + if blocked["kind"] != "blocked" || blocked["code"] != "AUTHORITY_REQUIRED" { + t.Fatalf("unauthorized driver result = %s", blockedRaw) + } + + completedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--human", "boateng", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + completed := decodeObject(t, completedRaw) + if completed["kind"] != "terminal" || completed["run_id"] != runID || completed["transition_id"] != "restart" || completed["invocation"] == nil || completed["receipt"] == nil { + t.Fatalf("resumed driver result = %s", completedRaw) + } + replayedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + replayed := decodeObject(t, replayedRaw) + if replayed["kind"] != "terminal" || replayed["receipt"] == nil || !reflect.DeepEqual(completed["receipt"], replayed["receipt"]) { + t.Fatalf("terminal replay lost its durable receipt:\ncompleted=%s\nreplayed=%s", completedRaw, replayedRaw) + } +} + +func TestDeclarativeRunRejectsCrossWorktreeResume(t *testing.T) { + // control-law: an explicit run ID cannot bypass the opaque execution-scope + // identity that was bound when the durable declarative run was created. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + repository := declarativeFlowRepository(t) + suspendedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--input", "incident=INC-7", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + suspended := decodeObject(t, suspendedRaw) + runID, _ := suspended["run_id"].(string) + request := suspended["request"] + otherWorktree := filepath.Join(t.TempDir(), "other-worktree") + runFlowGit(t, repository, "worktree", "add", "-q", "-b", "other-worktree", otherWorktree) + if _, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", otherWorktree, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }); err == nil || !strings.Contains(err.Error(), "FLOW_CONTEXT_MISMATCH") { + t.Fatalf("cross-worktree resume = %v", err) + } + replayedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + replayed := decodeObject(t, replayedRaw) + if replayed["kind"] != "suspended" || !reflect.DeepEqual(request, replayed["request"]) { + t.Fatalf("cross-scope refusal changed the originating run:\nbefore=%s\nafter=%s", suspendedRaw, replayedRaw) + } +} + +func TestDeclarativeSelectionPreservesEqualPriorityFrontier(t *testing.T) { + // control-law: equally preferred transitions are an explicit frontier, not + // an ID-based mutation choice. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + document := declarativeInvocationDocument() + alternate := document.Transitions[0] + alternate.ID = "alternate-restart" + document.Transitions = append(document.Transitions, alternate) + repository := declarativeFlowRepositoryWithDocument(t, document) + blockedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--input", "incident=INC-7", "--human", "boateng", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + blocked := decodeObject(t, blockedRaw) + if blocked["kind"] != "blocked" || blocked["code"] != "FLOW_SELECTION_AMBIGUOUS" || blocked["state_revision"] != float64(1) { + t.Fatalf("ambiguous selection = %s", blockedRaw) + } + want := []any{"alternate-restart", "restart"} + if !reflect.DeepEqual(blocked["transitions"], want) { + t.Fatalf("frontier = %#v, want %#v", blocked["transitions"], want) + } + runID, _ := blocked["run_id"].(string) + replayedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--human", "boateng", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + if replayed := decodeObject(t, replayedRaw); replayed["code"] != "FLOW_SELECTION_AMBIGUOUS" || replayed["state_revision"] != float64(1) { + t.Fatalf("ambiguous replay mutated state = %s", replayedRaw) + } +} + +func TestDeclarativeRunRefusesTargetOutsideBestPriorityFrontier(t *testing.T) { + // control-law: an explicit transition request narrows the current trusted + // frontier; it cannot select a lower-priority mutation. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + document := declarativeInvocationDocument() + alternate := document.Transitions[0] + alternate.ID = "alternate-restart" + alternate.Priority = 20 + document.Transitions = append(document.Transitions, alternate) + repository := declarativeFlowRepositoryWithDocument(t, document) + blockedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--transition", "alternate-restart", "--input", "incident=INC-7", "--human", "boateng", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + blocked := decodeObject(t, blockedRaw) + if blocked["kind"] != "blocked" || blocked["code"] != "FLOW_TRANSITION_NOT_ADMISSIBLE" || blocked["transition_id"] != "alternate-restart" || blocked["state_revision"] != float64(1) { + t.Fatalf("targeted selection = %s", blockedRaw) + } + if !reflect.DeepEqual(blocked["transitions"], []any{"restart"}) || blocked["receipt"] != nil { + t.Fatalf("targeted refusal did not preserve the frontier: %s", blockedRaw) + } + runID, _ := blocked["run_id"].(string) + suspendedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + suspended := decodeObject(t, suspendedRaw) + if suspended["kind"] != "suspended" || suspended["transition_id"] != "restart" { + t.Fatalf("untargeted continuation after refusal = %s", suspendedRaw) + } +} + +func TestDeclarativeReceiptHistoryIsImmutableAndContiguous(t *testing.T) { + // control-law: every accepted declarative transition remains recoverable as + // one contiguous durable receipt chain after later commits and restart. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + repository := declarativeFlowRepositoryWithDocument(t, twoStepDeclarativeDocument()) + firstRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--input", "incident=INC-7", "--human", "boateng", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + first := decodeObject(t, firstRaw) + if first["kind"] != "continued" { + t.Fatalf("first transition = %s", firstRaw) + } + runID, _ := first["run_id"].(string) + firstReceipt := first["receipt"] + secondRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--human", "boateng", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + second := decodeObject(t, secondRaw) + receipts, _ := second["receipts"].([]any) + if second["kind"] != "terminal" || len(receipts) != 2 || !reflect.DeepEqual(receipts[0], firstReceipt) { + t.Fatalf("second transition lost receipt history:\nfirst=%s\nsecond=%s", firstRaw, secondRaw) + } + prior := receipts[0].(map[string]any) + latest := receipts[1].(map[string]any) + if latest["prior_receipt_fingerprint"] != prior["fingerprint"] || latest["prior_state_revision"] != prior["result_state_revision"] { + t.Fatalf("receipt chain is not contiguous: %#v", receipts) + } + replayedRaw, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--run-id", runID, "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + replayed := decodeObject(t, replayedRaw) + if replayed["kind"] != "terminal" || !reflect.DeepEqual(replayed["receipts"], second["receipts"]) || !reflect.DeepEqual(replayed["receipt"], second["receipt"]) { + t.Fatalf("restart changed durable receipt history:\nsecond=%s\nreplayed=%s", secondRaw, replayedRaw) + } +} + +func TestDeclarativeRuntimeRejectsSemanticsItCannotProve(t *testing.T) { + open := "open" + for name, test := range map[string]struct { + mutate func(*controlprogram.Document) + witness string + }{ + "unsupported-verifier": {func(document *controlprogram.Document) { + document.Declarations.Verifiers = []string{"healthcheck"} + document.Evidence = []controlprogram.Evidence{{ID: "healthcheck", Subject: "incident", Kind: "observation"}} + document.Operators[0].Verifier = "healthcheck" + }, "effect-free"}, + "unsupported-authority": {func(document *controlprogram.Document) { + document.Declarations.Authorities = append(document.Declarations.Authorities, "autonomy") + document.Operators[0].Authority = controlprogram.AuthorityRequirement{AnyOf: []string{"autonomy"}} + }, "unsupported authority"}, + "unsupported-parameter-authority": {func(document *controlprogram.Document) { + document.Declarations.Authorities = append(document.Declarations.Authorities, "autonomy") + document.Operators[0].Parameters[1].Authority = controlprogram.AuthorityRequirement{AnyOf: []string{"autonomy"}} + document.Transitions[0].Parameters[1].Producer.Request.Authorities = []string{"autonomy"} + }, "parameter \"channel\" uses unsupported authority"}, + "unsupported-host-input-authority": {func(document *controlprogram.Document) { + document.Declarations.Authorities = append(document.Declarations.Authorities, "autonomy") + document.Operators[0].Parameters[1].Authority = controlprogram.AuthorityRequirement{} + document.Transitions[0].Parameters[1].Producer.Request.Authorities = []string{"autonomy"} + }, "host-input parameter \"channel\" uses unsupported authority"}, + "unproved-precondition": {func(document *controlprogram.Document) { + document.Operators[0].StateEffect.Preconditions = []controlprogram.StatePrecondition{{Facet: "incident", Values: []string{"open"}}} + }, "does not establish precondition"}, + "unsupported-assignment-source": {func(document *controlprogram.Document) { + document.Operators[0].StateEffect.Assignments[0] = controlprogram.StateAssignment{Facet: "incident", ValueFrom: &controlprogram.ValueReference{Admission: "id"}} + }, "unsupported value source"}, + "unproved-enum-parameter": {func(document *controlprogram.Document) { + document.Operators[0].StateEffect.Assignments[0] = controlprogram.StateAssignment{Facet: "incident", ValueFrom: &controlprogram.ValueReference{Parameter: "channel"}} + }, "cannot prove parameter"}, + "string-parameter-to-boolean-facet": {func(document *controlprogram.Document) { + document.Facets = append(document.Facets, controlprogram.Facet{ID: "approved", Kind: "boolean"}) + document.Operators[0].StateEffect.Assignments = append(document.Operators[0].StateEffect.Assignments, + controlprogram.StateAssignment{Facet: "approved", ValueFrom: &controlprogram.ValueReference{Parameter: "channel"}}) + }, "cannot prove parameter"}, + "invalid-boolean-literal": {func(document *controlprogram.Document) { + invalid := "not-a-boolean" + document.Facets = append(document.Facets, controlprogram.Facet{ID: "approved", Kind: "boolean"}) + document.Operators[0].StateEffect.Assignments = append(document.Operators[0].StateEffect.Assignments, + controlprogram.StateAssignment{Facet: "approved", Value: &invalid}) + }, "outside the facet type"}, + "target-not-established": {func(document *controlprogram.Document) { + document.Operators[0].StateEffect.Assignments[0] = controlprogram.StateAssignment{Facet: "incident", Value: &open} + }, "do not establish its target"}, + "parameter-mutation-cannot-inherit-guard": {func(document *controlprogram.Document) { + done := "yes" + document.Facets[0].Kind, document.Facets[0].Values = "string", nil + document.Facets = append(document.Facets, controlprogram.Facet{ID: "done", Kind: "enum", Values: []string{"no", "yes"}}) + document.Operators[0].StateEffect.Assignments = []controlprogram.StateAssignment{ + {Facet: "incident", ValueFrom: &controlprogram.ValueReference{Parameter: "channel"}}, + {Facet: "done", Value: &done}, + } + document.Transitions[0].Guard = flowFact("incident", "open") + document.Transitions[0].Target = controlprogram.Predicate{All: []controlprogram.Predicate{flowFact("incident", "open"), flowFact("done", "yes")}} + document.Targets[0].Predicate = document.Transitions[0].Target + }, "do not establish its target"}, + "non-positive-priority": {func(document *controlprogram.Document) { + document.Transitions[0].Priority = 0 + }, "priority must be positive"}, + } { + t.Run(name, func(t *testing.T) { + document := declarativeInvocationDocument() + test.mutate(&document) + compiled, err := controlprogram.Compile(document, nil) + if err == nil { + err = validateDeclarativeFlow(compiled) + } + if err == nil || !strings.Contains(err.Error(), test.witness) { + t.Fatalf("validation = %v, want %q", err, test.witness) + } + }) + } +} + +func TestDeclarativeRuntimeRejectsTypedAssignmentBeforeStateOrReceipt(t *testing.T) { + stateRoot := t.TempDir() + t.Setenv("BOATSTACK_STATE_ROOT", stateRoot) + document := declarativeInvocationDocument() + document.Facets = append(document.Facets, controlprogram.Facet{ID: "approved", Kind: "boolean"}) + document.Operators[0].StateEffect.Assignments = append(document.Operators[0].StateEffect.Assignments, + controlprogram.StateAssignment{Facet: "approved", ValueFrom: &controlprogram.ValueReference{Parameter: "channel"}}) + repository := declarativeFlowRepositoryWithDocument(t, document) + + _, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "incident-response-invocation", "--entry", "respond", "--input", "incident=INC-7", "--host", "codex", "--format", "json"}) + }) + if err == nil || !strings.Contains(err.Error(), "cannot prove parameter \"channel\" belongs to the facet") { + t.Fatalf("run validation = %v", err) + } + entries, readErr := os.ReadDir(stateRoot) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + t.Fatalf("invalid Flow created controller state or receipts: %#v", entries) + } +} diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index f90f244..3639404 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -102,11 +102,20 @@ func runFlowAuthorize(arguments []string) error { return loadErr } now := time.Now().UTC() - record, changed, err := authorizeDelegation(existing, bound.delegationRequest, requestFingerprint, options.humanActor, expiresIn, now) + record, changed, err := authorizeDelegation(existing, bound.delegationRequest, requestFingerprint, options.humanActor, expiresIn, now, bound.delegationReprojection) if err != nil { return err } if changed { + if existing != nil && existing.RequestFingerprint != record.RequestFingerprint { + archivePath, archivePathErr := delegation.SupersededPath(layout.FlowRoot, bound.runID, existing.RequestFingerprint) + if archivePathErr != nil { + return archivePathErr + } + if archiveErr := effects.ArchiveDelegationRecord(archivePath, *existing); archiveErr != nil { + return archiveErr + } + } if err := effects.StoreDelegationRecord(recordPath, record); err != nil { return err } @@ -114,11 +123,30 @@ func runFlowAuthorize(arguments []string) error { return printDelegationRecord(record) } -func authorizeDelegation(existing *delegation.Record, request delegation.Request, requestFingerprint, actor string, expiresIn time.Duration, now time.Time) (delegation.Record, bool, error) { +func authorizeDelegation(existing *delegation.Record, request delegation.Request, requestFingerprint, actor string, expiresIn time.Duration, now time.Time, allowReprojection bool) (delegation.Record, bool, error) { if expiresIn < 0 { return delegation.Record{}, false, fmt.Errorf("flow authorize --expires-in cannot be negative") } + computedFingerprint, err := request.Fingerprint() + if err != nil || computedFingerprint != requestFingerprint { + return delegation.Record{}, false, fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request") + } if existing != nil { + if allowReprojection && existing.RequestFingerprint != requestFingerprint { + if existing.Status != "active" && existing.Status != "revoked" { + return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: reconciled run authorization is %s", existing.Status) + } + record := delegation.Record{ + Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, + Request: request, RequestFingerprint: requestFingerprint, + ReceiptID: authorizationReceiptID(requestFingerprint, actor, existing.Revision+1, now), Actor: actor, + AuthorizedAt: now, Revision: existing.Revision + 1, Status: "active", + } + if expiresIn > 0 { + record.ExpiresAt = now.Add(expiresIn) + } + return record, true, nil + } if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.Status != "active" { return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status") } @@ -226,6 +254,12 @@ func runFlowContinuation(arguments []string) error { if options.programID == "" || options.entryID == "" { return fmt.Errorf("flow run requires --flow and --entry") } + if handled, declarativeErr := tryRunDeclarativeFlow(context.Background(), options); handled || declarativeErr != nil { + return declarativeErr + } + if len(options.entryInputs) != 0 { + return fmt.Errorf("FLOW_INPUT_INVALID: --input is available only to declarative Flow entries") + } var response surfaces.Response for step := 0; step < 256; step++ { response, err = executeContinuationStep(context.Background(), options) @@ -260,6 +294,13 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } + programChangeResponse, err := preflightDelegatedProgramChange(ctx, resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if programChangeResponse != nil { + return *programChangeResponse, nil + } _, delegationResponse, err := prepareDelegation(ctx, &resolveRequest) if err != nil { return surfaces.Response{}, err @@ -284,6 +325,9 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } + if err == nil { + resolveRequest, resolved, _, err = stabilizeRepositoryPrescription(ctx, resolveRequest, resolved) + } if err != nil || resolved.Prescription == nil { if err != nil { return resolved, err @@ -305,6 +349,13 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } + programChangeResponse, err = preflightDelegatedProgramChange(ctx, resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if programChangeResponse != nil { + return *programChangeResponse, nil + } _, delegationResponse, err = prepareDelegation(ctx, &resolveRequest) if err != nil { return surfaces.Response{}, err @@ -340,6 +391,19 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if resolved.Admission != nil { applyRequest.IdempotencyKey = resolved.Admission.IdempotencyKey } + lease, err := acquireFlowExecutionLease(applyRequest) + if err != nil { + return surfaces.Response{}, err + } + defer lease.Release() + applyRequest.Parameters, applyRequest.InvocationEvidence, applyRequest.InputRequest = nil, nil, nil + applyRequest, err = bindRPCFlowEntry(ctx, applyRequest) + if err != nil { + return surfaces.Response{}, err + } + if applyRequest.InputRequest != nil { + return surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, ProgramID: applyRequest.ProgramID, EntryID: applyRequest.EntryID, RunID: applyRequest.FlowID, InputRequest: applyRequest.InputRequest}, nil + } delegationLock, delegationResponse, err := prepareDelegation(ctx, &applyRequest) if err != nil { return surfaces.Response{}, err @@ -350,11 +414,6 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if delegationResponse != nil { return *delegationResponse, nil } - lease, err := acquireFlowExecutionLease(applyRequest) - if err != nil { - return surfaces.Response{}, err - } - defer lease.Release() if err := verifyTrustedRequestControlBundle(applyRequest); err != nil { return surfaces.Response{}, err } @@ -383,6 +442,48 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return applied, nil } +// stabilizeRepositoryPrescription ensures that selection and parameter +// materialization are one resolved identity before a prescription can leave +// the command boundary. It is shared by next, RPC, and Flow continuation. +func stabilizeRepositoryPrescription(ctx context.Context, request surfaces.Request, response surfaces.Response) (surfaces.Request, surfaces.Response, bool, error) { + rebound, changed, err := bindPrescribedRepositoryInvocation(ctx, request, response) + if err != nil || !changed { + return request, response, changed, err + } + if rebound.InputRequest != nil { + return rebound, surfaces.Response{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, + ProgramID: rebound.ProgramID, EntryID: rebound.EntryID, RunID: rebound.FlowID, + InputRequest: rebound.InputRequest, + }, true, nil + } + lease, err := acquireFlowExecutionLease(rebound) + if err != nil { + return surfaces.Request{}, surfaces.Response{}, true, err + } + defer lease.Release() + if err := verifyTrustedRequestControlBundle(rebound); err != nil { + return surfaces.Request{}, surfaces.Response{}, true, err + } + kernel, err := standardKernel(ctx, rebound) + if err != nil { + return surfaces.Request{}, surfaces.Response{}, true, err + } + stabilized, err := kernel.Handle(ctx, rebound) + if err != nil { + return rebound, stabilized, true, err + } + if stabilized.Prescription != nil { + if rebound.InvocationEvidence == nil { + return surfaces.Request{}, surfaces.Response{}, true, fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: stabilized repository prescription has no invocation evidence") + } + if err := stabilized.Prescription.ValidateInvocation(rebound.InvocationEvidence.InvocationFingerprint); err != nil { + return surfaces.Request{}, surfaces.Response{}, true, err + } + } + return rebound, stabilized, true, nil +} + func bindTrustedProviderCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) { if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || (response.Decision.Kind != supervisor.DecisionFrontier && response.Decision.Kind != supervisor.DecisionCandidate) || @@ -427,7 +528,7 @@ func bindContinuationCandidate(ctx context.Context, bound commandOptions, respon if err != nil { return commandOptions{}, false, err } - if len(rebound.parameters) <= len(bound.parameters) { + if rebound.inputRequest == nil && len(rebound.parameters) <= len(bound.parameters) { return bound, false, nil } return rebound, true, nil @@ -460,5 +561,7 @@ func advanceContinuation(options *commandOptions, response surfaces.Response) er options.effectiveCapabilities = nil options.idempotencyKey = "" options.trustedAuthorityReceipts = nil + options.invocationEvidence = nil + options.inputRequest = nil return nil } diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 49f43dd..010c92f 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -11,6 +11,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" @@ -18,6 +19,18 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) +func canReprojectDelegation(layout ports.ControllerLayout, invocation model.InvocationContext, prior, current delegation.Request) (bool, error) { + if prior.RunID != current.RunID || prior.ProgramID != current.ProgramID || prior.EntryID != current.EntryID || + prior.TargetID != current.TargetID || prior.ObjectiveID != current.ObjectiveID || prior.DeliveryID != current.DeliveryID || + prior.RepositoryID != current.RepositoryID || prior.GitCommonID != current.GitCommonID { + return false, nil + } + if prior.ControlBundleFingerprint == current.ControlBundleFingerprint { + return false, nil + } + return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint) +} + func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lock, *surfaces.Response, error) { if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 { return nil, nil, nil @@ -68,17 +81,28 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.Operation == surfaces.OperationExplain { return nil, nil, nil } - return nil, &surfaces.Response{ - SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Objective: request.Objective, - Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run"}, - }, nil + return nil, delegationRequiredResponse(*request), nil } if err != nil { releaseOnError() return nil, nil, err } if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint { + reprojected, reprojectErr := canReprojectDelegation(layout, invocation, record.Request, delegation.Request{ + RunID: request.FlowID, ProgramID: request.ProgramID, ProgramFingerprint: request.ProgramFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, + EntryID: request.EntryID, TargetID: string(request.Objective.TargetID), ObjectiveID: request.Objective.ID, DeliveryID: request.Objective.DeliveryID, + RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, BindingFingerprint: request.DelegationBindingFingerprint, + }) releaseOnError() + if reprojectErr != nil { + return nil, nil, reprojectErr + } + if reprojected { + if request.Operation == surfaces.OperationExplain { + return nil, nil, nil + } + return nil, delegationRequiredResponse(*request), nil + } return nil, nil, fmt.Errorf("DELEGATION_DRIFT: authorization does not match the current run context") } initial := invocation @@ -121,6 +145,62 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo return lock, nil, nil } +func delegationRequiredResponse(request surfaces.Request) *surfaces.Response { + return &surfaces.Response{ + SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Objective: request.Objective, + Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run"}, + } +} + +// preflightDelegatedProgramChange observes the selected program before any +// product delegation is requested. Reconciliation changes the control bundle, +// so authorizing against the prior bundle would create an authorization that +// must be rejected immediately after the accepted maintenance transition. +func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Request) (*surfaces.Response, error) { + if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 || request.Operation == surfaces.OperationExplain { + return nil, nil + } + probe := request + probe.Operation = surfaces.OperationExplain + probe.Prescription = protocol.Prescription{} + probe.IdempotencyKey = "" + probe.InvocationEvidence = nil + probe.InputRequest = nil + lease, err := acquireFlowExecutionLease(probe) + if err != nil { + return nil, err + } + defer lease.Release() + if err := verifyTrustedRequestControlBundle(probe); err != nil { + return nil, err + } + kernel, err := standardKernel(ctx, probe) + if err != nil { + return nil, err + } + response, err := kernel.Handle(ctx, probe) + if err != nil { + return nil, err + } + if !isExactProgramChangeSuspension(response) { + return nil, nil + } + response.Operation = request.Operation + return &response, nil +} + +func isExactProgramChangeSuspension(response surfaces.Response) bool { + return response.Decision != nil && + response.Decision.Kind == supervisor.DecisionUnresolved && + response.Decision.Reason == supervisor.ReasonProgramDrift && + response.ProgramChange != nil && + response.ProgramChange.PriorProgramFingerprint != "" && + response.ProgramChange.CandidateProgramFingerprint != "" && + response.ProgramChange.ProgramDeltaFingerprint != "" && + response.ProgramChange.RequiredTransition == "installation.reconcile-update" && + response.ProgramChange.AcceptanceFlag == "--accept-program-change" +} + func settleDelegationAtTarget(ctx context.Context, request surfaces.Request, response surfaces.Response, targetSatisfied, lockHeld bool) error { terminalDecision := response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal committedTarget := response.Receipt != nil && targetSatisfied diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index a606115..de04d46 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.3" +const flowCompilerVersion = "control-program.compiler.4" type flowCommandOptions struct { repository string @@ -33,7 +33,7 @@ type flowCommandOptions struct { func runFlowCommand(arguments []string) error { if len(arguments) == 0 { - return fmt.Errorf("usage: boatstack flow [flags]") + return fmt.Errorf("usage: boatstack flow [flags]") } action := arguments[0] if action == "authorize" { @@ -48,6 +48,9 @@ func runFlowCommand(arguments []string) error { if action == "work" { return runFlowWork(arguments[1:]) } + if action == "input" { + return runFlowInput(arguments[1:]) + } flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError) flags.SetOutput(os.Stderr) options := flowCommandOptions{} @@ -123,7 +126,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - if err := validateSoftwareFlow(ctx, options.repository, compiled, resolver); err != nil { + if err := validateCompiledFlow(ctx, options.repository, compiled, resolver); err != nil { return err } artifactPath, err := resolveArtifactPath(options.repository, options.artifact, compiled.Document.Program.ID) @@ -280,12 +283,217 @@ func checkFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - if err := validateSoftwareFlow(ctx, options.repository, compiled, resolver); err != nil { + if err := validateCompiledFlow(ctx, options.repository, compiled, resolver); err != nil { return err } return renderFlowResult("valid", artifactPath, artifact) } +func validateCompiledFlow(ctx context.Context, repository string, compiled controlprogram.Compiled, resolver softwareflow.Resolver) error { + declarative, err := declarativeFlow(compiled.Document) + if err != nil { + return fmt.Errorf("FLOW_RUNTIME_INVALID: %w", err) + } + if declarative { + return validateDeclarativeFlow(compiled) + } + return validateSoftwareFlow(ctx, repository, compiled, resolver) +} + +func declarativeFlow(document controlprogram.Document) (bool, error) { + inline, bound := 0, 0 + for _, operator := range document.Operators { + if operator.Binding == nil { + inline++ + } else { + bound++ + } + } + if inline != 0 && bound != 0 { + return false, fmt.Errorf("a Flow cannot mix inline and adapter-bound operators") + } + return inline != 0, nil +} + +// validateDeclarativeFlow admits the smallest domain-neutral executable +// adapter: repository-independent, assignment-only state transitions. The +// generic Control Program compiler remains broader; a domain adapter must +// explicitly own every additional producer or effect mechanism. +func validateDeclarativeFlow(compiled controlprogram.Compiled) error { + const stateVerifier = "state-effect" + facets := make(map[string]controlprogram.Facet, len(compiled.Document.Facets)) + for _, facet := range compiled.Document.Facets { + facets[facet.ID] = facet + } + for _, entry := range compiled.Document.Entries { + if entry.Delegation != nil || entry.Diagnostics != nil { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative entries do not support delegation or domain diagnostics") + } + } + for _, work := range compiled.Document.Work { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative runtime has no foreground-work adapter for %q", work.ID) + } + for _, operator := range compiled.Document.Operators { + if len(operator.Capabilities) != 0 || len(operator.Effects) != 0 || operator.Verifier != stateVerifier || operator.Recovery != "" || operator.StateEffect == nil || operator.StateEffect.Kind != "assignments" || operator.ExecutionContext != "preserve" { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative operator %q must be effect-free, assignment-only, and preserve context", operator.ID) + } + if !declarativeAuthoritySupported(operator.Authority.AnyOf) || !declarativeAuthoritySupported(operator.Authority.AllOf) { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative operator %q uses unsupported authority", operator.ID) + } + for _, parameter := range operator.Parameters { + if !declarativeAuthoritySupported(parameter.Authority.AnyOf) || !declarativeAuthoritySupported(parameter.Authority.AllOf) { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative operator %q parameter %q uses unsupported authority", operator.ID, parameter.ID) + } + } + } + for _, transition := range compiled.Document.Transitions { + if !declarativeAuthoritySupported(transition.Requires.Authorities) { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q uses unsupported authority", transition.ID) + } + operator, ok := findCompiledOperator(compiled.Document.Operators, transition.Operator) + if !ok || operator.StateEffect == nil { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q has no executable operator", transition.ID) + } + contracts := make(map[string]controlprogram.OperatorParameter, len(operator.Parameters)) + bindings := make(map[string]bool, len(transition.Parameters)) + for _, contract := range operator.Parameters { + contracts[contract.ID] = contract + } + for _, binding := range transition.Parameters { + bindings[binding.Parameter] = true + switch binding.Producer.Kind { + case controlprogram.ParameterSourceEntryInput, controlprogram.ParameterSourceState, controlprogram.ParameterSourceHostInput: + if binding.Producer.Kind == controlprogram.ParameterSourceHostInput && (binding.Producer.Request == nil || !declarativeAuthoritySupported(binding.Producer.Request.Authorities)) { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q host-input parameter %q uses unsupported authority", transition.ID, binding.Parameter) + } + default: + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q requires an adapter for producer %q", transition.ID, binding.Producer.Kind) + } + } + for _, precondition := range operator.StateEffect.Preconditions { + if !predicateRequiresFacetValue(transition.Guard, precondition.Facet, precondition.Values) { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q guard does not establish precondition %q", transition.ID, precondition.Facet) + } + } + for _, assignment := range operator.StateEffect.Assignments { + facet := facets[assignment.Facet] + if assignment.Value != nil { + if facet.Kind == "boolean" && *assignment.Value != "true" && *assignment.Value != "false" { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative assignment %q has a value outside the facet type", assignment.Facet) + } + continue + } + if assignment.ValueFrom == nil { + continue + } + if assignment.ValueFrom.Parameter == "" || assignment.ValueFrom.Admission != "" || assignment.ValueFrom.Invocation != "" { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative assignment %q has an unsupported value source", assignment.Facet) + } + contract, declared := contracts[assignment.ValueFrom.Parameter] + if !declared || !bindings[assignment.ValueFrom.Parameter] { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative assignment %q requires bound operator parameter %q", assignment.Facet, assignment.ValueFrom.Parameter) + } + if facet.Kind == "enum" || contract.Type.Kind != facet.Kind { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative assignment %q cannot prove parameter %q belongs to the facet", assignment.Facet, assignment.ValueFrom.Parameter) + } + } + if !assignmentsEstablishPredicate(*operator.StateEffect, transition.Guard, transition.Target) { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q assignments do not establish its target", transition.ID) + } + } + return nil +} + +func declarativeAuthoritySupported(authorities []string) bool { + for _, authority := range authorities { + if authority != "human" { + return false + } + } + return true +} + +// predicateRequiresFacetValue is intentionally conservative. It accepts only +// facts whose truth is structurally required by every path through a guard. +func predicateRequiresFacetValue(predicate controlprogram.Predicate, facet string, values []string) bool { + if predicate.Fact != nil { + fact := predicate.Fact + if fact.Facet != facet || len(fact.Values) == 0 { + return false + } + for _, value := range fact.Values { + if !containsString(values, value) { + return false + } + } + return true + } + if len(predicate.All) != 0 { + for _, child := range predicate.All { + if predicateRequiresFacetValue(child, facet, values) { + return true + } + } + return false + } + if len(predicate.Any) != 0 { + for _, child := range predicate.Any { + if !predicateRequiresFacetValue(child, facet, values) { + return false + } + } + return true + } + return false +} + +func assignmentsEstablishPredicate(effect controlprogram.StateEffect, guard, target controlprogram.Predicate) bool { + assigned := make(map[string]string, len(effect.Assignments)) + mutated := make(map[string]bool, len(effect.Assignments)) + for _, assignment := range effect.Assignments { + mutated[assignment.Facet] = true + if assignment.Value != nil { + assigned[assignment.Facet] = *assignment.Value + } + } + var established func(controlprogram.Predicate) bool + established = func(predicate controlprogram.Predicate) bool { + if predicate.True != nil { + return *predicate.True + } + if predicate.Fact != nil { + fact := predicate.Fact + if value, ok := assigned[fact.Facet]; ok { + if len(fact.Statuses) != 0 && !containsString(fact.Statuses, "known") { + return false + } + return len(fact.Values) == 0 || containsString(fact.Values, value) + } + if mutated[fact.Facet] { + return false + } + return predicateRequiresFacetValue(guard, fact.Facet, fact.Values) + } + if len(predicate.All) != 0 { + for _, child := range predicate.All { + if !established(child) { + return false + } + } + return true + } + if len(predicate.Any) != 0 { + for _, child := range predicate.Any { + if established(child) { + return true + } + } + } + return false + } + return established(target) +} + func validateSoftwareFlow(ctx context.Context, _ string, compiled controlprogram.Compiled, resolver softwareflow.Resolver) error { for _, flowEntry := range compiled.Document.Entries { if _, err := softwareflow.PlanInboxForEntry(flowEntry); err != nil { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index bac1ce6..3de2ddd 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -13,15 +13,21 @@ import ( "strings" "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/core" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" + "github.com/operatorstack/boatstack/boatstack/invocation" + general "github.com/operatorstack/boatstack/boatstack/kernel" ) var flowSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -33,6 +39,9 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if !flowSegment.MatchString(options.programID) || !flowSegment.MatchString(options.entryID) { return commandOptions{}, fmt.Errorf("FLOW_ENTRY_INVALID: --flow and --entry require semantic identifiers") } + if len(options.parameters) != 0 && !options.maintenanceParameterSurface { + return commandOptions{}, fmt.Errorf("FLOW_PARAMETER_BYPASS: repository Flow parameters must come from compiled producer declarations") + } repository, err := filepath.Abs(options.repository) if err != nil { return commandOptions{}, err @@ -41,6 +50,10 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err != nil { return commandOptions{}, err } + // Preserve the exact root used to validate and compile the Flow. Downstream + // control-bundle verification rejects relative or symlinked repository + // paths, including the generated command's ordinary "--repo ." form. + options.repository = repository artifactPath := filepath.Join(repository, ".boatstack", "flows", options.programID+".flow.ir.json") artifactRaw, err := os.ReadFile(artifactPath) if err != nil { @@ -119,33 +132,17 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if options.targetID != string(objective.TargetID) || options.trustedObjectiveClass != string(objective.TrustedClass) || options.deliveryID != deliveryID || options.objectiveID != expectedObjectiveID { return commandOptions{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: objective or delivery changed across the run") } - if options.transitionID == "installation.initialize" { - configPath := filepath.Join(repository, ".boatstack", "project.json") - initialParameters, parseErr := parseParameters(options.parameters) - if parseErr != nil { - return commandOptions{}, parseErr - } - if err := bindResolvedParameter(&options, initialParameters, "config_path", configPath); err != nil { - return commandOptions{}, err - } - if err := populateProjectConfigFingerprint(&options); err != nil { - return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind verified project configuration: %w", err) - } - if err := populateRuntimeParameters(&options); err != nil { - return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind exact runtime identity: %w", err) - } - } - parameters, err := parseParameters(options.parameters) - if err != nil { - return commandOptions{}, err - } - bundle, bundleFingerprint, err := bindControlBundle(ctx, repository, catalog.TransitionID(options.transitionID), parameters) + bundle, bundleFingerprint, err := bindControlBundle(ctx, repository, "", nil) if err != nil { return commandOptions{}, err } options.controlBundle = bundle options.controlBundleFingerprint = bundleFingerprint - if entry.Delegation != nil { + // 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 { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { return commandOptions{}, resolverErr @@ -183,9 +180,17 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, bound := record.Request inputDrift := !options.activeFlowBound && strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.ControlBundleFingerprint != delegationRequest.ControlBundleFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { - return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request (bundle %s, authorized %s)", delegationRequest.ControlBundleFingerprint, bound.ControlBundleFingerprint) + reprojected, reprojectErr := canReprojectDelegation(layout, invocation, bound, delegationRequest) + if reprojectErr != nil { + return commandOptions{}, reprojectErr + } + if !reprojected { + return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request (bundle %s, authorized %s)", delegationRequest.ControlBundleFingerprint, bound.ControlBundleFingerprint) + } + options.delegationReprojection = true + } else { + delegationRequest = bound } - delegationRequest = bound } else if !os.IsNotExist(loadErr) { return commandOptions{}, loadErr } @@ -199,94 +204,336 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, options.delegationDescription = description options.delegationRequest = delegationRequest } - for name, expected := range map[string]string{ - "target_id": string(objective.TargetID), - "delivery_id": deliveryID, - "source_path": plan, - "source_fingerprint": planFingerprint, - } { - if err := validateResolvedParameter(parameters, name, expected); err != nil { + 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 { + return options, err + } + } else if err := bindInternalFlowContextParameters(ctx, &options); err != nil { return commandOptions{}, err } - } - switch options.transitionID { - case "objective.bind": - if err := bindResolvedParameter(&options, parameters, "target_id", string(objective.TargetID)); err != nil { - return commandOptions{}, err + parameters, parseErr := parseParameters(options.parameters) + if parseErr != nil { + return commandOptions{}, parseErr } - if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { + bundle, bundleFingerprint, err = bindControlBundle(ctx, repository, catalog.TransitionID(options.transitionID), parameters) + if err != nil { return commandOptions{}, err } - case "plan.create", "plan.amend", softwareflow.PlanningPackageAdmit: - if err := bindResolvedParameter(&options, parameters, "source_path", plan); err != nil { - return commandOptions{}, err + options.controlBundle, options.controlBundleFingerprint = bundle, bundleFingerprint + if repositoryTransition && options.invocationEvidence == nil { + return commandOptions{}, fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: materialization produced no ready evidence") } - if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { - return commandOptions{}, err + if repositoryTransition { + boundEvidence, bindErr := invocation.BindControlBundle(*options.invocationEvidence, bundle.Fingerprint) + if bindErr != nil { + return commandOptions{}, bindErr + } + options.invocationEvidence = &boundEvidence } - if err := bindResolvedParameter(&options, parameters, "source_fingerprint", planFingerprint); err != nil { - return commandOptions{}, err + } + return options, nil +} + +func bindInternalFlowContextParameters(ctx context.Context, options *commandOptions) error { + manifest, err := core.System().CoreManifest(ctx) + if 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 } - case softwareflow.PlanningPackageApprove: - fingerprint, fingerprintErr := softwareflow.PlanningPackageFingerprint(repository, deliveryID) - if fingerprintErr != nil { - return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read planning package fingerprint: %w", fingerprintErr) + declared := map[string]bool{} + for _, parameter := range transition.Parameters { + declared[parameter.Name] = true + value := "" + switch parameter.Name { + case "target_id": + value = options.targetID + case "delivery_id": + value = options.deliveryID + } + if value != "" { + if err := bindFlowContextParameter(options, parameters, parameter.Name, value); err != nil { + return err + } + } } - if err := bindResolvedParameter(&options, parameters, "package_fingerprint", fingerprint); err != nil { - return commandOptions{}, err + for _, parameter := range parameters { + if !declared[parameter.Name] { + return fmt.Errorf("FLOW_PARAMETER_BYPASS: internal transition %s does not declare parameter %s", options.transitionID, parameter.Name) + } } - case "publication.preview": - if err := bindPublicationPreviewParameters(ctx, repository, deliveryID, options.host, &options, parameters); err != nil { - return commandOptions{}, err + return nil + } + return fmt.Errorf("FLOW_TRANSITION_UNKNOWN: %s", options.transitionID) +} + +func bindFlowContextParameter(options *commandOptions, parameters protocol.Parameters, name, value string) error { + if actual, exists := parameters.Get(name); exists { + if actual != value { + return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name) } + return nil } - return options, nil + options.parameters = append(options.parameters, name+"="+value) + return nil } -func bindPublicationPreviewParameters(ctx context.Context, repository, deliveryID, host string, options *commandOptions, parameters protocol.Parameters) error { - canonicalRepository, err := filepath.EvalSymlinks(repository) +func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions, bundle *boatstackruntime.ControlBundleContract) (commandOptions, error) { + var transition *controlprogram.Transition + for index := range compiled.Document.Transitions { + if compiled.Document.Transitions[index].ID == options.transitionID { + transition = &compiled.Document.Transitions[index] + break + } + } + if transition == nil { + return commandOptions{}, fmt.Errorf("FLOW_TRANSITION_UNKNOWN: %s", options.transitionID) + } + var operator *controlprogram.Operator + for index := range compiled.Document.Operators { + if compiled.Document.Operators[index].ID == transition.Operator { + operator = &compiled.Document.Operators[index] + break + } + } + if operator == nil { + return commandOptions{}, fmt.Errorf("FLOW_OPERATOR_UNKNOWN: %s", transition.Operator) + } + host := options.host + if host == "" { + host = "cli" + } + if options.correlationID == "" { + options.correlationID = "flow-" + options.runID + } + plantResolver, err := plant.NewResolver("") if err != nil { - return fmt.Errorf("FLOW_INPUT_REQUIRED: resolve publication repository: %w", err) + return commandOptions{}, err } - repository = canonicalRepository - configRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + invocationContext, err := plantResolver.ResolveInvocation(ctx, options.repository, host, options.correlationID) if err != nil { - return fmt.Errorf("FLOW_INPUT_REQUIRED: read publication configuration: %w", err) + return commandOptions{}, err } - config, err := protocol.DecodeProjectConfig(configRaw) + layout, invocationContext, err := plantResolver.ResolveLayout(ctx, invocationContext) if err != nil { - return fmt.Errorf("FLOW_INPUT_REQUIRED: decode publication configuration: %w", err) + return commandOptions{}, err } - resolver, err := plant.NewResolver("") + state := durable.State{} + if raw, readErr := os.ReadFile(layout.StatePath); readErr == nil { + state, err = durable.DecodeState(raw) + if err != nil { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: decode durable state: %w", err) + } + } else if !os.IsNotExist(readErr) { + return commandOptions{}, readErr + } + bundleFingerprint := "" + if bundle != nil { + bundleFingerprint = bundle.Fingerprint + } + contextFingerprint, err := general.Fingerprint(struct { + Invocation model.InvocationContext `json:"invocation"` + StateRevision uint64 `json:"state_revision"` + Program string `json:"program"` + ExecutionProgram string `json:"execution_program"` + Entry string `json:"entry"` + Target string `json:"target"` + Transition string `json:"transition"` + }{invocationContext, state.Revision, compiled.Fingerprint, state.ProgramFingerprint, entry.ID, options.targetID, transition.ID}) if err != nil { - return err + return commandOptions{}, err } - if host == "" { - host = "cli" + if len(state.ProgramFingerprint) != 64 { + return commandOptions{}, fmt.Errorf("FLOW_PROGRAM_UNBOUND: repository transition invocation requires an admitted executable program") + } + entryInputs := map[string]invocation.Value{} + for id, value := range options.workInputs { + entryInputs[id] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value.Value, Provenance: "entry-input:" + value.Fingerprint} } - invocation, err := resolver.ResolveInvocation(ctx, repository, host, "flow-publication-preview") + stateValues := softwareflow.StateParameterValues(state) + if softwareflow.UsesObservationParameterValues(transition.Parameters) { + observer, observerErr := plant.NewObserver(plantResolver, effects.Clock{}) + if observerErr != nil { + return commandOptions{}, observerErr + } + observation, observeErr := observer.Observe(ctx, ports.ObservationRequest{Invocation: invocationContext}) + if observeErr != nil { + return commandOptions{}, fmt.Errorf("FLOW_INVOCATION_OBSERVATION_FAILED: %w", observeErr) + } + for facet, value := range softwareflow.ObservationParameterValues(observation) { + stateValues[facet] = value + } + } + receiptValues := map[string]invocation.Value{} + for _, binding := range transition.Parameters { + if binding.Producer.Kind != controlprogram.ParameterSourceReceipt && binding.Producer.Kind != controlprogram.ParameterSourceStateOrReceipt { + continue + } + value, receiptID, found, lookupErr := effects.FindLatestCommittedTransitionOutput(layout, options.runID, invocationContext, catalog.TransitionID(binding.Producer.Transition), binding.Producer.Field, state.Revision) + if lookupErr != nil { + return commandOptions{}, lookupErr + } + if found { + receiptValues[binding.Producer.Transition+"/"+binding.Producer.Field] = invocation.Value{ + Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value, Provenance: "transition-receipt:" + receiptID, + } + } + } + workOutputs := map[string]invocation.Value{} + workByID := map[string]controlprogram.WorkContract{} + for _, work := range compiled.Document.Work { + workByID[work.ID] = work + } + for _, binding := range transition.Parameters { + if binding.Producer.Kind != controlprogram.ParameterSourceWorkOutput { + continue + } + work, declared := workByID[binding.Producer.Work] + if !declared { + return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: producer references unknown work %q", binding.Producer.Work) + } + record, loadErr := foregroundwork.LoadRecord(layout, options.runID, work.ID) + if loadErr != nil { + if os.IsNotExist(loadErr) { + return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q has no current result", work.ID) + } + return commandOptions{}, loadErr + } + if record.Status != foregroundwork.StatusCompleted || record.Result == nil { + return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q is not complete", work.ID) + } + if err := validateWorkOutputProducer(record, work, compiled, entry, options, invocationContext); err != nil { + return commandOptions{}, err + } + foundOutput := false + for _, output := range record.Result.Outputs { + if output.ID != binding.Producer.Output { + continue + } + foundOutput = true + kind := "string" + if output.MediaType == "application/json" { + kind = "json" + } + workOutputs[work.ID+"/"+output.ID] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: kind}, Canonical: output.Content, Provenance: "work-output:" + output.SHA256, ProducerFingerprint: record.Result.ResultFingerprint} + } + if !foundOutput { + return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q lacks output %q", work.ID, binding.Producer.Output) + } + } + store := invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()} + inputReceipts, err := store.LoadReceipts(options.runID, transition.ID) if err != nil { - return err + return commandOptions{}, err } - if !strings.HasPrefix(invocation.Ref, "refs/heads/") { - return fmt.Errorf("FLOW_INPUT_REQUIRED: publication requires an attached branch") + executionScopeFingerprint, err := flowExecutionScopeFingerprint(invocationContext) + if err != nil { + return commandOptions{}, err + } + materializationContext := invocation.Context{ + RunID: options.runID, ProgramFingerprint: compiled.Fingerprint, ExecutionProgramFingerprint: state.ProgramFingerprint, + EntryID: entry.ID, TargetID: options.targetID, TransitionID: transition.ID, + StateRevision: state.Revision, ContextFingerprint: contextFingerprint, ControlBundleFingerprint: bundleFingerprint, + ExecutionScopeFingerprint: executionScopeFingerprint, + EntryInputs: entryInputs, State: stateValues, Receipts: receiptValues, WorkOutputs: workOutputs, InputReceipts: inputReceipts, } - bodyPath, err := resolveRegularRepositoryFile(repository, filepath.Join(repository, ".boatstack", "evidence", deliveryID+"-pr-body.md"), "publication body") + if latest, found, latestErr := store.LatestRequest(materializationContext); latestErr != nil { + return commandOptions{}, latestErr + } else if found { + materializationContext.InputRequestGeneration = latest.Generation + materializationContext.InputRequestSupersession = latest.Supersession + } + bindingResolver, err := softwareflow.NewResolver(ctx) if err != nil { - return fmt.Errorf("FLOW_INPUT_REQUIRED: bind publication body: %w", err) + return commandOptions{}, err } - for name, value := range map[string]string{ - "base_ref": config.Project.DefaultBranch, - "head_ref": strings.TrimPrefix(invocation.Ref, "refs/heads/"), - "body_path": bodyPath, - } { - if err := bindResolvedParameter(options, parameters, name, value); err != nil { - return err + sourceRevision, err := plantResolver.ResolveSourceRevision(ctx, options.repository) + if err != nil { + return commandOptions{}, err + } + result, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, softwareflow.RuntimeParameterResolver{Context: ctx, Repository: options.repository, DeliveryID: options.deliveryID, SourceRevision: sourceRevision, Binding: bindingResolver}) + if err != nil { + return commandOptions{}, err + } + if result.Blocker != nil { + return commandOptions{}, fmt.Errorf("%s: %s", result.Blocker.Code, result.Blocker.Detail) + } + options.parameters, options.inputRequest, options.invocationEvidence = nil, result.Request, result.Ready + if result.Request != nil { + if err := store.SaveRequest(*result.Request); err != nil { + return commandOptions{}, err + } + return options, nil + } + for _, parameter := range result.Ready.Parameters { + if parameter.SecretReference != "" { + return commandOptions{}, fmt.Errorf("FLOW_SECRET_STORE_UNAVAILABLE: parameter %s requires a trusted secret store", parameter.Name) + } + options.parameters = append(options.parameters, parameter.Name+"="+parameter.Value) + } + return options, nil +} + +func validateWorkOutputProducer(record foregroundwork.Record, work controlprogram.WorkContract, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions, current model.InvocationContext) error { + contract, err := softwareflow.RuntimeWorkContract(work) + if err != nil { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: %w", err) + } + request := record.Request + result := record.Result + if result == nil || result.Validate() != nil || request.RunID != options.runID || request.ProgramID != compiled.Document.Program.ID || request.ProgramFingerprint != compiled.Fingerprint || request.EntryID != entry.ID || request.Objective.ID != options.objectiveID || string(request.Objective.TargetID) != options.targetID || request.Objective.DeliveryID != options.deliveryID || request.RepositoryID != current.RepositoryID || request.GitCommonID != current.GitCommonID || request.WorktreeID != current.WorktreeID || request.Ref != current.Ref || request.Contract.ID != contract.ID || request.Contract.Fingerprint != contract.Fingerprint || result.ContractID != contract.ID || result.ContractFingerprint != contract.Fingerprint || result.RequestFingerprint != request.Fingerprint || result.RepositoryID != current.RepositoryID || result.WorktreeID != current.WorktreeID { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q belongs to a different run, program, entry, objective, scope, or contract", work.ID) + } + producerTransition := "" + for _, candidate := range compiled.Document.Transitions { + if candidate.Work == work.ID { + if producerTransition != "" { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q has ambiguous producer transitions", work.ID) + } + producerTransition = candidate.ID + } + } + if producerTransition == "" || string(request.TransitionID) != producerTransition || result.TransitionID != request.TransitionID { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q transition context changed", work.ID) + } + expectedInputs := map[string]protocol.WorkInputValue{} + for _, input := range work.Inputs { + value, ok := options.workInputs[input.EntryInput] + if !ok { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q entry input %q is unavailable", work.ID, input.EntryInput) + } + expectedInputs[input.ID] = value + } + if len(request.Inputs) != len(expectedInputs) { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input binding changed", work.ID) + } + for _, input := range request.Inputs { + expected, ok := expectedInputs[input.ID] + if !ok || input.Value != expected.Value || input.Fingerprint != expected.Fingerprint { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input binding changed", work.ID) } } return nil } +func flowExecutionScopeFingerprint(value model.InvocationContext) (string, error) { + return general.Fingerprint(struct { + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + WorktreeID string `json:"worktree_id"` + Ref string `json:"ref"` + }{value.RepositoryID, value.GitCommonID, value.WorktreeID, value.Ref}) +} + func populateProjectConfigFingerprint(options *commandOptions) error { parameters, err := parseParameters(options.parameters) if err != nil { @@ -321,7 +568,7 @@ func bindSelectedPlanRun(options commandOptions, repository, programFingerprint, } runID := flowRunID(repositoryIdentity, programFingerprint, options.entryID, deliveryID, planFingerprint) if options.runID != "" && options.runID != runID { - return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID %s does not identify the selected plan and repository (expected %s)", options.runID, runID) } options.runID = runID return options, nil @@ -373,22 +620,29 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma if findErr != nil { return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: inspect committed flow receipts: %w", findErr) } - if !found || !strings.HasPrefix(receipt.FlowID, "run-") { - return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active objective has no committed run identity") - } if active.TargetID == entryObjective.TargetID && strings.HasPrefix(active.ID, prefix) { + if !found || !strings.HasPrefix(receipt.FlowID, "run-") { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active objective has no committed run identity") + } bound, bindErr := bindCommittedActiveRun(options, active, receipt) if bindErr != nil { return commandOptions{}, bindErr } - return bindStateOwnedTransitionParameters(bound, state) + return bound, nil } if entryObjective.TrustedClass == model.ObjectiveAbandoned { repositoryIdentity, identityErr := flowRepositoryIdentity(repository) if identityErr != nil { return commandOptions{}, identityErr } - expectedRunID := flowRunID(repositoryIdentity, options.flowProgramFingerprint, options.entryID, active.DeliveryID, "active-run:"+receipt.FlowID) + activeIdentity := "objective:" + active.ID + ":" + string(active.TargetID) + ":" + string(active.TrustedObjectiveClass()) + if found { + if !strings.HasPrefix(receipt.FlowID, "run-") { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: committed active run identity is invalid") + } + activeIdentity = "run:" + receipt.FlowID + } + expectedRunID := flowRunID(repositoryIdentity, options.flowProgramFingerprint, options.entryID, active.DeliveryID, activeIdentity) if options.runID != "" && options.runID != expectedRunID { return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the active delivery") } @@ -399,36 +653,6 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_CONFLICT: delivery %q is active under objective %q; abandon it before selecting another inbox plan", active.DeliveryID, active.ID) } -func bindStateOwnedTransitionParameters(options commandOptions, state durable.State) (commandOptions, error) { - parameters, err := parseParameters(options.parameters) - if err != nil { - return commandOptions{}, err - } - bindings := [][2]string{} - switch options.transitionID { - case "workspace.activate", "workspace.sync", "workspace.publish": - bindings = append(bindings, [2]string{"branch", state.WorkspaceBranch}) - case "publication.execute": - bindings = append(bindings, [2]string{"preview_fingerprint", state.PreviewFingerprint}) - case "publication.observe": - bindings = append(bindings, [2]string{"publication_id", state.PublicationID}) - case "publication.reconcile": - bindings = append(bindings, - [2]string{"publication_id", state.PublicationID}, - [2]string{"transaction_id", state.TransactionID}, - ) - } - for _, binding := range bindings { - if binding[1] == "" { - continue - } - if err := bindResolvedParameter(&options, parameters, binding[0], binding[1]); err != nil { - return commandOptions{}, err - } - } - return options, nil -} - func bindCommittedActiveRun(options commandOptions, active model.Objective, receipt protocol.TransitionReceipt) (commandOptions, error) { if options.runID != "" && options.runID != receipt.FlowID { return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the committed active delivery") @@ -439,37 +663,35 @@ func bindCommittedActiveRun(options commandOptions, active model.Objective, rece return options, nil } -func validateResolvedParameter(parameters protocol.Parameters, name, expected string) error { - if actual, exists := parameters.Get(name); exists && actual != expected { - return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name) - } - return nil -} - -func bindResolvedParameter(options *commandOptions, parameters protocol.Parameters, name, expected string) error { - if actual, exists := parameters.Get(name); exists { - if actual != expected { - return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name) - } - return nil - } - options.parameters = append(options.parameters, name+"="+expected) - return nil +func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.Request, error) { + return bindRPCFlowEntryWithMaintenance(ctx, request, false) } -func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.Request, error) { +func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Request, maintenanceParameterSurface bool) (surfaces.Request, error) { if request.ProgramID == "" && request.EntryID == "" { return request, nil } + if replay, canonicalRepository, err := committedFlowReplay(ctx, request); err != nil { + return surfaces.Request{}, err + } else if replay { + request.Repository = canonicalRepository + request.Parameters, request.InvocationEvidence, request.InputRequest = nil, nil, nil + request.ControlBundle, request.ControlBundleFingerprint = nil, "" + return request, nil + } + repositoryTransition, err := repositoryFlowDeclaresTransition(request.Repository, request.ProgramID, string(request.TransitionID)) + if err != nil { + return surfaces.Request{}, err + } parameterFlags := make([]string, 0, len(request.Parameters)) for _, parameter := range request.Parameters { parameterFlags = append(parameterFlags, parameter.Name+"="+parameter.Value) } bound, err := bindFlowEntry(ctx, commandOptions{ - repository: request.Repository, host: request.Host, programID: request.ProgramID, entryID: request.EntryID, + repository: request.Repository, host: request.Host, correlationID: request.CorrelationID, programID: request.ProgramID, entryID: request.EntryID, 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, + transitionID: string(request.TransitionID), parameters: parameterFlags, maintenanceParameterSurface: maintenanceParameterSurface && request.TransitionID != "" && !repositoryTransition, }) if err != nil { return surfaces.Request{}, err @@ -492,16 +714,138 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R request.WorkInputs = bound.workInputs request.ControlBundle = bound.controlBundle request.ControlBundleFingerprint = bound.controlBundleFingerprint + request.InvocationEvidence = bound.invocationEvidence + request.InputRequest = bound.inputRequest return request, nil } +// committedFlowReplay recognizes an exact, already committed apply before any +// producer is rematerialized. Repository effects may have consumed or moved +// their inputs, so safe replay must be decided from the immutable receipt. +func committedFlowReplay(ctx context.Context, request surfaces.Request) (bool, string, error) { + if request.Operation != surfaces.OperationApply || request.IdempotencyKey == "" || request.Prescription.ID == "" || request.FlowID == "" || request.TransitionID == "" { + return false, "", nil + } + repository, err := filepath.Abs(request.Repository) + if err != nil { + return false, "", err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return false, "", err + } + resolver, err := plant.NewResolver("") + if err != nil { + return false, "", err + } + host := request.Host + if host == "" { + host = "cli" + } + invoking, err := resolver.ResolveInvocation(ctx, repository, host, request.CorrelationID) + if err != nil { + return false, "", err + } + store, err := effects.NewReceiptStore(resolver, effects.Clock{}) + if err != nil { + return false, "", err + } + receipt, found, err := store.FindByIdempotency(ctx, invoking, request.IdempotencyKey) + if err != nil || !found { + return false, "", err + } + if receipt.FlowID != request.FlowID || receipt.PrescriptionID != request.Prescription.ID || receipt.TransitionID != request.TransitionID || receipt.InvocationFingerprint != request.Prescription.InvocationFingerprint { + return false, "", fmt.Errorf("FLOW_REPLAY_MISMATCH: committed receipt does not match the exact Flow apply request") + } + return true, repository, nil +} + +func repositoryFlowDeclaresTransition(repository, programID, transitionID string) (bool, error) { + if programID == "" || transitionID == "" { + return false, nil + } + repository, err := filepath.Abs(repository) + if err != nil { + return false, err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return false, err + } + raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "flows", programID+".flow.ir.json")) + if err != nil { + return false, err + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + return false, err + } + _, found := findCompiledTransition(artifact.Program.Transitions, transitionID) + return found, nil +} + +// bindPrescribedRepositoryInvocation closes the selector-to-invocation gap. +// A repository transition may be selected before its exact transition-specific +// producers are known. That first prescription is candidate evidence only: it +// must be rebound and re-resolved before it can be returned or applied. +func bindPrescribedRepositoryInvocation(ctx context.Context, request surfaces.Request, response surfaces.Response) (surfaces.Request, bool, error) { + if request.Operation != surfaces.OperationResolve || request.ProgramID == "" || request.InvocationEvidence != nil || response.Prescription == nil { + return request, false, nil + } + transitionID := string(response.Prescription.TransitionID) + repositoryTransition, err := repositoryFlowDeclaresTransition(request.Repository, request.ProgramID, transitionID) + if err != nil { + return surfaces.Request{}, false, err + } + if !repositoryTransition { + return request, false, nil + } + if response.Prescription.InvocationFingerprint != "" { + return surfaces.Request{}, false, fmt.Errorf("FLOW_INVOCATION_INVALID: unmaterialized repository prescription carries invocation identity") + } + rebound := request + rebound.TransitionID = response.Prescription.TransitionID + rebound.Parameters = nil + rebound.Prescription = protocol.Prescription{} + rebound.IdempotencyKey = "" + rebound.InvocationEvidence = nil + rebound.InputRequest = nil + rebound, err = bindRPCFlowEntry(ctx, rebound) + if err != nil { + return surfaces.Request{}, false, err + } + if rebound.InputRequest == nil && rebound.InvocationEvidence == nil { + return surfaces.Request{}, false, fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: selected repository transition produced neither an input request nor invocation evidence") + } + return rebound, true, nil +} + func resolveBoundPlan(repository string, entry controlprogram.Entry, entryObjective softwareflow.EntryObjective, options commandOptions) (string, string, error) { + if !options.activeFlowBound { + // A pre-materialization resume already carries the delivery identity + // derived from the selected plan. Resolve that exact identity rather + // than selecting from the inbox again: unrelated new plans cannot + // redirect the run, and case-colliding aliases fail closed. + if options.runID != "" && options.deliveryID != "" { + inbox, _, err := resolvePlanInbox(repository, entry) + if err != nil { + return "", "", err + } + plan, err := resolveActiveInboxPlan(repository, inbox, options.deliveryID) + return plan, options.deliveryID, err + } + plan, deliveryID, err := resolvePlanInput(repository, entry) + if err != nil { + return "", "", err + } + if options.deliveryID != "" && options.deliveryID != deliveryID { + return "", "", fmt.Errorf("FLOW_CONTEXT_MISMATCH: selected inbox plan does not match the preserved delivery identity") + } + return plan, deliveryID, nil + } if options.activeFlowBound && entryObjective.TrustedClass == model.ObjectiveAbandoned { return "", options.deliveryID, nil } - if options.deliveryID == "" { - return resolvePlanInput(repository, entry) - } if !flowSegment.MatchString(options.deliveryID) { return "", "", fmt.Errorf("FLOW_CONTEXT_MISMATCH: active run requires its delivery identity") } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index a553d99..3a9d7bd 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -16,6 +16,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "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/delegation" @@ -23,9 +24,11 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" "github.com/operatorstack/boatstack/boatstack/kernel" ) @@ -45,6 +48,74 @@ func flowRepository(t *testing.T) string { return repository } +func flowRepositoryWithHumanSlice(t *testing.T) string { + t.Helper() + repository := t.TempDir() + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o700); err != nil { + t.Fatal(err) + } + document := productDeliveryDocument("product-delivery") + truth := true + document.Operators = append(document.Operators, controlprogram.Operator{ID: "delivery.slice.advance", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/delivery.slice.advance", Version: "1"}}) + document.Transitions = append(document.Transitions, controlprogram.Transition{ + ID: "delivery.slice.advance", Operator: "delivery.slice.advance", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 76, + Parameters: []controlprogram.TransitionParameterBinding{ + {Parameter: "slice_id", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ID: "delivery-slice", Description: "Select the next bounded delivery slice.", Authorities: []string{"human", "autonomy"}, Scope: "transition"}}}, + {Parameter: "source_revision", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{Reference: softwareflow.ParameterResolverPrefix + "current-source-revision", Version: "1"}}}, + }, + }) + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + resolved, err := resolver.ResolveOperator("software-delivery/delivery.slice.advance", "1") + if err != nil { + t.Fatal(err) + } + declared := map[string]bool{} + for _, facet := range document.Facets { + declared[facet.ID] = true + } + for _, condition := range resolved.StateEffect.Preconditions { + if !declared[condition.Facet] { + document.Facets = append(document.Facets, controlprogram.Facet{ID: condition.Facet, Kind: "string"}) + declared[condition.Facet] = true + } + } + for _, assignment := range resolved.StateEffect.Assignments { + if !declared[assignment.Facet] { + document.Facets = append(document.Facets, controlprogram.Facet{ID: assignment.Facet, Kind: "string"}) + declared[assignment.Facet] = true + } + } + sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" + source, lock := []byte("flow source"), []byte("lock") + for path, content := range map[string][]byte{sourcePath: source, lockPath: lock} { + writeFixture(t, repository, path, content) + } + writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, lock) + return repository +} + +func TestFlowEntryCanonicalizesRepositoryRoot(t *testing.T) { + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + noncanonical := repository + string(os.PathSeparator) + "." + bound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: noncanonical, programID: "product-delivery", entryID: "run", host: "codex", + }) + if err != nil { + t.Fatal(err) + } + exact, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + if bound.repository != exact { + t.Fatalf("repository = %q, want exact root %q", bound.repository, exact) + } +} + func runFlowGit(t *testing.T, repository string, arguments ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) @@ -63,6 +134,34 @@ func runFlowGitOutput(t *testing.T, repository string, arguments ...string) stri return strings.TrimSpace(string(output)) } +func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint string) { + t.Helper() + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invoking, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "fixture-program-state") + if err != nil { + t.Fatal(err) + } + layout, invoking, err := resolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + state := durable.Default(invoking, time.Now().UTC()) + state.ProgramFingerprint = programFingerprint + raw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { + t.Fatal(err) + } +} + func captureRunOutput(t *testing.T, arguments ...string) ([]byte, error) { t.Helper() return captureStdout(t, func() error { return run(arguments) }) @@ -245,9 +344,15 @@ func productDeliveryDocument(programID string) controlprogram.Document { Facets: []controlprogram.Facet{ {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, + {ID: "preview_fingerprint", Kind: "string"}, {ID: "publication_id", Kind: "string"}, }, - Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, - Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77}}, + Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, + Transitions: []controlprogram.Transition{{ + ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77, + Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "publication_id", Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceState, Facet: "publication_id", AvailableWhen: ptrPredicate(flowKnown("publication_id")), + }}}, + }}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ flowFact("verification", "current"), flowFact("configuration", "verified"), flowFact("runtime", "verified"), flowFact("publication", "open"), }}}}, @@ -255,6 +360,241 @@ func productDeliveryDocument(programID string) controlprogram.Document { } } +type publicationReconcileRunner struct{ output []byte } + +func (r publicationReconcileRunner) CombinedOutput(context.Context, string, string, ...string) ([]byte, error) { + return r.output, nil +} + +func recoveryMaterializationDocument(programID string) controlprogram.Document { + document := productDeliveryDocument(programID) + truth := true + document.Facets = append(document.Facets, controlprogram.Facet{ID: softwareflow.RecoveryTransactionFacet, Kind: "string"}) + document.Operators = []controlprogram.Operator{{ID: "publication.reconcile", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.reconcile", Version: "1"}}} + document.Transitions = []controlprogram.Transition{{ + ID: "publication.reconcile", Operator: "publication.reconcile", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 1, + Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "transaction_id", Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceState, Facet: softwareflow.RecoveryTransactionFacet, AvailableWhen: ptrPredicate(flowKnown(softwareflow.RecoveryTransactionFacet)), + }}}, + }} + return document +} + +func publicationExecuteAdmission(t *testing.T, invocationContext model.InvocationContext, stateRevision uint64, programFingerprint, sourceRevision string) (protocol.Admission, catalog.Transition) { + t.Helper() + now := time.Now().UTC() + evidence := model.Evidence{Source: "git:fixture", Revision: sourceRevision, Fingerprint: strings.Repeat("f", 64), ObservedAt: now} + objective := model.Objective{ID: "objective-product-delivery-run-plan", TargetID: model.ObjectiveOpenPR, DeliveryID: "plan"} + observation := model.Observation{ + SchemaVersion: model.SnapshotSchemaVersion, StateRevision: stateRevision, RecordedProgramFingerprint: programFingerprint, Invocation: invocationContext, + Phase: model.Known(model.PhaseActive, evidence), Engagement: model.Known(model.EngagementActive, evidence), Delivery: model.Known(model.DeliveryActive, evidence), + Workspace: model.Known(model.WorkspaceActive, evidence), Plan: model.Known(model.PlanLocked, evidence), + Configuration: model.Known(model.ConfigurationVerified, evidence), Runtime: model.Known(model.RuntimeVerified, evidence), + ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli", "codex"}}, evidence), + Publication: model.Known(model.PublicationCandidate, evidence), Verification: model.Known(model.VerificationCurrent, evidence), + Recovery: model.Known(model.RecoveryNone, evidence), Transaction: model.Known(model.TransactionNone, evidence), + RecoveryInfo: model.Absent[model.RecoveryContext]("none", evidence), TransactionInfo: model.Absent[model.TransactionContext]("none", evidence), + Terminal: model.Known(model.TerminalNonterminal, evidence), Objective: model.Known(objective, evidence), ObservedAt: now, + } + snapshot, err := model.CanonicalizeForProgram(observation, programFingerprint) + if err != nil { + t.Fatal(err) + } + transition, ok := testprogram.StandardRegistry().Lookup("publication.execute") + if !ok { + t.Fatal("publication execute transition is unavailable") + } + previewFingerprint := strings.Repeat("a", 64) + authority := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{ + {ID: "human-publication", Class: catalog.AuthorityHuman, Subject: "operator", Fingerprint: "human-publication", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Minute)}, + {ID: "provider-publication", Class: catalog.AuthorityProvider, Subject: "github:fixture", Fingerprint: previewFingerprint, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Minute)}, + }} + capabilities, err := protocol.ProjectCapabilities(snapshot, transition, authority, now) + if err != nil { + t.Fatal(err) + } + prescription, err := protocol.NewPrescription(snapshot, transition, capabilities) + if err != nil { + t.Fatal(err) + } + admission, err := protocol.NewAdmission(snapshot, objective, transition, prescription, authority, protocol.Parameters{{Name: "preview_fingerprint", Value: previewFingerprint}}, now, time.Minute) + if err != nil { + t.Fatal(err) + } + return admission, transition +} + +func TestUntargetedReconciliationMaterializesPendingJournalAdmissionID(t *testing.T) { + // control-law: recovery admissibility and its transaction parameter must + // come from the same pending-journal observation when durable state was not + // advanced before an external outcome became unknown. + repository := flowRepository(t) + runFlowGit(t, repository, "init", "-q", "-b", "main") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + + document := recoveryMaterializationDocument("product-delivery") + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + plantResolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invoking, err := plantResolver.ResolveInvocation(context.Background(), repository, "codex", "pending-journal-reconcile") + if err != nil { + t.Fatal(err) + } + layout, invoking, err := plantResolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + state := durable.Default(invoking, time.Now().UTC()) + state.Revision = 1 + state.ProgramFingerprint = compiled.Fingerprint + raw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { + t.Fatal(err) + } + admission, execute := publicationExecuteAdmission(t, invoking, state.Revision, compiled.Fingerprint, runFlowGitOutput(t, repository, "rev-parse", "HEAD")) + journal, err := effects.NewJournal(plantResolver, effects.Clock{}) + if err != nil { + t.Fatal(err) + } + if err := journal.Begin(context.Background(), admission, execute); err != nil { + t.Fatal(err) + } + if err := journal.Mark(context.Background(), admission.ID, "executing"); err != nil { + t.Fatal(err) + } + if err := journal.RequireRecovery(context.Background(), admission.ID, "provider outcome unknown"); err != nil { + t.Fatal(err) + } + if state.TransactionID != "" || state.Transaction != model.TransactionNone { + t.Fatalf("fixture advanced durable transaction state: %#v", state) + } + + materialized, err := materializeFlowInvocation(context.Background(), compiled, compiled.Document.Entries[0], commandOptions{ + repository: repository, host: "codex", runID: "run-pending-journal", deliveryID: "plan", + targetID: "published-pr", transitionID: "publication.reconcile", + }, nil) + if err != nil { + t.Fatal(err) + } + want := "transaction_id=" + admission.ID + if len(materialized.parameters) != 1 || materialized.parameters[0] != want || materialized.invocationEvidence == nil { + t.Fatalf("observed recovery materialization = parameters %#v evidence %#v, want %q", materialized.parameters, materialized.invocationEvidence, want) + } +} + +func TestPublicationReconciliationWithoutExecuteReceiptMaterializesObservation(t *testing.T) { + // control-law: an unknown publication effect may reconcile a durable + // publication identity without manufacturing an execute receipt; the next + // observation must consume that durable identity. + repository := flowRepository(t) + runFlowGit(t, repository, "init", "-q", "-b", "main") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + + document := productDeliveryDocument("product-delivery") + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + plantResolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invoking, err := plantResolver.ResolveInvocation(context.Background(), repository, "codex", "publication-reconcile-fixture") + if err != nil { + t.Fatal(err) + } + layout, invoking, err := plantResolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + state := durable.Default(invoking, time.Now().UTC()) + state.ProgramFingerprint = compiled.Fingerprint + state.TransactionID = "publication-unknown" + state.TransactionTransition = "publication.execute" + state.Phase = model.PhaseRecovery + state.Recovery = model.RecoveryReconcile + state.RecoveryCause = "publication result was not parseable" + state.RecoverySourcePhase = model.PhaseExecutingExternal + state.RecoveryResumption = model.PhaseActive + state.RecoveryBudget = 3 + state.Transaction = model.TransactionExternalUncertain + + if _, _, found, err := effects.FindLatestCommittedTransitionOutput(layout, "run-publication-unknown", invoking, "publication.execute", "publication_id", state.Revision); err != nil || found { + t.Fatalf("interrupted execute receipt found=%t err=%v", found, err) + } + boundary, err := effects.NewNativeBoundaryWithRunner(publicationReconcileRunner{output: []byte(`{"state":"OPEN","url":"https://github.com/operatorstack/todo/pull/9","number":9,"mergedAt":null,"baseRefName":"main","headRefName":"main","headRefOid":"` + runFlowGitOutput(t, repository, "rev-parse", "HEAD") + `","isCrossRepository":false}`)}) + if err != nil { + t.Fatal(err) + } + reconcile, ok := testprogram.StandardRegistry().Lookup("publication.reconcile") + if !ok { + t.Fatal("publication reconciliation transition is unavailable") + } + admission := protocol.Admission{ + Invocation: invoking, SourceRevision: runFlowGitOutput(t, repository, "rev-parse", "HEAD"), + Parameters: protocol.Parameters{{Name: "transaction_id", Value: state.TransactionID}}, + } + admission.RequiredCapabilities = catalog.RequiredCapabilities(reconcile) + admission.EffectiveCapabilities = admission.RequiredCapabilities + if err := boundary.PrepareObservation(context.Background(), admission, reconcile, layout, &state); err != nil { + t.Fatal(err) + } + if state.PublicationID != "9" { + t.Fatalf("reconciled publication ID = %q", state.PublicationID) + } + raw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { + t.Fatal(err) + } + + materialized, err := materializeFlowInvocation(context.Background(), compiled, compiled.Document.Entries[0], commandOptions{ + repository: repository, host: "codex", runID: "run-publication-unknown", deliveryID: "delivery-one", + targetID: "published-pr", transitionID: "publication.observe", + }, nil) + if err != nil { + t.Fatal(err) + } + if len(materialized.parameters) != 1 || materialized.parameters[0] != "publication_id=9" || materialized.invocationEvidence == nil { + t.Fatalf("state-backed observation materialization = parameters %#v evidence %#v", materialized.parameters, materialized.invocationEvidence) + } +} + +func ptrPredicate(value controlprogram.Predicate) *controlprogram.Predicate { return &value } +func flowKnown(facet string) controlprogram.Predicate { + return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}}} +} + func writeFixture(t *testing.T, repository, relative string, content []byte) { t.Helper() path := filepath.Join(repository, filepath.FromSlash(relative)) @@ -325,6 +665,55 @@ func TestRPCFlowEntryRejectsUnknownEntryAndInvalidInboxBeforeManagedState(t *tes } } +func TestPrescribedRepositoryTransitionRebindsBeforeExposure(t *testing.T) { + // control-law: a-selected-repository-transition-cannot-return-or-apply-an-unbound-prescription + repository := flowRepositoryWithHumanSlice(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + if err := os.RemoveAll(filepath.Join(repository, ".git")); err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") + runFlowGit(t, repository, "config", "user.name", "Fixture") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + writeAdmittedFlowProgramState(t, repository, strings.Repeat("f", 64)) + bound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + }) + if err != nil { + t.Fatal(err) + } + request, err := buildRequest(surfaces.OperationResolve, bound) + if err != nil { + t.Fatal(err) + } + response := surfaces.Response{Prescription: &protocol.Prescription{ + SchemaVersion: protocol.PrescriptionSchemaVersion, + TransitionID: "delivery.slice.advance", + }} + rebound, changed, err := bindPrescribedRepositoryInvocation(context.Background(), request, response) + if err != nil { + t.Fatal(err) + } + if !changed || rebound.TransitionID != "delivery.slice.advance" || rebound.Prescription.ID != "" { + t.Fatalf("selected prescription was not rebound: changed=%t request=%#v", changed, rebound) + } + if rebound.InputRequest == nil || rebound.InvocationEvidence != nil { + t.Fatalf("host-input transition crossed selection without its exact input request: request=%#v evidence=%#v", rebound.InputRequest, rebound.InvocationEvidence) + } + if rebound.InputRequest.ProgramFingerprint != rebound.ProgramFingerprint || rebound.InputRequest.ExecutionProgramFingerprint != strings.Repeat("f", 64) || rebound.InputRequest.ProgramFingerprint == rebound.InputRequest.ExecutionProgramFingerprint { + t.Fatalf("input request collapsed definition and executable program identities: %#v", rebound.InputRequest) + } + _, suspended, changed, err := stabilizeRepositoryPrescription(context.Background(), request, response) + if err != nil { + t.Fatal(err) + } + if !changed || suspended.InputRequest == nil || suspended.Prescription != nil { + t.Fatalf("unstabilized prescription escaped the shared resolution boundary: changed=%t response=%#v", changed, suspended) + } +} + func TestRPCFlowEntryPreservesObjectiveEvidenceAndStopContext(t *testing.T) { // control-law: entry-binding-preserves-nonidentity-objective-context repository := flowRepository(t) @@ -365,7 +754,7 @@ func TestFlowRunIdentitySurvivesWorkspaceTransfer(t *testing.T) { repository: destination, programID: "product-delivery", entryID: "run", host: "codex", flowProgramFingerprint: initial.flowProgramFingerprint, runID: initial.runID, deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, - transitionID: "plan.create", + activeFlowBound: true, }) if err != nil { t.Fatal(err) @@ -373,12 +762,8 @@ func TestFlowRunIdentitySurvivesWorkspaceTransfer(t *testing.T) { if resumed.runID != initial.runID { t.Fatalf("workspace transfer changed Flow run identity: %q != %q", resumed.runID, initial.runID) } - parameters, err := parseParameters(resumed.parameters) - if err != nil { - t.Fatal(err) - } - if sourcePath, ok := parameters.Get("source_path"); !ok || sourcePath != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { - t.Fatalf("destination plan binding = %q, %t", sourcePath, ok) + if source, ok := resumed.workInputs["plan"]; !ok || source.Value != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { + t.Fatalf("destination entry input = %#v, %t", source, ok) } continuation := commandOptions{ @@ -509,6 +894,33 @@ func TestWorkspaceCutFreezesMovingBaseReference(t *testing.T) { } } +func TestWorkspaceCutResolvesConfiguredBaseFromOriginTrackingBranch(t *testing.T) { + // control-law: a semantic PR base does not require a redundant local branch + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + runFlowGit(t, repository, "branch", "-M", "main") + want := strings.TrimSpace(runFlowGitOutput(t, repository, "rev-parse", "HEAD")) + runFlowGit(t, repository, "update-ref", "refs/remotes/origin/main", want) + runFlowGit(t, repository, "switch", "-q", "-c", "feature") + runFlowGit(t, repository, "branch", "-D", "main") + + contract, _, err := bindControlBundle(context.Background(), repository, "workspace.cut", protocol.Parameters{{Name: "base_ref", Value: "main"}}) + if err != nil { + t.Fatal(err) + } + if contract.TargetRevision != want { + t.Fatalf("target revision = %s, want origin/main revision %s", contract.TargetRevision, want) + } +} + func TestOneStaleFlowBlocksMultiFlowControlBundle(t *testing.T) { // control-law: a repository control bundle is complete across every Flow repository := flowRepository(t) @@ -553,7 +965,7 @@ func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "plan.create", parameters: []string{"source_path=" + other}, }) - if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + if err == nil || !strings.Contains(err.Error(), "FLOW_PARAMETER_BYPASS") { t.Fatalf("CLI override result = %v", err) } return @@ -563,7 +975,7 @@ func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { Host: "claude", CorrelationID: "rpc-override", ProgramID: "product-delivery", EntryID: "run", TransitionID: "plan.create", Parameters: protocol.Parameters{{Name: "source_path", Value: other}}, }) - if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + if err == nil || !strings.Contains(err.Error(), "FLOW_PARAMETER_BYPASS") { t.Fatalf("RPC override result = %v", err) } }) @@ -580,11 +992,186 @@ func TestFlowEntryRejectsCallerOverridesDuringUntargetedResolution(t *testing.T) repository: repository, programID: "product-delivery", entryID: "run", host: "codex", parameters: []string{"source_path=" + other}, }) - if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + if err == nil || !strings.Contains(err.Error(), "FLOW_PARAMETER_BYPASS") { t.Fatalf("untargeted override result = %v", err) } } +func TestFlowEntryDoesNotMaterializeInternalKernelTransition(t *testing.T) { + // control-law: repository invocation contracts govern only transitions in + // canonical Flow IR; internal kernel transitions retain their trusted path. + repository := flowRepository(t) + runFlowGit(t, repository, "init", "-q") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + + bound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "objective.bind", + }) + if err != nil { + t.Fatal(err) + } + if bound.invocationEvidence != nil || bound.inputRequest != nil { + t.Fatalf("internal transition acquired repository invocation state: evidence=%#v request=%#v", bound.invocationEvidence, bound.inputRequest) + } + parameters, err := parseParameters(bound.parameters) + if err != nil { + t.Fatal(err) + } + if target, ok := parameters.Get("target_id"); !ok || target != "published-pr" { + t.Fatalf("target context = %q, %t", target, ok) + } + if delivery, ok := parameters.Get("delivery_id"); !ok || delivery != "delivery-one" { + t.Fatalf("delivery context = %q, %t", delivery, ok) + } +} + +func TestFlowRefreshPreservesTrustedMaintenanceParameters(t *testing.T) { + // control-law: Flow refresh rematerializes repository transition values but + // preserves parameters already bound by a trusted maintenance command. + repository := flowRepository(t) + document := productDeliveryDocument("product-delivery") + document.Entries[0].Delegation = &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"} + writeFlowArtifact(t, repository, document, ".boatstack/flows/product-delivery.flow.ts", []byte("flow source"), "package-lock.json", []byte("lock")) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + parameters := []string{ + "source_revision=exact-source", + "runtime_version=v-test", + "runtime_sha256=" + strings.Repeat("a", 64), + "accept_obligation_change=true", + } + options, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + transitionID: "installation.reconcile-update", parameters: parameters, + maintenanceParameterSurface: true, humanActor: "operator", + }) + if err != nil { + t.Fatal(err) + } + if len(options.delegationAuthorities) != 0 || options.delegationRequestFingerprint != "" { + t.Fatalf("program reconciliation acquired product delegation: authorities=%v request=%q", options.delegationAuthorities, options.delegationRequestFingerprint) + } + prior, err := buildRequest(surfaces.OperationApply, options) + if err != nil { + t.Fatal(err) + } + refreshed, _, err := refreshFlowInvocation(context.Background(), surfaces.OperationApply, prior, options) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"source_revision", "runtime_version", "runtime_sha256", "accept_obligation_change"} { + if _, ok := refreshed.Parameters.Get(name); !ok { + t.Fatalf("CLI refresh dropped trusted maintenance parameter %q", name) + } + } + rpc, err := refreshRPCFlowInvocation(context.Background(), prior) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(rpc.Parameters, refreshed.Parameters) { + t.Fatalf("RPC and CLI maintenance refresh differ:\nRPC: %#v\nCLI: %#v", rpc.Parameters, refreshed.Parameters) + } +} + +func TestProgramChangePreflightRequiresExactTypedRecoverySurface(t *testing.T) { + // control-law: product delegation is delayed only for a complete, exact + // program-drift suspension with an explicit reconciliation transition. + response := surfaces.Response{ + Decision: &supervisor.Decision{Kind: supervisor.DecisionUnresolved, Reason: supervisor.ReasonProgramDrift}, + ProgramChange: &surfaces.ProgramChange{ + PriorProgramFingerprint: strings.Repeat("a", 64), CandidateProgramFingerprint: strings.Repeat("b", 64), + ProgramDeltaFingerprint: strings.Repeat("c", 64), RequiredTransition: "installation.reconcile-update", AcceptanceFlag: "--accept-program-change", + }, + } + if !isExactProgramChangeSuspension(response) { + t.Fatal("complete program-drift suspension was not recognized") + } + response.ProgramChange.AcceptanceFlag = "--implicit" + if isExactProgramChangeSuspension(response) { + t.Fatal("noncanonical acceptance surface delayed delegation") + } +} + +func TestAcceptedProgramReconciliationReprojectsSameFlowRun(t *testing.T) { + // control-law: an accepted program mutation is a hard reprojection boundary; + // the next product resolution uses the new program and preserves the run. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + runtimeHome := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, runtimeHome) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + runtimeRaw, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + if _, err := boatstackruntime.InstallExecutable(executable, runtimeHome, boatstackruntime.Identity{Version: buildinfo.Version, SHA256: hash(runtimeRaw), SourceRevision: buildRevision()}); err != nil { + t.Fatal(err) + } + repository := flowRepository(t) + runFlowGit(t, repository, "init", "-q") + 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") + output, err := captureRunOutput(t, + "init", "--repo", repository, "--flow", "product-delivery", "--entry", "run", + "--param", "config_path="+filepath.Join(repository, ".boatstack", "project.json"), "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("initialize old program: %v\n%s", err, output) + } + bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + output, err = captureRunOutput(t, + "objective-bind", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", bound.runID, + "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("bind old-program objective: %v\n%s", err, output) + } + document := productDeliveryDocument("product-delivery") + document.Program.Version = "2" + writeFlowArtifact(t, repository, document, ".boatstack/flows/product-delivery.flow.ts", []byte("flow source"), "package-lock.json", []byte("lock")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "update flow") + + output, err = captureRunOutput(t, + "reconcile-update", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", bound.runID, + "--accept-program-change", "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("accepted reconciliation: %v\n%s", err, output) + } + var reconciled surfaces.Response + if err := json.Unmarshal(output, &reconciled); err != nil { + t.Fatal(err) + } + if reconciled.Receipt == nil || reconciled.Receipt.TransitionID != "installation.reconcile-update" || reconciled.RunID != bound.runID { + t.Fatalf("reconciliation response = %#v", reconciled) + } + + output, err = captureRunOutput(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) + } + var projected surfaces.Response + if decodeErr := json.Unmarshal(output, &projected); decodeErr != nil { + t.Fatal(decodeErr) + } + if projected.RunID != bound.runID || projected.ProgramID != "product-delivery" || projected.ProgramChange != nil { + t.Fatalf("post-reconciliation projection changed run or Flow program: %#v", projected) + } +} + func TestFlowCompileRejectsSourceChangedDuringFrontend(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell fixture is Unix-only") @@ -877,8 +1464,14 @@ func TestFlowExecutionLeaseSerializesProjectionPublicationThroughEffect(t *testi func TestFlowValidationRejectsMissingProductionRecoveryClosure(t *testing.T) { // control-law: published-flows-close-recovery-in-the-production-composition document := productDeliveryDocument("product-delivery") + available := flowKnown("preview_fingerprint") document.Operators[0] = controlprogram.Operator{ID: "publication.execute", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.execute", Version: "1"}} - document.Transitions[0] = controlprogram.Transition{ID: "publication.execute", Operator: "publication.execute", Guard: document.Transitions[0].Guard, Target: document.Transitions[0].Target, Priority: 77} + document.Transitions[0] = controlprogram.Transition{ + ID: "publication.execute", Operator: "publication.execute", Guard: document.Transitions[0].Guard, Target: document.Transitions[0].Target, Priority: 77, + Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "preview_fingerprint", Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceState, Facet: "preview_fingerprint", AvailableWhen: &available, + }}}, + } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { t.Fatal(err) @@ -1082,8 +1675,8 @@ func TestFlowCompileAndCheckRejectUnbindableEntryInputs(t *testing.T) { } } -func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { - // control-law: questions-and-restarts-preserve-the-exact-plan-worktree-and-run +func TestFreshFlowEntryPreservesInboxProducerAcrossDelegationContext(t *testing.T) { + // control-law: authority-suspension-cannot-switch-a-fresh-run-from-its-inbox-plan-to-prior-managed-input repository := flowRepository(t) writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("exact plan")) initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) @@ -1093,30 +1686,10 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { if !strings.HasPrefix(initial.runID, "run-") || initial.deliveryID != "delivery-one" || initial.targetID != "published-pr" || initial.trustedObjectiveClass != "open-or-updated-pr" || len(initial.parameters) != 0 { t.Fatalf("initial Flow context = %#v", initial) } - for _, transitionID := range []string{"objective.bind", "plan.create"} { - preManaged, err := bindFlowEntry(context.Background(), commandOptions{ - repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", - deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, transitionID: transitionID, - }) - if err != nil { - t.Fatalf("pre-materialization %s binding failed: %v", transitionID, err) - } - if transitionID == "plan.create" { - parameters, parseErr := parseParameters(preManaged.parameters) - if parseErr != nil { - t.Fatal(parseErr) - } - expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery-one.md") - if source, ok := parameters.Get("source_path"); !ok || source != expected { - t.Fatalf("pre-materialization source = %q, present=%t", source, ok) - } - } - } - writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("exact plan")) - writeFixture(t, repository, ".boatstack/plans/inbox/unrelated.md", []byte("other plan")) + writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("prior managed plan")) resumed, err := bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", - deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, transitionID: "plan.create", + deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, }) if err != nil { t.Fatal(err) @@ -1124,12 +1697,30 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { if resumed.runID != initial.runID { t.Fatalf("run identity changed: %s != %s", resumed.runID, initial.runID) } - parameters, err := parseParameters(resumed.parameters) + if source, ok := resumed.workInputs["plan"]; !ok || source.Value != filepath.Join(resumed.repository, ".boatstack", "plans", "inbox", "delivery-one.md") { + t.Fatalf("resumed entry input = %#v, present=%t", source, ok) + } +} + +func TestActiveFlowEntryResumesManagedPlan(t *testing.T) { + // control-law: only a durably active Flow may resume through its materialized plan + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("inbox plan")) + managed := filepath.Join(repository, ".boatstack", "plans", "delivery-one.source") + writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("active managed plan")) + plan, deliveryID, err := resolveBoundPlan(repository, controlprogram.Entry{ID: "run"}, softwareflow.EntryObjective{}, commandOptions{ + activeFlowBound: true, + deliveryID: "delivery-one", + }) if err != nil { t.Fatal(err) } - if source, ok := parameters.Get("source_path"); !ok || source != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { - t.Fatalf("resumed source = %q, present=%t", source, ok) + if plan != managed || deliveryID != "delivery-one" { + t.Fatalf("active plan = %q delivery = %q; want %q and delivery-one", plan, deliveryID, managed) } } @@ -1271,174 +1862,8 @@ func TestRepositoryBytesDetectsIndexOnlyMutation(t *testing.T) { } } -func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) { - // control-law: continuation-may-re-resolve-only-one-supervisor-candidate-with-repository-owned-parameters - repository := flowRepository(t) - writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("exact plan")) - bound, err := bindFlowEntry(context.Background(), commandOptions{ - repository: repository, programID: "product-delivery", entryID: "run", host: "codex", - }) - if err != nil { - t.Fatal(err) - } - objectiveBind := catalog.Transition{ID: "objective.bind", Parameters: []catalog.ParameterSpec{{Name: "target_id", Required: true}, {Name: "delivery_id", Required: true}}} - rebound, changed, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{ - Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"}, - }}) - if err != nil || !changed || rebound.transitionID != "objective.bind" { - t.Fatalf("repository candidate rebind = options=%#v changed=%t err=%v", rebound, changed, err) - } - parameters, err := parseParameters(rebound.parameters) - if err != nil { - t.Fatal(err) - } - if target, ok := parameters.Get("target_id"); !ok || target != "published-pr" { - t.Fatalf("bound target = %q, %t", target, ok) - } - if delivery, ok := parameters.Get("delivery_id"); !ok || delivery != "delivery-one" { - t.Fatalf("bound delivery = %q, %t", delivery, ok) - } - planCreate := catalog.Transition{ID: "plan.create", Parameters: []catalog.ParameterSpec{{Name: "source_path", Required: true}, {Name: "source_fingerprint", Required: true}, {Name: "delivery_id", Required: true}}} - planBound, planChanged, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{ - Kind: supervisor.DecisionCandidate, Transition: &planCreate, Candidates: []catalog.TransitionID{"plan.create"}, - }}) - if err != nil || !planChanged || planBound.transitionID != "plan.create" { - t.Fatalf("plan candidate rebind = options=%#v changed=%t err=%v", planBound, planChanged, err) - } - planParameters, err := parseParameters(planBound.parameters) - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"source_path", "source_fingerprint", "delivery_id"} { - if _, ok := planParameters.Get(name); !ok { - t.Fatalf("plan parameter %q was not bound", name) - } - } - - projectConfig := []byte(`{"schema_version":2,"project":{"name":"fresh-flow","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex"]}`) - writeFixture(t, repository, ".boatstack/project.json", projectConfig) - installationInitialize := catalog.Transition{ID: "installation.initialize", Parameters: []catalog.ParameterSpec{ - {Name: "config_path", Required: true}, {Name: "config_sha256", Required: true}, {Name: "runtime_version", Required: true}, {Name: "runtime_sha256", Required: true}, {Name: "source_revision", Required: true}, - }} - installationBound, installationChanged, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{ - Kind: supervisor.DecisionCandidate, Transition: &installationInitialize, Candidates: []catalog.TransitionID{"installation.initialize"}, - }}) - if err != nil || !installationChanged || installationBound.transitionID != "installation.initialize" { - t.Fatalf("installation candidate rebind = options=%#v changed=%t err=%v", installationBound, installationChanged, err) - } - installationParameters, err := parseParameters(installationBound.parameters) - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"config_path", "config_sha256", "runtime_version", "runtime_sha256", "source_revision"} { - if value, ok := installationParameters.Get(name); !ok || value == "" { - t.Fatalf("installation parameter %q = %q, %t", name, value, ok) - } - } - _, expectedConfigFingerprint, err := protocol.ProjectConfigFingerprint(projectConfig) - if err != nil { - t.Fatal(err) - } - if actual, _ := installationParameters.Get("config_sha256"); actual != expectedConfigFingerprint { - t.Fatalf("installation config fingerprint = %q, want semantic fingerprint %q", actual, expectedConfigFingerprint) - } - - for name, decision := range map[string]supervisor.Decision{ - "ambiguous": { - Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, - Candidates: []catalog.TransitionID{"objective.bind", "plan.create"}, - }, - "mismatched": { - Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, - Candidates: []catalog.TransitionID{"plan.create"}, - }, - "human-question": { - Kind: supervisor.DecisionCandidate, - Transition: &catalog.Transition{ID: "plan.approve", Parameters: []catalog.ParameterSpec{{Name: "plan_fingerprint", Required: true}}, Prescription: catalog.Prescription{AuthorityPrompt: "Approve exact plan bytes"}}, - Candidates: []catalog.TransitionID{"plan.approve"}, - }, - } { - t.Run(name, func(t *testing.T) { - result, reboundChanged, reboundErr := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &decision}) - if reboundErr != nil || reboundChanged || result.transitionID != "" || len(result.parameters) != 0 { - t.Fatalf("unsafe candidate rebound = options=%#v changed=%t err=%v", result, reboundChanged, reboundErr) - } - }) - } - - explicit := bound - explicit.transitionID = "objective.bind" - if _, changed, err := bindContinuationCandidate(context.Background(), explicit, surfaces.Response{Decision: &supervisor.Decision{ - Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"}, - }}); err != nil || changed { - t.Fatalf("explicit transition rebound changed=%t err=%v", changed, err) - } - if _, changed, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{ - Decision: &supervisor.Decision{Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"}}, - Prescription: &protocol.Prescription{TransitionID: "objective.bind"}, - }); err != nil || changed { - t.Fatalf("prescribed response rebound changed=%t err=%v", changed, err) - } -} - -func TestPublicationPreviewParametersAreRepositoryResolved(t *testing.T) { - repository := t.TempDir() - runFlowGit(t, repository, "init", "-q") - runFlowGit(t, repository, "checkout", "-q", "-b", "feature/publication") - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli"]}`)) - bodyPath := filepath.Join(repository, ".boatstack", "evidence", "delivery-pr-body.md") - writeFixture(t, repository, ".boatstack/evidence/delivery-pr-body.md", []byte("# Pull request\n")) - runFlowGit(t, repository, "add", ".") - runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") - options := commandOptions{repository: repository, transitionID: "publication.preview", host: "cli"} - if err := bindPublicationPreviewParameters(context.Background(), repository, "delivery", "cli", &options, nil); err != nil { - t.Fatal(err) - } - parameters, err := parseParameters(options.parameters) - if err != nil { - t.Fatal(err) - } - canonicalBodyPath, err := filepath.EvalSymlinks(bodyPath) - if err != nil { - t.Fatal(err) - } - for name, want := range map[string]string{"base_ref": "main", "head_ref": "feature/publication", "body_path": canonicalBodyPath} { - if got, ok := parameters.Get(name); !ok || got != want { - t.Fatalf("publication parameter %s = %q, present=%t, want %q", name, got, ok, want) - } - } -} - -func TestStateOwnedTransitionParametersDoNotRequireHumanAnswers(t *testing.T) { - state := durable.State{ - WorkspaceBranch: "feat/exact-branch", - PreviewFingerprint: strings.Repeat("a", 64), - PublicationID: "123", - TransactionID: "adm-123", - } - for transition, expected := range map[string][]string{ - "workspace.activate": {"branch=feat/exact-branch"}, - "workspace.sync": {"branch=feat/exact-branch"}, - "workspace.publish": {"branch=feat/exact-branch"}, - "publication.execute": {"preview_fingerprint=" + strings.Repeat("a", 64)}, - "publication.observe": {"publication_id=123"}, - "publication.reconcile": {"publication_id=123", "transaction_id=adm-123"}, - } { - t.Run(transition, func(t *testing.T) { - bound, err := bindStateOwnedTransitionParameters(commandOptions{transitionID: transition}, state) - if err != nil || strings.Join(bound.parameters, "\x00") != strings.Join(expected, "\x00") { - t.Fatalf("state-owned binding = %#v, %v", bound.parameters, err) - } - }) - } - - _, err := bindStateOwnedTransitionParameters(commandOptions{ - transitionID: "workspace.activate", parameters: []string{"branch=feat/other"}, - }, state) - if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { - t.Fatalf("caller override was not rejected: %v", err) - } -} +// Legacy transition-specific parameter rebinding was removed. Repository Flows now +// materialize only compiled producer declarations. func TestCommittedActiveRunRehydratesExactDeliveryWhenRunIDIsSupplied(t *testing.T) { // control-law: a resumed run resolves inputs from its committed delivery before selecting work @@ -1483,6 +1908,92 @@ func TestRepositoryNamedAbandonmentEntryUsesCompiledObjective(t *testing.T) { } } +func TestAbandonmentEntryCanReplacePreFlowActiveObjectiveWithoutReceipt(t *testing.T) { + // control-law: a trusted durable objective that predates Flow receipts can + // be abandoned in the same repository without deleting controller state. + repository := t.TempDir() + runFlowGit(t, repository, "init", "-q") + document := productDeliveryDocument("product-delivery") + truth := true + document.Facets = append(document.Facets, + controlprogram.Facet{ID: "delivery", Kind: "string"}, + controlprogram.Facet{ID: "workspace", Kind: "string"}, + ) + document.Operators = append(document.Operators, controlprogram.Operator{ + ID: "plan.abandon", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/plan.abandon", Version: "1"}, + }) + document.Transitions = append(document.Transitions, controlprogram.Transition{ + ID: "plan.abandon", Operator: "plan.abandon", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 31, + }) + document.Targets = append(document.Targets, controlprogram.Target{ID: "safely-abandoned", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ + flowFact("delivery", "discarded"), + {Fact: &controlprogram.FactPredicate{Facet: "workspace", Statuses: []string{"known"}, Values: []string{"abandoned", "absent"}}}, + }}}) + document.Entries = append(document.Entries, controlprogram.Entry{ + ID: "abandon", Target: "safely-abandoned", + Inputs: []controlprogram.EntryInput{{ + ID: "plan", Type: "markdown-file", Required: true, Resolver: "software-delivery.plan-inbox", + Config: json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`), + }}, + }) + sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" + source, lock := []byte("flow source"), []byte("lock") + writeFixture(t, repository, sourcePath, source) + writeFixture(t, repository, lockPath, lock) + writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, lock) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invoking, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "pre-flow-active-objective") + if err != nil { + t.Fatal(err) + } + layout, invoking, err := resolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + state := durable.Default(invoking, time.Now().UTC()) + state.ProgramFingerprint = strings.Repeat("a", 64) + state.Revision = 9 + state.Phase = model.PhaseActive + state.Engagement = model.EngagementCommand + state.Delivery = model.DeliveryActive + state.Objective = model.Objective{ + ID: "objective-product-delivery-run-delivery-one", TargetID: "published-pr", + TrustedClass: model.ObjectiveOpenPR, DeliveryID: "delivery-one", + } + raw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { + t.Fatal(err) + } + + bound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "abandon", host: "codex", + }) + if err != nil { + t.Fatal(err) + } + if !bound.activeFlowBound || !strings.HasPrefix(bound.runID, "run-") || bound.deliveryID != "delivery-one" || bound.targetID != "safely-abandoned" || bound.trustedObjectiveClass != string(model.ObjectiveAbandoned) { + t.Fatalf("pre-Flow abandonment binding = %#v", bound) + } + rebound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "abandon", host: "codex", runID: bound.runID, + }) + if err != nil || rebound.runID != bound.runID { + t.Fatalf("stable abandonment binding = %#v err=%v", rebound, err) + } +} + func TestFlowEntryRejectsSelectedPlanContentSubstitution(t *testing.T) { // control-law: one-flow-run-binds-the-exact-selected-plan-bytes repository := flowRepository(t) @@ -1495,7 +2006,7 @@ func TestFlowEntryRejectsSelectedPlanContentSubstitution(t *testing.T) { writeFixture(t, repository, planPath, []byte("plan B")) _, err = bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", - deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, transitionID: "plan.create", + deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, }) if err == nil || !strings.Contains(err.Error(), "FLOW_RUN_MISMATCH") { t.Fatalf("plan substitution result = %v", err) @@ -1515,18 +2026,36 @@ func TestFlowEntryPreservesSelectedPlanFilenameBeforeMaterialization(t *testing. } resumed, err := bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", - deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, transitionID: "plan.create", + deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, }) if err != nil { t.Fatal(err) } - parameters, err := parseParameters(resumed.parameters) + expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery.MD") + if source, ok := resumed.workInputs["plan"]; !ok || source.Value != expected { + t.Fatalf("resumed entry input = %#v, present=%t; want %q", source, ok, expected) + } +} + +func TestFlowEntryResumeIgnoresUnrelatedNewInboxPlan(t *testing.T) { + // control-law: one-flow-run-retains-its-selected-plan-identity-before-materialization + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery.md", []byte("selected plan")) + initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/plans/inbox/unrelated.md", []byte("different plan")) + resumed, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", + deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, + }) if err != nil { t.Fatal(err) } - expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery.MD") - if source, ok := parameters.Get("source_path"); !ok || source != expected { - t.Fatalf("resumed source = %q, present=%t; want %q", source, ok, expected) + expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery.md") + if source, ok := resumed.workInputs["plan"]; !ok || source.Value != expected { + t.Fatalf("resumed entry input = %#v, present=%t; want %q", source, ok, expected) } } @@ -1548,7 +2077,7 @@ func TestFlowEntryRejectsAmbiguousPlanFilenameOnResume(t *testing.T) { } _, err = bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", - deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, transitionID: "plan.create", + deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, }) if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_INVALID") { t.Fatalf("ambiguous resume result = %v", err) @@ -1566,7 +2095,7 @@ func TestFlowEntryRejectsObjectiveSubstitutionWithinRun(t *testing.T) { _, err = bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", host: "codex", runID: initial.runID, deliveryID: initial.deliveryID, targetID: initial.targetID, - objectiveID: "objective-substituted", transitionID: "objective.bind", + objectiveID: "objective-substituted", }) if err == nil || !strings.Contains(err.Error(), "FLOW_CONTEXT_MISMATCH") { t.Fatalf("objective substitution result = %v", err) @@ -1598,6 +2127,7 @@ func TestFlowEntryRejectsManagedPlanSymlinkEscape(t *testing.T) { _, err = bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", deliveryID: initial.deliveryID, targetID: initial.targetID, objectiveID: initial.objectiveID, + activeFlowBound: true, }) if err == nil || !strings.Contains(err.Error(), "regular non-symlink") { t.Fatalf("managed symlink result = %v", err) @@ -1795,6 +2325,13 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil || lock != nil || suspension != nil || !request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { t.Fatalf("authorized resolve = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) } + refreshedApply, _, err := refreshFlowInvocation(context.Background(), surfaces.OperationApply, request, bound) + if err != nil { + t.Fatal(err) + } + if !refreshedApply.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] || !reflect.DeepEqual(refreshedApply.Authority, request.Authority) { + t.Fatalf("direct CLI refresh dropped admitted delegation authority:\nprior=%#v\nfresh=%#v", request.Authority, refreshedApply.Authority) + } var replayedReceipt protocol.AuthorityReceipt for _, receipt := range request.Authority.Receipts { if strings.HasPrefix(receipt.ID, "delegation-") { @@ -1839,14 +2376,14 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatalf("expired delegation = lock=%v response=%#v err=%v", expiredLock, expiredSuspension, expiredErr) } renewedAt := time.Now().UTC() - renewed, changed, err := authorizeDelegation(&record, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt) + renewed, changed, err := authorizeDelegation(&record, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt, false) if err != nil || !changed || renewed.Revision != record.Revision+1 || renewed.ReceiptID == record.ReceiptID || !renewed.ExpiresAt.Equal(renewedAt.Add(time.Hour)) { t.Fatalf("renewed delegation = record=%#v changed=%v err=%v", renewed, changed, err) } - if idempotent, changedAgain, idempotentErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt.Add(time.Second)); idempotentErr != nil || changedAgain || idempotent.ReceiptID != renewed.ReceiptID { + if idempotent, changedAgain, idempotentErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt.Add(time.Second), false); idempotentErr != nil || changedAgain || idempotent.ReceiptID != renewed.ReceiptID { t.Fatalf("idempotent renewal = record=%#v changed=%v err=%v", idempotent, changedAgain, idempotentErr) } - if _, _, conflictErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, "other-actor", time.Hour, renewedAt); conflictErr == nil || !strings.Contains(conflictErr.Error(), "DELEGATION_CONFLICT") { + if _, _, conflictErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, "other-actor", time.Hour, renewedAt, false); conflictErr == nil || !strings.Contains(conflictErr.Error(), "DELEGATION_CONFLICT") { t.Fatalf("conflicting renewal = %v", conflictErr) } if err := effects.StoreDelegationRecord(recordPath, renewed); err != nil { @@ -1927,3 +2464,59 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatalf("post-target apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) } } + +func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testing.T) { + // control-law: revocation remains effective for its exact request, while an + // explicitly authorized post-installation request creates new authority. + prior := delegation.Request{ + RunID: "run-example", ProgramID: "product-delivery", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), + EntryID: "run", TargetID: "published-pr", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"plan"}, + RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", + BindingFingerprint: strings.Repeat("c", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run product delivery", + } + priorFingerprint, err := prior.Fingerprint() + if err != nil { + t.Fatal(err) + } + existing := delegation.Record{ + Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: prior, RequestFingerprint: priorFingerprint, + ReceiptID: "authorization-prior", Actor: "operator", AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 3, Status: "revoked", + } + current := prior + current.ProgramFingerprint, current.ControlBundleFingerprint = strings.Repeat("d", 64), strings.Repeat("e", 64) + currentFingerprint, err := current.Fingerprint() + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_700_000_100, 0).UTC() + refreshed, changed, err := authorizeDelegation(&existing, current, currentFingerprint, "operator", 0, now, true) + if err != nil || !changed || refreshed.Status != "active" || refreshed.Revision != 4 || refreshed.RequestFingerprint != currentFingerprint || refreshed.ReceiptID == existing.ReceiptID { + t.Fatalf("reprojected authorization = %#v changed=%t err=%v", refreshed, changed, err) + } + if _, _, err := authorizeDelegation(&existing, current, currentFingerprint, "operator", 0, now, false); err == nil { + t.Fatal("revoked authority was replaced without an admitted reprojection") + } +} + +func TestDelegationReprojectionRequiresAChangedControlBundle(t *testing.T) { + // control-law: ordinary input or context drift cannot be relabeled as an + // installation reprojection when the installed control bundle is unchanged. + request := delegation.Request{ + RunID: "run-example", ProgramID: "product-delivery", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), + EntryID: "run", TargetID: "published-pr", ObjectiveID: "objective", DeliveryID: "delivery", + RepositoryID: "repository", GitCommonID: "common", + } + changedInput := request + changedInput.InputFingerprints = []string{"changed-plan"} + admitted, err := canReprojectDelegation(ports.ControllerLayout{}, model.InvocationContext{}, request, changedInput) + if err != nil || admitted { + t.Fatalf("unchanged-bundle reprojection admitted=%t err=%v", admitted, err) + } + changedObjective := request + changedObjective.ControlBundleFingerprint = strings.Repeat("d", 64) + changedObjective.ObjectiveID = "objective-other" + admitted, err = canReprojectDelegation(ports.ControllerLayout{}, model.InvocationContext{}, request, changedObjective) + if err != nil || admitted { + t.Fatalf("changed-objective reprojection admitted=%t err=%v", admitted, err) + } +} diff --git a/boatstack/cmd/boatstack-helper/input_command.go b/boatstack/cmd/boatstack-helper/input_command.go new file mode 100644 index 0000000..232a5ad --- /dev/null +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -0,0 +1,323 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/invocation" +) + +type flowInputOptions struct { + repository string + programID string + entryID string + runID string + requestFingerprint string + answerPath string + human string + host string + format string + reason string +} + +func runFlowInput(arguments []string) error { + if len(arguments) == 0 { + return fmt.Errorf("usage: boatstack flow input [flags]") + } + action := arguments[0] + flags := flag.NewFlagSet("flow input "+action, flag.ContinueOnError) + flags.SetOutput(os.Stderr) + options := flowInputOptions{repository: ".", host: "cli", format: "json"} + flags.StringVar(&options.repository, "repo", options.repository, "repository containing the active Flow") + flags.StringVar(&options.programID, "flow", "", "repository Control Program identity") + flags.StringVar(&options.entryID, "entry", "", "named Flow entry") + flags.StringVar(&options.runID, "run-id", "", "opaque active run identity") + flags.StringVar(&options.requestFingerprint, "request-fingerprint", "", "exact transition-input request fingerprint") + flags.StringVar(&options.answerPath, "answer", "", "JSON answer object path") + flags.StringVar(&options.human, "human", "", "human actor recording the answer") + flags.StringVar(&options.host, "host", options.host, "driver host identity") + flags.StringVar(&options.format, "format", options.format, "json") + flags.StringVar(&options.reason, "reason", "", "semantic rejection reason for a new immutable request generation") + if err := flags.Parse(arguments[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected flow input arguments: %s", strings.Join(flags.Args(), " ")) + } + if action == "block" { + return fmt.Errorf("TRANSITION_INPUT_BLOCKED: no input receipt was recorded") + } + if action != "show" && action != "answer" && action != "supersede" { + return fmt.Errorf("unknown flow input action %q", action) + } + if options.programID == "" || options.entryID == "" || options.runID == "" || options.requestFingerprint == "" { + return fmt.Errorf("--flow, --entry, --run-id, and --request-fingerprint are required") + } + repository, err := filepath.Abs(options.repository) + if err != nil { + return err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return err + } + options.repository = repository + lease, err := boatstackruntime.AcquireFlowProjectionLease(repository) + if err != nil { + return err + } + defer lease.Release() + ctx := context.Background() + compiled, store, runtimeContext, err := loadFlowInputContext(ctx, options) + if err != nil { + return err + } + request, err := store.FindRequest(options.runID, options.requestFingerprint) + if err != nil { + return err + } + if request.ProgramFingerprint != compiled.Fingerprint || request.EntryID != options.entryID { + return fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: request does not belong to the selected program and entry") + } + if action == "show" { + receipts, loadErr := store.LoadReceipts(options.runID, request.TransitionID) + if loadErr != nil { + return loadErr + } + return encodeFlowInputResult(map[string]any{"request": request, "receipts": receipts}, options.format) + } + if action == "supersede" { + if options.reason == "" || options.human == "" || options.host == "" { + return fmt.Errorf("--reason, --human, and --host are required") + } + if runtimeContext.executionScopeFingerprint != request.ExecutionScopeFingerprint { + return fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: execution scope changed after suspension") + } + requestContext := invocation.Context{ + RunID: request.RunID, ProgramFingerprint: request.ProgramFingerprint, ExecutionProgramFingerprint: request.ExecutionProgramFingerprint, + EntryID: request.EntryID, TargetID: request.TargetID, TransitionID: request.TransitionID, StateRevision: request.StateRevision, + ContextFingerprint: request.ContextFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, ExecutionScopeFingerprint: request.ExecutionScopeFingerprint, + } + latest, found, latestErr := store.LatestRequest(requestContext) + if latestErr != nil { + return latestErr + } + if !found || latest.Fingerprint != request.Fingerprint { + return fmt.Errorf("FLOW_INPUT_REQUEST_SUPERSEDED: only the latest request generation can be superseded") + } + receipts, loadErr := store.LoadReceipts(request.RunID, request.TransitionID) + if loadErr != nil { + return loadErr + } + for _, parameter := range request.Parameters { + if _, answered := receipts[parameter.ID+"@"+request.Fingerprint]; !answered { + return fmt.Errorf("FLOW_INPUT_SUPERSESSION_UNANSWERED: request has no immutable answer for parameter %s", parameter.ID) + } + } + next, supersedeErr := invocation.SupersedeRequest(request, options.reason, options.human, options.host, time.Now().UTC()) + if supersedeErr != nil { + return supersedeErr + } + if saveErr := store.SaveRequest(next); saveErr != nil { + return saveErr + } + return encodeFlowInputResult(map[string]any{ + "prior_request_fingerprint": request.Fingerprint, "request": next, "status": "superseded", + }, options.format) + } + if options.answerPath == "" || options.human == "" || options.host == "" { + return fmt.Errorf("--answer, --human, and --host are required") + } + answers, err := loadFlowInputAnswers(options.answerPath) + if err != nil { + return err + } + receipts, err := recordFlowInputAnswers(store, compiled, request, runtimeContext, answers, options.human, options.host) + if err != nil { + return err + } + return encodeFlowInputResult(map[string]any{"request_fingerprint": request.Fingerprint, "receipts": receipts, "status": "recorded"}, options.format) +} + +type flowInputRuntimeContext struct { + executionScopeFingerprint string +} + +func loadFlowInputContext(ctx context.Context, options flowInputOptions) (controlprogram.Compiled, invocation.Store, flowInputRuntimeContext, error) { + raw, err := os.ReadFile(filepath.Join(options.repository, ".boatstack", "flows", options.programID+".flow.ir.json")) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, fmt.Errorf("FLOW_ARTIFACT_REQUIRED: %w", err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + bindingResolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + compiled, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, bindingResolver, generateSoftwareFlowSkills) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + resolver, err := plant.NewResolver("") + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + invoking, err := resolver.ResolveInvocation(ctx, options.repository, options.host, "flow-input-"+options.runID) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + layout, invoking, err := resolver.ResolveLayout(ctx, invoking) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + executionScopeFingerprint, err := flowExecutionScopeFingerprint(invoking) + if err != nil { + return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err + } + return compiled, invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()}, flowInputRuntimeContext{executionScopeFingerprint: executionScopeFingerprint}, nil +} + +func loadFlowInputAnswers(path string) (map[string]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var values map[string]any + if err := decoder.Decode(&values); err != nil { + return nil, fmt.Errorf("decode input answers: %w", err) + } + if nested, ok := values["answers"].(map[string]any); ok && len(values) == 1 { + values = nested + } + result := make(map[string]string, len(values)) + for name, value := range values { + switch typed := value.(type) { + case string: + result[name] = typed + case bool, json.Number, map[string]any, []any: + encoded, encodeErr := json.Marshal(typed) + if encodeErr != nil { + return nil, encodeErr + } + result[name] = string(encoded) + default: + return nil, fmt.Errorf("answer %q has an unsupported null value", name) + } + } + return result, nil +} + +func recordFlowInputAnswers(store invocation.Store, compiled controlprogram.Compiled, request invocation.InputRequest, runtimeContext flowInputRuntimeContext, answers map[string]string, actor, host string) ([]invocation.InputReceipt, error) { + if runtimeContext.executionScopeFingerprint != request.ExecutionScopeFingerprint { + return nil, fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: execution scope changed after suspension") + } + transition, ok := findCompiledTransition(compiled.Document.Transitions, request.TransitionID) + if !ok { + return nil, fmt.Errorf("FLOW_TRANSITION_UNKNOWN: %s", request.TransitionID) + } + operator, ok := findCompiledOperator(compiled.Document.Operators, transition.Operator) + if !ok { + return nil, fmt.Errorf("FLOW_OPERATOR_UNKNOWN: %s", transition.Operator) + } + contracts := map[string]controlprogram.OperatorParameter{} + for _, contract := range operator.Parameters { + contracts[contract.ID] = contract + } + producers := map[string]controlprogram.ParameterProducer{} + for _, binding := range transition.Parameters { + producers[binding.Parameter] = binding.Producer + } + requested := map[string]invocation.RequestedParameter{} + for _, parameter := range request.Parameters { + requested[parameter.ID] = parameter + } + if len(answers) != len(requested) { + return nil, fmt.Errorf("FLOW_INPUT_ANSWER_INCOMPLETE: answer must contain exactly the requested parameter IDs") + } + prior, err := store.LoadReceipts(request.RunID, request.TransitionID) + if err != nil { + return nil, err + } + result := make([]invocation.InputReceipt, 0, len(answers)) + for parameterID, value := range answers { + requestedParameter, requestedOK := requested[parameterID] + contract, contractOK := contracts[parameterID] + producer, producerOK := producers[parameterID] + if !requestedOK || !contractOK || !producerOK || producer.Kind != controlprogram.ParameterSourceHostInput || producer.Request == nil { + return nil, fmt.Errorf("FLOW_INPUT_ANSWER_UNKNOWN: %s", parameterID) + } + if requestedParameter.Secret { + return nil, fmt.Errorf("FLOW_SECRET_STORE_UNAVAILABLE: parameter %s requires a trusted secret store", parameterID) + } + if err := invocation.ValidateAnswer(contract, value, ""); err != nil { + return nil, fmt.Errorf("FLOW_INPUT_ANSWER_INVALID: parameter %s: %w", parameterID, err) + } + if existing, exists := prior[parameterID+"@"+request.Fingerprint]; exists { + if existing.Value != value || existing.RequestFingerprint != request.Fingerprint { + return nil, fmt.Errorf("FLOW_INPUT_ANSWER_CONFLICT: parameter %s already has a different receipt", parameterID) + } + result = append(result, existing) + continue + } + receipt, sealErr := invocation.SealReceipt(invocation.InputReceipt{ + RunID: request.RunID, ProgramFingerprint: request.ProgramFingerprint, ExecutionProgramFingerprint: request.ExecutionProgramFingerprint, + EntryID: request.EntryID, TargetID: request.TargetID, + TransitionID: request.TransitionID, ParameterID: parameterID, Type: contract.Type, Value: value, + ProducerFingerprint: invocation.ProducerFingerprint(producer), RequestFingerprint: request.Fingerprint, + StateRevision: request.StateRevision, ContextFingerprint: request.ContextFingerprint, ControlBundleFingerprint: request.ControlBundleFingerprint, + ExecutionScopeFingerprint: runtimeContext.executionScopeFingerprint, + Actor: actor, Host: host, AuthorityReceipts: []string{"human:" + actor}, Scope: "transition", + }) + if sealErr != nil { + return nil, sealErr + } + if err := store.SaveReceipt(receipt); err != nil { + return nil, err + } + result = append(result, receipt) + } + return result, nil +} + +func findCompiledTransition(values []controlprogram.Transition, id string) (controlprogram.Transition, bool) { + for _, value := range values { + if value.ID == id { + return value, true + } + } + return controlprogram.Transition{}, false +} + +func findCompiledOperator(values []controlprogram.Operator, id string) (controlprogram.Operator, bool) { + for _, value := range values { + if value.ID == id { + return value, true + } + } + return controlprogram.Operator{}, false +} + +func encodeFlowInputResult(value any, format string) error { + if format != "json" { + return fmt.Errorf("flow input currently requires --format json") + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/boatstack/cmd/boatstack-helper/input_command_test.go b/boatstack/cmd/boatstack-helper/input_command_test.go new file mode 100644 index 0000000..94f0ede --- /dev/null +++ b/boatstack/cmd/boatstack-helper/input_command_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" + "github.com/operatorstack/boatstack/boatstack/invocation" +) + +func TestFlowInputAnswerResumesSameRunAndConflictsFailClosed(t *testing.T) { + // control-law: a missing declared host value suspends and only an exact + // runtime-owned receipt resumes the same invocation. + repository := flowRepositoryWithHumanSlice(t) + runFlowGit(t, repository, "init", "-q") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + 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)) + + base := commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "delivery.slice.advance"} + suspended, err := bindFlowEntry(context.Background(), base) + if err != nil { + t.Fatal(err) + } + if suspended.inputRequest == nil || suspended.inputRequest.Code != "TRANSITION_INPUT_REQUIRED" || suspended.invocationEvidence != nil { + t.Fatalf("suspension = %#v", suspended.inputRequest) + } + answerPath := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"slice-one"}`), 0o600); err != nil { + t.Fatal(err) + } + arguments := []string{ + "answer", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", suspended.runID, + "--request-fingerprint", suspended.inputRequest.Fingerprint, "--answer", answerPath, "--human", "operator", "--host", "codex", "--format", "json", + } + if _, err := captureStdout(t, func() error { return runFlowInput(arguments) }); err != nil { + t.Fatal(err) + } + + resumed, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "delivery.slice.advance", + runID: suspended.runID, deliveryID: suspended.deliveryID, targetID: suspended.targetID, objectiveID: suspended.objectiveID, + }) + if err != nil { + t.Fatal(err) + } + if resumed.invocationEvidence == nil || resumed.inputRequest != nil || !strings.Contains(strings.Join(resumed.parameters, ","), "slice_id=slice-one") { + t.Fatalf("resumed invocation = evidence %#v request %#v parameters %#v", resumed.invocationEvidence, resumed.inputRequest, resumed.parameters) + } + + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"slice-two"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { return runFlowInput(arguments) }); err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_ANSWER_CONFLICT") { + t.Fatalf("conflicting answer result = %v", err) + } +} + +func TestRejectedHostAnswerCanBeSupersededWithoutMutation(t *testing.T) { + // control-law: semantic rejection preserves the original request and receipt + // while a fresh request generation can collect a corrected free-form value. + stateRoot := t.TempDir() + t.Setenv("BOATSTACK_STATE_ROOT", stateRoot) + repository := flowRepositoryWithHumanSlice(t) + runFlowGit(t, repository, "init", "-q") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + 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)) + + base := commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "delivery.slice.advance"} + first, err := bindFlowEntry(context.Background(), base) + if err != nil || first.inputRequest == nil { + t.Fatalf("first request = %#v, %v", first.inputRequest, err) + } + answerPath := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"rejected-slice"}`), 0o600); err != nil { + t.Fatal(err) + } + answer := func(fingerprint string) error { + _, answerErr := captureStdout(t, func() error { + return runFlowInput([]string{ + "answer", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", first.runID, + "--request-fingerprint", fingerprint, "--answer", answerPath, "--human", "operator", "--host", "codex", "--format", "json", + }) + }) + return answerErr + } + if err := answer(first.inputRequest.Fingerprint); err != nil { + t.Fatal(err) + } + + output, err := captureStdout(t, func() error { + return runFlowInput([]string{ + "supersede", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", first.runID, + "--request-fingerprint", first.inputRequest.Fingerprint, "--reason", "slice is outside the accepted plan", "--human", "operator", "--host", "codex", "--format", "json", + }) + }) + if err != nil { + t.Fatal(err) + } + var superseded struct { + Request invocation.InputRequest `json:"request"` + } + if err := json.Unmarshal(output, &superseded); err != nil { + t.Fatal(err) + } + second := superseded.Request + if second.EffectiveGeneration() != 2 || second.Fingerprint == first.inputRequest.Fingerprint || second.Supersession == nil || second.Supersession.PreviousRequestFingerprint != first.inputRequest.Fingerprint { + t.Fatalf("superseded request = %#v", second) + } + resuspended, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "delivery.slice.advance", + runID: first.runID, deliveryID: first.deliveryID, targetID: first.targetID, objectiveID: first.objectiveID, + }) + if err != nil || resuspended.inputRequest == nil || resuspended.inputRequest.Fingerprint != second.Fingerprint || resuspended.invocationEvidence != nil { + t.Fatalf("new generation did not suspend: %#v evidence=%#v err=%v", resuspended.inputRequest, resuspended.invocationEvidence, err) + } + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"accepted-slice"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := answer(second.Fingerprint); err != nil { + t.Fatal(err) + } + resumed, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "delivery.slice.advance", + runID: first.runID, deliveryID: first.deliveryID, targetID: first.targetID, objectiveID: first.objectiveID, + }) + if err != nil || resumed.invocationEvidence == nil || resumed.inputRequest != nil || !strings.Contains(strings.Join(resumed.parameters, ","), "slice_id=accepted-slice") { + t.Fatalf("corrected generation did not resume: parameters=%#v evidence=%#v request=%#v err=%v", resumed.parameters, resumed.invocationEvidence, resumed.inputRequest, err) + } + receiptCount := 0 + if err := filepath.WalkDir(stateRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".receipt.json") { + receiptCount++ + } + return nil + }); err != nil { + t.Fatal(err) + } + if receiptCount != 2 { + t.Fatalf("immutable receipt count = %d, want rejected and corrected generations", receiptCount) + } +} + +func TestCLIAndRPCBindingsCreateTheSameInputSuspension(t *testing.T) { + // control-law: transport selection cannot change the typed invocation + // request for one exact Flow context. + repository := flowRepositoryWithHumanSlice(t) + runFlowGit(t, repository, "init", "-q") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + 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)) + + cli, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + correlationID: "transport-parity", transitionID: "delivery.slice.advance", + }) + if err != nil { + t.Fatal(err) + } + if cli.inputRequest == nil { + t.Fatal("CLI binding did not suspend for host input") + } + rpc, err := bindRPCFlowEntry(context.Background(), surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, + Repository: repository, Host: "codex", CorrelationID: "transport-parity", + ProgramID: "product-delivery", EntryID: "run", FlowID: cli.runID, + Objective: model.Objective{ + ID: cli.objectiveID, TargetID: model.TargetID(cli.targetID), + TrustedClass: model.TargetID(cli.trustedObjectiveClass), DeliveryID: cli.deliveryID, + }, + TransitionID: "delivery.slice.advance", + }) + if err != nil { + t.Fatal(err) + } + if rpc.InputRequest == nil || rpc.InvocationEvidence != nil { + t.Fatalf("RPC binding did not create a typed suspension: %#v", rpc) + } + if rpc.InputRequest.Fingerprint != cli.inputRequest.Fingerprint || rpc.InputRequest.RunID != cli.inputRequest.RunID || rpc.InputRequest.TransitionID != cli.inputRequest.TransitionID { + t.Fatalf("CLI/RPC suspension drift:\nCLI %#v\nRPC %#v", cli.inputRequest, rpc.InputRequest) + } +} + +func TestApplyAndRecoveryDiscardRevokedInvocationEvidence(t *testing.T) { + // control-law: apply and recovery rematerialize from runtime-owned inputs; + // neither can reuse evidence after its receipt is revoked. + stateRoot := t.TempDir() + t.Setenv("BOATSTACK_STATE_ROOT", stateRoot) + repository := flowRepositoryWithHumanSlice(t) + runFlowGit(t, repository, "init", "-q") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + 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)) + + suspended, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + correlationID: "revocation", transitionID: "delivery.slice.advance", + }) + if err != nil { + t.Fatal(err) + } + answerPath := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"slice-one"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { + return runFlowInput([]string{ + "answer", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", suspended.runID, + "--request-fingerprint", suspended.inputRequest.Fingerprint, "--answer", answerPath, "--human", "operator", "--host", "codex", "--format", "json", + }) + }); err != nil { + t.Fatal(err) + } + resumed, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", correlationID: "revocation", + transitionID: "delivery.slice.advance", runID: suspended.runID, deliveryID: suspended.deliveryID, + targetID: suspended.targetID, objectiveID: suspended.objectiveID, + }) + if err != nil || resumed.invocationEvidence == nil { + t.Fatalf("resolved invocation = %#v, %v", resumed.invocationEvidence, err) + } + boundFingerprint := resumed.invocationEvidence.InvocationFingerprint + prior, err := buildRequest(surfaces.OperationApply, resumed) + if err != nil { + t.Fatal(err) + } + prior.Prescription = protocol.Prescription{InvocationFingerprint: boundFingerprint} + + removed := 0 + if err := filepath.WalkDir(stateRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".receipt.json") { + removed++ + return os.Remove(path) + } + return nil + }); err != nil { + t.Fatal(err) + } + 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 { + t.Fatalf("%s refresh: %v", operation, err) + } + if fresh.InvocationEvidence != nil || fresh.InputRequest == nil || fresh.InputRequest.Fingerprint != suspended.inputRequest.Fingerprint { + t.Fatalf("%s reused revoked evidence: evidence=%#v request=%#v", operation, fresh.InvocationEvidence, fresh.InputRequest) + } + if err := fresh.Prescription.ValidateInvocation(""); err == nil || !strings.Contains(err.Error(), "INVOCATION_DRIFT") { + t.Fatalf("%s old prescription result = %v", operation, err) + } + } +} diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 25f41a0..557ddc0 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -28,6 +28,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" + "github.com/operatorstack/boatstack/boatstack/invocation" general "github.com/operatorstack/boatstack/boatstack/kernel" ) @@ -67,6 +68,8 @@ type commandOptions struct { repositoryPolicy bool acceptProgramChange bool parameters stringList + entryInputs stringList + maintenanceParameterSurface bool authorityReceipts stringList trustedAuthorityReceipts []protocol.AuthorityReceipt follow bool @@ -77,6 +80,7 @@ type commandOptions struct { delegationAuthorities stringList delegationDescription string delegationRequest delegation.Request + delegationReprojection bool workInputs map[string]protocol.WorkInputValue workID string workQuestionPrompt string @@ -87,6 +91,8 @@ type commandOptions struct { workResultFingerprint string controlBundle *boatstackruntime.ControlBundleContract controlBundleFingerprint string + invocationEvidence *invocation.Evidence + inputRequest *invocation.InputRequest } func main() { @@ -148,6 +154,13 @@ func run(arguments []string) error { return err } } + programChangeResponse, err := preflightDelegatedProgramChange(context.Background(), request) + if err != nil { + return err + } + if programChangeResponse != nil { + return renderResponse(*programChangeResponse, options.format) + } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) if err != nil { return err @@ -163,6 +176,12 @@ func run(arguments []string) error { return err } defer lease.Release() + if (operation == surfaces.OperationApply || operation == surfaces.OperationRecover) && request.ProgramID != "" { + request, options, err = refreshFlowInvocation(context.Background(), operation, request, options) + if err != nil { + return err + } + } if err := verifyTrustedRequestControlBundle(request); err != nil { return err } @@ -193,6 +212,9 @@ func run(arguments []string) error { request.Prescription = *resolved.Prescription } response, handleErr := kernel.Handle(context.Background(), request) + if handleErr == nil && operation == surfaces.OperationResolve { + request, response, _, handleErr = stabilizeRepositoryPrescription(context.Background(), request, response) + } 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 @@ -234,6 +256,15 @@ func runRPC() error { return err } } + programChangeResponse, err := preflightDelegatedProgramChange(context.Background(), request) + if err != nil { + return err + } + if programChangeResponse != nil { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(programChangeResponse) + } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) if err != nil { return err @@ -251,6 +282,12 @@ func runRPC() error { return err } defer lease.Release() + if (request.Operation == surfaces.OperationApply || request.Operation == surfaces.OperationRecover) && request.ProgramID != "" { + request, err = refreshRPCFlowInvocation(context.Background(), request) + if err != nil { + return err + } + } if err := verifyTrustedRequestControlBundle(request); err != nil { return err } @@ -259,6 +296,9 @@ func runRPC() error { return err } response, handleErr := kernel.Handle(context.Background(), request) + if handleErr == nil && request.Operation == surfaces.OperationResolve { + request, response, _, handleErr = stabilizeRepositoryPrescription(context.Background(), request, response) + } 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 @@ -348,7 +388,10 @@ func parseOptions(command string, arguments []string, transition catalog.Transit if command == "explain" { defaultFormat = "text" } - options := commandOptions{format: defaultFormat, transitionID: string(transition), host: "cli"} + options := commandOptions{ + format: defaultFormat, transitionID: string(transition), host: "cli", + maintenanceParameterSurface: command == "init" || command == "update" || command == "reconcile-update" || command == "hydrate-runtime" || command == "configure", + } if defaults != nil { options.targetID, options.deliveryID, options.objectiveID = defaults["target-id"], defaults["delivery"], defaults["objective-id"] } @@ -376,6 +419,7 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from Boatstack project configuration") flags.BoolVar(&options.acceptProgramChange, "accept-program-change", false, "explicitly accept the exact prior-to-candidate control-program delta during update") flags.Var(&options.parameters, "param", "transition parameter name=value (repeatable)") + flags.Var(&options.entryInputs, "input", "Flow entry input name=value (repeatable)") flags.Var(&options.authorityReceipts, "authority-receipt", "authority receipt JSON path (repeatable)") flags.BoolVar(&options.follow, "follow", false, "follow passive process events (events with jsonl only)") flags.StringVar(&options.host, "host", options.host, "cli, sdk, cursor, codex, claude, gemini, or mcp") @@ -452,6 +496,52 @@ func acquireFlowExecutionLease(request surfaces.Request) (*boatstackruntime.Flow return boatstackruntime.AcquireFlowProjectionLease(request.Repository) } +func refreshFlowInvocation(ctx context.Context, operation surfaces.Operation, prior surfaces.Request, options commandOptions) (surfaces.Request, commandOptions, error) { + prescription := prior.Prescription + repositoryTransition, err := repositoryFlowDeclaresTransition(options.repository, options.programID, options.transitionID) + if err != nil { + return surfaces.Request{}, commandOptions{}, err + } + if repositoryTransition { + options.parameters = nil + } else if options.transitionID != "" { + options.maintenanceParameterSurface = true + } + options.invocationEvidence, options.inputRequest = nil, nil + fresh, err := bindFlowEntry(ctx, options) + if err != nil { + return surfaces.Request{}, commandOptions{}, err + } + request, err := buildRequest(operation, fresh) + if err != nil { + return surfaces.Request{}, commandOptions{}, err + } + // Delegation is admitted before the Flow invocation is refreshed under the + // execution lease. Preserve that exact external authority evidence; the + // refresh owns producer evidence, not authority re-admission. + request.Authority = prior.Authority + request.Prescription = prescription + return request, fresh, nil +} + +func refreshRPCFlowInvocation(ctx context.Context, prior surfaces.Request) (surfaces.Request, error) { + prescription := prior.Prescription + repositoryTransition, err := repositoryFlowDeclaresTransition(prior.Repository, prior.ProgramID, string(prior.TransitionID)) + if err != nil { + return surfaces.Request{}, err + } + if repositoryTransition { + prior.Parameters = nil + } + prior.InvocationEvidence, prior.InputRequest = nil, nil + fresh, err := bindRPCFlowEntryWithMaintenance(ctx, prior, !repositoryTransition) + if err != nil { + return surfaces.Request{}, err + } + fresh.Prescription = prescription + return fresh, nil +} + func followEvents(kernel boatstack.DeliveryController, request surfaces.Request) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() @@ -623,6 +713,10 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface if flowID == "" && (operation == surfaces.OperationApply || operation == surfaces.OperationRecover) { flowID = "flow-" + correlation } + invocationFingerprint := "" + if options.invocationEvidence != nil { + invocationFingerprint = options.invocationEvidence.InvocationFingerprint + } return surfaces.Request{ SchemaVersion: surfaces.SchemaVersion, Operation: operation, Repository: options.repository, Host: options.host, CorrelationID: correlation, ProgramID: options.programID, ProgramFingerprint: options.flowProgramFingerprint, EntryID: options.entryID, FlowID: flowID, Objective: objective, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters, @@ -631,7 +725,7 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface ExpectedInstanceID: options.expectedInstanceID, ExpectedStateRevision: options.expectedStateRevision, ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint, ExpectedObjectiveBindingFingerprint: options.expectedObjectiveBindingFingerprint, AuthorityFingerprint: options.authorityFingerprint, - }, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities, WorkResultFingerprint: options.workResultFingerprint}, + }, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities, WorkResultFingerprint: options.workResultFingerprint, InvocationFingerprint: invocationFingerprint}, RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command, DelegationBindingFingerprint: options.delegationBindingFingerprint, DelegationRequestFingerprint: options.delegationRequestFingerprint, @@ -643,6 +737,8 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface WorkBlockReason: options.workBlockReason, ControlBundle: options.controlBundle, ControlBundleFingerprint: options.controlBundleFingerprint, + InvocationEvidence: options.invocationEvidence, + InputRequest: options.inputRequest, }, nil } @@ -809,6 +905,13 @@ func renderResponse(response surfaces.Response, format string) error { response.Doctor.ProgramFingerprint, response.Doctor.UnresolvedProgramDrift, response.Doctor.RuntimeHealthy, response.Doctor.UpdateReady, response.Doctor.RecoveryRequired, response.Doctor.Snapshot, response.Doctor.Detail) return nil } + if response.InputRequest != nil { + fmt.Printf("SUSPENDED: %s transition=%s request=%s state_revision=%d\n", response.InputRequest.Code, response.InputRequest.TransitionID, response.InputRequest.Fingerprint, response.InputRequest.StateRevision) + for _, parameter := range response.InputRequest.Parameters { + fmt.Printf("input=%s type=%s %s\n", parameter.ID, parameter.Type.Kind, parameter.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/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index 268c986..d0e416d 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -117,7 +117,7 @@ func TestPublicationRequestsDeriveTrustedProviderAuthorityFromCatalogBinding(t * for transition, parameter := range map[string]string{ "publication.execute": "preview_fingerprint=" + strings.Repeat("a", 64), "publication.correct": "body_sha256=" + strings.Repeat("b", 64), - "publication.reconcile": "publication_id=123", + "publication.reconcile": "transaction_id=transaction-123", } { t.Run(transition, func(t *testing.T) { options := commandOptions{repository: ".", transitionID: transition, parameters: stringList{parameter}} diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go new file mode 100644 index 0000000..d7e6c10 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -0,0 +1,490 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "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/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T) { + // control-law: the exact reference product-delivery Flow can advance from + // one inbox plan to its marked target without human-produced deterministic + // parameters. Provider authority is exercised unchanged through a fake CLI. + if runtime.GOOS == "windows" { + t.Skip("the POSIX fake publication provider is exercised by the Linux and macOS jobs") + } + stateRoot, runtimeHome := t.TempDir(), t.TempDir() + t.Setenv("BOATSTACK_STATE_ROOT", stateRoot) + t.Setenv(boatstackruntime.HomeEnvironment, runtimeHome) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + runtimeRaw, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + if _, err := boatstackruntime.InstallExecutable(executable, runtimeHome, boatstackruntime.Identity{Version: buildinfo.Version, SHA256: hash(runtimeRaw), SourceRevision: buildRevision()}); err != nil { + t.Fatal(err) + } + + document, sourceRaw, lockRaw, assets := exactProductDeliveryFixture(t) + repository := t.TempDir() + 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/project.json", []byte(`{"schema_version":2,"project":{"name":"todo","default_branch":"main","commands":{"build":"true","test":"true"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional","external_effect_authority":"human-or-autonomy-plus-provider","independent_review_for_high_risk":false},"hosts":["cli","codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/plans/inbox/todo.md", []byte("# Add one todo\n")) + for path, content := range assets { + writeFixture(t, repository, path, content) + } + writeFixture(t, repository, ".boatstack/publication/todo.body.md", []byte("# Add one todo\n\nDeterministic test publication.\n")) + sourcePath, lockPath := "boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts", "package-lock.json" + writeFixture(t, repository, sourcePath, sourceRaw) + writeFixture(t, repository, lockPath, lockRaw) + writeFlowArtifact(t, repository, document, sourcePath, sourceRaw, lockPath, lockRaw) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + + bare := filepath.Join(t.TempDir(), "todo.git") + runFlowGit(t, repository, "init", "--bare", bare) + runFlowGit(t, repository, "remote", "add", "origin", bare) + runFlowGit(t, repository, "push", "-q", "-u", "origin", "main") + installFakePublicationProvider(t) + initialize, err := captureRunOutput(t, + "init", "--repo", repository, "--flow", "product-delivery", "--entry", "run", + "--param", "config_path="+filepath.Join(repository, ".boatstack", "project.json"), "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("initialize: %v\n%s", err, initialize) + } + var initialized surfaces.Response + if err := json.Unmarshal(initialize, &initialized); err != nil { + t.Fatal(err) + } + if initialized.Receipt == nil || initialized.Receipt.TransitionID != "installation.initialize" { + t.Fatalf("initialization response = %#v", initialized) + } + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "install Boatstack control bundle") + runFlowGit(t, repository, "push", "-q", "origin", "main") + + first, err := captureStdout(t, func() error { + return runFlowContinuation([]string{"--repo", repository, "--flow", "product-delivery", "--entry", "run", "--host", "codex", "--format", "json"}) + }) + if err != nil { + t.Fatalf("delegation suspension: %v\n%s", err, first) + } + var delegated surfaces.Response + if err := json.Unmarshal(first, &delegated); err != nil { + t.Fatal(err) + } + if delegated.Delegation == nil || delegated.Delegation.RunID == "" { + t.Fatalf("delegation response = %#v", delegated) + } + runID := delegated.Delegation.RunID + t.Logf("SUSPENSION delegation run=%s request=%s authorities=%v", runID, delegated.Delegation.RequestFingerprint, delegated.Delegation.Authorities) + if _, err := captureStdout(t, func() error { + return runFlowAuthorize([]string{ + "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, + "--request-fingerprint", delegated.Delegation.RequestFingerprint, "--human", "operator", "--host", "codex", + }) + }); err != nil { + t.Fatal(err) + } + 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"}) + }) + if err != nil { + t.Fatalf("planning suspension: %v\n%s", err, workOutput) + } + var workResponse surfaces.Response + if err := json.Unmarshal(workOutput, &workResponse); err != nil { + t.Fatal(err) + } + if workResponse.Work == nil || workResponse.Work.Status != foregroundwork.StatusRequested { + t.Fatalf("foreground work response = %#v", workResponse) + } + t.Logf("SUSPENSION work run=%s request=%s transition=%s", runID, workResponse.Work.Request.Fingerprint, workResponse.Work.Request.TransitionID) + writePlanningOutputs(t, *workResponse.Work) + completeOutput, err := captureStdout(t, func() error { + return runFlowWork([]string{ + "complete", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, + "--work-id", "planning-package", "--host", "codex", "--format", "json", + }) + }) + if err != nil { + t.Fatalf("complete planning work: %v\n%s", err, completeOutput) + } + var completed surfaces.Response + if err := json.Unmarshal(completeOutput, &completed); err != nil { + t.Fatal(err) + } + if completed.Work == nil || completed.Work.Result == nil { + t.Fatalf("completed work response = %#v", completed) + } + t.Logf("WORK completed result=%s", completed.Work.Result.ResultFingerprint) + + continuation, err := parseOptions("flow run", []string{ + "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, + "--repository-authority", "--host", "codex", "--format", "json", + }, "", nil) + if err != nil { + t.Fatal(err) + } + var final surfaces.Response + for step := 0; step < 64; step++ { + final, err = executeContinuationStep(context.Background(), continuation) + if err != nil { + if strings.Contains(err.Error(), "TRANSITION_INPUT_BLOCKED: canonical parameter artifact is unavailable") { + if continuation.repository == repository { + continuation.repository = managedFlowWorktree(t, repository) + } + writeGateInputs(t, continuation.repository, "todo") + inspectActiveFlow(t, continuation.repository, runID) + t.Logf("SUSPENSION evidence transition=gate.build.record reason=%s", err) + continue + } + t.Fatalf("full continuation step %d: %v", step, err) + } + if final.RunID != "" { + continuation.runID = final.RunID + } + if final.Objective.ID != "" { + continuation.objectiveID = final.Objective.ID + continuation.targetID = string(final.Objective.TargetID) + continuation.trustedObjectiveClass = string(final.Objective.TrustedObjectiveClass()) + continuation.deliveryID = final.Objective.DeliveryID + } + if final.Receipt != nil { + t.Logf("STEP %d transition=%s invocation=%s receipt=%s effects=%d outputs=%v", step, final.Receipt.TransitionID, final.Receipt.InvocationFingerprint, final.Receipt.ID, len(final.Receipt.CommittedEffects), final.Receipt.EffectOutputs) + } + if final.Decision != nil && final.Decision.Kind == "TERMINAL" { + break + } + if final.Decision != nil && strings.Contains(final.Decision.Reason, "gate evidence must be") && final.Snapshot != nil { + continuation.repository = final.Snapshot.Invocation.InvokingPath + writeGateInputs(t, continuation.repository, "todo") + t.Logf("SUSPENSION evidence transition=%s reason=%s", final.Invocation.TransitionID, final.Decision.Reason) + continue + } + if final.InputRequest != nil { + if len(final.InputRequest.Parameters) != 1 || final.InputRequest.Parameters[0].ID != "slice_id" { + t.Fatalf("deterministic parameter requested human text: %#v", final.InputRequest) + } + answerPath := filepath.Join(t.TempDir(), "slice.json") + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"slice-one"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { + return runFlowInput([]string{ + "answer", "--repo", continuation.repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, + "--request-fingerprint", final.InputRequest.Fingerprint, "--answer", answerPath, "--human", "operator", "--host", "codex", "--format", "json", + }) + }); err != nil { + t.Fatal(err) + } + t.Logf("SUSPENSION input transition=%s request=%s parameter=slice_id", final.InputRequest.TransitionID, final.InputRequest.Fingerprint) + continue + } + if final.Receipt == nil || final.Prescription == nil { + t.Fatalf("unexpected non-terminal continuation response at step %d: %#v", step, final) + } + if err := advanceContinuation(&continuation, final); err != nil { + t.Fatal(err) + } + } + if final.Decision == nil || final.Decision.Kind != "TERMINAL" || final.Snapshot == nil { + t.Fatalf("terminal response = %#v", final) + } + if final.Snapshot.Verification.Value != model.VerificationCurrent || final.Snapshot.Configuration.Value != model.ConfigurationVerified || final.Snapshot.Runtime.Value != model.RuntimeVerified || final.Snapshot.Publication.Value != model.PublicationOpen { + t.Fatalf("terminal marked state = verification=%s configuration=%s runtime=%s publication=%s", final.Snapshot.Verification.Value, final.Snapshot.Configuration.Value, final.Snapshot.Runtime.Value, final.Snapshot.Publication.Value) + } + receipts := committedFlowReceipts(t, continuation.repository, runID) + if len(receipts) == 0 { + t.Fatal("full Flow produced no committed receipts") + } + wantTrace := []string{ + "installation.initialize", "objective.bind", "engagement.begin", + "planning.package.admit", "planning.package.approve", "planning.package.promote", "plan.activate", + "workspace.cut", "workspace.activate", + "gate.build.record", "gate.test.record", "gate.review.record", + "publication.preview", "publication.execute", "publication.observe", + } + if len(receipts) != len(wantTrace) { + t.Fatalf("committed transition count = %d, want %d", len(receipts), len(wantTrace)) + } + for _, receipt := range receipts { + classes := make([]string, 0, len(receipt.AuthoritySources)) + for _, source := range receipt.AuthoritySources { + classes = append(classes, string(source.Class)) + } + t.Logf("TRACE seq=%d transition=%s invocation=%s receipt=%s authority=%v effects=%d outputs=%v", receipt.Sequence, receipt.TransitionID, receipt.InvocationFingerprint, receipt.ID, classes, len(receipt.CommittedEffects), receipt.EffectOutputs) + } + for index, transitionID := range wantTrace { + if receipts[index].TransitionID != catalog.TransitionID(transitionID) { + t.Fatalf("trace transition %d = %s, want %s", index+1, receipts[index].TransitionID, transitionID) + } + } + last := receipts[len(receipts)-1] + if last.TransitionID != "publication.observe" || final.Snapshot.Publication.Value != model.PublicationOpen { + t.Fatalf("final publication observation = receipt=%s state=%s", last.TransitionID, final.Snapshot.Publication.Value) + } + var replayReceipt protocol.TransitionReceipt + for _, receipt := range receipts { + if receipt.TransitionID == "gate.review.record" { + replayReceipt = receipt + } + } + if replayReceipt.ID == "" { + t.Fatal("full Flow produced no gate-review receipt to replay") + } + if err := os.Remove(filepath.Join(continuation.repository, ".boatstack", "evidence", "todo", "review.input.json")); err != nil { + t.Fatal(err) + } + replayedRequest, err := bindRPCFlowEntry(context.Background(), surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, + Repository: continuation.repository, Host: "codex", CorrelationID: "committed-replay", + ProgramID: "product-delivery", ProgramFingerprint: documentFingerprint(t, document), EntryID: "run", FlowID: runID, + TransitionID: replayReceipt.TransitionID, IdempotencyKey: replayReceipt.IdempotencyKey, + Prescription: protocol.Prescription{ID: replayReceipt.PrescriptionID, InvocationFingerprint: replayReceipt.InvocationFingerprint}, + }) + if err != nil { + t.Fatalf("committed replay rematerialized consumed producer input: %v", err) + } + if replayedRequest.InvocationEvidence != nil || replayedRequest.ControlBundle != nil || len(replayedRequest.Parameters) != 0 { + t.Fatalf("committed replay crossed producer materialization: %#v", replayedRequest) + } + t.Logf("TERMINAL target=published-pr verification=%s configuration=%s runtime=%s publication=%s snapshot=%s", final.Snapshot.Verification.Value, final.Snapshot.Configuration.Value, final.Snapshot.Runtime.Value, final.Snapshot.Publication.Value, final.Snapshot.Fingerprint) +} + +func documentFingerprint(t *testing.T, document controlprogram.Document) string { + t.Helper() + compiled, err := controlprogram.Compile(document, mustSoftwareResolver(t)) + if err != nil { + t.Fatal(err) + } + return compiled.Fingerprint +} + +func mustSoftwareResolver(t *testing.T) softwareflow.Resolver { + t.Helper() + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + return resolver +} + +func inspectActiveFlow(t *testing.T, repository, runID string) { + t.Helper() + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "inspect-active-flow") + if err != nil { + t.Fatal(err) + } + layout, invocation, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + state, err := durable.DecodeState(raw) + if err != nil { + t.Fatal(err) + } + objective, ok := state.ActiveObjective() + if !ok { + t.Fatalf("managed worktree has no active objective: %#v", state) + } + receipt, found, err := effects.FindLatestCommittedFlowForObjective(layout, invocation, objective, state.Revision) + if err != nil || !found || receipt.FlowID != runID { + t.Fatalf("managed active Flow receipt = %#v found=%t err=%v want=%s state-revision=%d journal=%s", receipt, found, err, runID, state.Revision, layout.JournalRoot) + } +} + +func managedFlowWorktree(t *testing.T, repository string) string { + t.Helper() + canonicalRepository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + output := runFlowGitOutput(t, repository, "worktree", "list", "--porcelain") + for _, line := range strings.Split(output, "\n") { + if !strings.HasPrefix(line, "worktree ") { + continue + } + path := strings.TrimPrefix(line, "worktree ") + canonicalPath, canonicalErr := filepath.EvalSymlinks(path) + if canonicalErr != nil { + t.Fatal(canonicalErr) + } + if canonicalPath != canonicalRepository { + return canonicalPath + } + } + t.Fatal("managed Flow worktree was not created") + return "" +} + +func writeGateInputs(t *testing.T, repository, deliveryID string) { + t.Helper() + revision := runFlowGitOutput(t, repository, "rev-parse", "HEAD") + for _, gate := range []string{"build", "test", "review", "change", "journey"} { + raw, err := json.Marshal(map[string]any{ + "schema_version": 1, "gate": gate, "source_revision": revision, "outcome": "passed", + "producer": "deterministic-fake-provider", "completed_at": time.Unix(1_700_000_000, 0).UTC(), + }) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, filepath.Join(".boatstack", "evidence", deliveryID, gate+".input.json"), append(raw, '\n')) + } +} + +func exactProductDeliveryFixture(t *testing.T) (controlprogram.Document, []byte, []byte, map[string][]byte) { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate exact product-delivery fixture") + } + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) + repositoryRoot := filepath.Dir(moduleRoot) + source := filepath.Join(moduleRoot, "testdata", "control-programs", "product-delivery-planning-package.flow.ts") + raw, err := os.ReadFile(filepath.Join(moduleRoot, "testdata", "control-programs", "product-delivery-planning-package.raw.json")) + if err != nil { + t.Fatalf("read checked exact product-delivery fixture: %v", err) + } + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.LoadWithAssets(bytes.NewReader(raw), resolver, controlprogram.RepositoryAssetResolver{Repository: repositoryRoot}) + if err != nil { + t.Fatal(err) + } + sourceRaw, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + lockRaw, err := os.ReadFile(filepath.Join(repositoryRoot, "package-lock.json")) + if err != nil { + t.Fatal(err) + } + assets := map[string][]byte{} + for _, path := range []string{ + "boatstack/testdata/control-programs/assets/planning-package.md", + "boatstack/testdata/control-programs/assets/planning-list.schema.json", + } { + raw, readErr := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(path))) + if readErr != nil { + t.Fatal(readErr) + } + assets[path] = raw + } + return compiled.Document, sourceRaw, lockRaw, assets +} + +func installFakePublicationProvider(t *testing.T) { + t.Helper() + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + gitScript := fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = remote ] && [ \"$2\" = get-url ] && [ \"$3\" = --push ] && [ \"$4\" = origin ]; then\n echo git@github.com:operatorstack/todo.git\n exit 0\nfi\nexec %q \"$@\"\n", realGit) + ghScript := fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = repo ] && [ \"$2\" = view ]; then\n echo '{\"nameWithOwner\":\"operatorstack/todo\",\"url\":\"https://github.com/operatorstack/todo\",\"viewerPermission\":\"WRITE\"}'\n exit 0\nfi\nif [ \"$1\" = pr ] && [ \"$2\" = create ]; then\n echo 'https://github.com/operatorstack/todo/pull/17'\n exit 0\nfi\nif [ \"$1\" = pr ] && [ \"$2\" = view ]; then\n head=$(%q rev-parse HEAD)\n branch=$(%q branch --show-current)\n printf '{\"state\":\"OPEN\",\"url\":\"https://github.com/operatorstack/todo/pull/17\",\"number\":17,\"mergedAt\":\"\",\"baseRefName\":\"main\",\"headRefName\":\"%%s\",\"headRefOid\":\"%%s\",\"isCrossRepository\":false}\\n' \"$branch\" \"$head\"\n exit 0\nfi\necho unsupported fake gh command >&2\nexit 2\n", realGit, realGit) + for name, content := range map[string]string{"git": gitScript, "gh": ghScript} { + if err := os.WriteFile(filepath.Join(bin, name), []byte(content), 0o700); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func writePlanningOutputs(t *testing.T, record foregroundwork.Record) { + t.Helper() + for _, output := range record.Request.Contract.Outputs { + if !output.Required { + continue + } + path := filepath.Join(record.Request.StagingRoot, filepath.FromSlash(output.Path)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + content := []byte("# Deterministic planning output\n") + if output.MediaType == "application/json" { + content = []byte(`{"items":[]}` + "\n") + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + } +} + +func committedFlowReceipts(t *testing.T, repository, runID string) []protocol.TransitionReceipt { + t.Helper() + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invoking, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "e2e-trace") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invoking) + if err != nil { + t.Fatal(err) + } + file, err := os.Open(layout.ReceiptPath) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var receipts []protocol.TransitionReceipt + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var receipt protocol.TransitionReceipt + if err := json.Unmarshal(scanner.Bytes(), &receipt); err != nil { + t.Fatal(err) + } + if receipt.FlowID == runID { + receipts = append(receipts, receipt) + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + sort.Slice(receipts, func(i, j int) bool { return receipts[i].Sequence < receipts[j].Sequence }) + return receipts +} diff --git a/boatstack/cmd/boatstack-helper/work_output_invocation_test.go b/boatstack/cmd/boatstack-helper/work_output_invocation_test.go new file mode 100644 index 0000000..f5691e3 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/work_output_invocation_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestWorkOutputProducerRejectsStaleExecutionScope(t *testing.T) { + // control-law: completed work is evidence only for its exact run, program, + // entry, objective, source scope, contract, transition, and entry inputs. + work := controlprogram.WorkContract{ + ID: "planning-package", Instructions: controlprogram.WorkAsset{Path: "instructions.md", SHA256: strings.Repeat("a", 64), Content: "Plan."}, + Inputs: []controlprogram.WorkInput{{ID: "plan", EntryInput: "plan"}}, + Outputs: []controlprogram.WorkOutput{{ID: "result", Path: "result.json", MediaType: "application/json", Required: true, MaxBytes: 1024}}, + } + contract, err := softwareflow.RuntimeWorkContract(work) + if err != nil { + t.Fatal(err) + } + programFingerprint := strings.Repeat("b", 64) + requestFingerprint := strings.Repeat("c", 64) + contextFingerprint := strings.Repeat("d", 64) + current := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main"} + objective := model.Objective{ID: "objective", TargetID: "target", TrustedClass: "target", DeliveryID: "delivery"} + result, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ + SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: "work-request", RequestFingerprint: requestFingerprint, + ContractID: contract.ID, ContractFingerprint: contract.Fingerprint, TransitionID: "planning.admit", + ProgramFingerprint: programFingerprint, ContextFingerprint: contextFingerprint, StateRevision: 3, + RepositoryID: current.RepositoryID, WorktreeID: current.WorktreeID, + Outputs: []protocol.WorkOutputEvidence{{ID: "result", Path: "result.json", MediaType: "application/json", SHA256: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", Size: 2, Content: "{}"}}, + }) + if err != nil { + t.Fatal(err) + } + record := foregroundwork.Record{Status: foregroundwork.StatusCompleted, Request: foregroundwork.Request{ + ID: "work-request", Fingerprint: requestFingerprint, RunID: "run-1", ProgramID: "fixture", EntryID: "run", Objective: objective, + TransitionID: "planning.admit", Contract: *contract, Inputs: []foregroundwork.InputBinding{{ID: "plan", EntryInput: "plan", Value: "plan.md", Fingerprint: strings.Repeat("e", 64)}}, + RepositoryID: current.RepositoryID, GitCommonID: current.GitCommonID, WorktreeID: current.WorktreeID, Ref: current.Ref, + ProgramFingerprint: programFingerprint, ContextFingerprint: contextFingerprint, StateRevision: 3, + }, Result: &result} + compiled := controlprogram.Compiled{Fingerprint: programFingerprint, Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "fixture"}, Work: []controlprogram.WorkContract{work}, + Transitions: []controlprogram.Transition{{ID: "planning.admit", Work: work.ID}}, + }} + entry := controlprogram.Entry{ID: "run"} + options := commandOptions{runID: "run-1", objectiveID: objective.ID, targetID: string(objective.TargetID), deliveryID: objective.DeliveryID, workInputs: map[string]protocol.WorkInputValue{"plan": {Value: "plan.md", Fingerprint: strings.Repeat("e", 64)}}} + if err := validateWorkOutputProducer(record, work, compiled, entry, options, current); err != nil { + t.Fatal(err) + } + drifted := current + drifted.WorktreeID = "other-worktree" + if err := validateWorkOutputProducer(record, work, compiled, entry, options, drifted); err == nil || !strings.Contains(err.Error(), "FLOW_WORK_EVIDENCE_STALE") { + t.Fatalf("stale work output result = %v", err) + } +} diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index a9ac233..a666b23 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -15,7 +15,7 @@ import ( const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 2 + ArtifactSchemaRevision = 3 ) type Artifact struct { diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index dde875f..a9a37da 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -144,6 +144,9 @@ func compile(document Document, resolver BindingResolver, assets AssetResolver) if err := normalizeTransitions(&document, facets, operators, work); err != nil { return Compiled{}, err } + if err := normalizeInvocationCompleteness(&document, operators, work, facets, resolver); err != nil { + return Compiled{}, err + } semantic := stripDescriptions(document) canonical, err := json.Marshal(semantic) @@ -308,12 +311,12 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi if op.Binding.Fingerprint != resolved.Fingerprint { return nil, invalid("operators."+op.ID+".binding", "binding fingerprint drift") } - expected := Operator{ID: op.ID, Binding: &OperatorBinding{Reference: op.Binding.Reference, Version: op.Binding.Version, Fingerprint: resolved.Fingerprint}, Capabilities: resolved.Capabilities, Authority: resolved.Authority, Effects: resolved.Effects, Verifier: resolved.Verifier, Recovery: resolved.Recovery, StateEffect: &resolved.StateEffect, ExecutionContext: resolved.ExecutionContext} + expected := Operator{ID: op.ID, Binding: &OperatorBinding{Reference: op.Binding.Reference, Version: op.Binding.Version, Fingerprint: resolved.Fingerprint}, Capabilities: resolved.Capabilities, Authority: resolved.Authority, Effects: resolved.Effects, Verifier: resolved.Verifier, Recovery: resolved.Recovery, StateEffect: &resolved.StateEffect, ExecutionContext: resolved.ExecutionContext, Parameters: resolved.Parameters, Outputs: resolved.Outputs, StateInputs: resolved.StateInputs, ReceiptInputs: resolved.ReceiptInputs} expectedBinding = &expected } else { op.Binding.Fingerprint = resolved.Fingerprint op.Capabilities, op.Authority, op.Effects = resolved.Capabilities, resolved.Authority, resolved.Effects - op.Verifier, op.Recovery, op.StateEffect, op.ExecutionContext = resolved.Verifier, resolved.Recovery, &resolved.StateEffect, resolved.ExecutionContext + op.Verifier, op.Recovery, op.StateEffect, op.ExecutionContext, op.Parameters, op.Outputs, op.StateInputs, op.ReceiptInputs = resolved.Verifier, resolved.Recovery, &resolved.StateEffect, resolved.ExecutionContext, resolved.Parameters, resolved.Outputs, resolved.StateInputs, resolved.ReceiptInputs } } var err error @@ -326,6 +329,18 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi if op.Authority.AllOf, err = normalizedReferenceSet("operators."+op.ID+".authority.all_of", op.Authority.AllOf); err != nil { return nil, err } + if err := normalizeOperatorParameters(op, resolver); err != nil { + return nil, err + } + if err := normalizeOperatorOutputs(op, resolver); err != nil { + return nil, err + } + if err := normalizeOperatorStateInputs(op, facets); err != nil { + return nil, err + } + if err := normalizeOperatorReceiptInputs(op); err != nil { + return nil, err + } if op.ExecutionContext != "preserve" && op.ExecutionContext != "advance" { return nil, invalid("operators."+op.ID+".execution_context", "must be preserve or advance") } @@ -341,6 +356,10 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi document.Declarations.Authorities = union(document.Declarations.Authorities, op.Authority.AllOf) document.Declarations.Effects = union(document.Declarations.Effects, op.Effects) document.Declarations.Verifiers = union(document.Declarations.Verifiers, []string{op.Verifier}) + for _, parameter := range op.Parameters { + document.Declarations.Authorities = union(document.Declarations.Authorities, parameter.Authority.AnyOf) + document.Declarations.Authorities = union(document.Declarations.Authorities, parameter.Authority.AllOf) + } } if missing := firstUndeclared(op.Capabilities, document.Declarations.Capabilities); missing != "" { return nil, invalid("operators."+op.ID+".capabilities", "undeclared "+missing) @@ -371,6 +390,10 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi expectedBinding.Authority.AnyOf, _ = normalizedReferenceSet("binding.authority.any_of", expectedBinding.Authority.AnyOf) expectedBinding.Authority.AllOf, _ = normalizedReferenceSet("binding.authority.all_of", expectedBinding.Authority.AllOf) expectedBinding.Effects, _ = normalizedReferenceSet("binding.effects", expectedBinding.Effects) + _ = normalizeOperatorParameters(expectedBinding, resolver) + _ = normalizeOperatorOutputs(expectedBinding, resolver) + _ = normalizeOperatorStateInputs(expectedBinding, facets) + _ = normalizeOperatorReceiptInputs(expectedBinding) _ = normalizeStateEffect(expectedBinding.StateEffect, facets) if !sameOperatorSemantics(*op, *expectedBinding) { return nil, invalid("operators."+op.ID, "compiled binding semantics drift") @@ -407,6 +430,9 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators if !validID(value.ID) || seen[value.ID] || operators[value.Operator].ID == "" { return invalid(fmt.Sprintf("transitions[%d]", i), "invalid transition or operator reference") } + if value.Priority <= 0 { + return invalid("transitions."+value.ID+".priority", "priority must be positive") + } seen[value.ID] = true if value.Work != "" { if work[value.Work].ID == "" { @@ -699,7 +725,7 @@ func stripDescriptions(value Document) Document { } func hasInlineSemantics(value Operator) bool { - return len(value.Capabilities) != 0 || len(value.Authority.AnyOf) != 0 || len(value.Authority.AllOf) != 0 || len(value.Effects) != 0 || value.Verifier != "" || value.Recovery != "" || value.StateEffect != nil || value.ExecutionContext != "" + return len(value.Capabilities) != 0 || len(value.Authority.AnyOf) != 0 || len(value.Authority.AllOf) != 0 || len(value.Effects) != 0 || value.Verifier != "" || value.Recovery != "" || value.StateEffect != nil || value.ExecutionContext != "" || len(value.Parameters) != 0 || len(value.Outputs) != 0 || len(value.StateInputs) != 0 || len(value.ReceiptInputs) != 0 } func sameOperatorSemantics(left, right Operator) bool { left.Description, right.Description = "", "" diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index a415c17..98bc98f 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -19,6 +19,46 @@ type delegationResolver struct { delegable bool } +type invocationResolver struct { + delegationResolver + operators map[string]controlprogram.ResolvedOperator + parameterResolvers map[string]controlprogram.ResolvedParameterResolver + validators map[string]controlprogram.ResolvedValueValidator +} + +func (r invocationResolver) ResolveOperator(reference, version string) (controlprogram.ResolvedOperator, error) { + if version != "1" { + return controlprogram.ResolvedOperator{}, os.ErrNotExist + } + value, ok := r.operators[reference] + if !ok { + return controlprogram.ResolvedOperator{}, os.ErrNotExist + } + return value, nil +} + +func (r invocationResolver) ResolveParameterResolver(reference, version string) (controlprogram.ResolvedParameterResolver, error) { + if version != "1" { + return controlprogram.ResolvedParameterResolver{}, os.ErrNotExist + } + value, ok := r.parameterResolvers[reference] + if !ok { + return controlprogram.ResolvedParameterResolver{}, os.ErrNotExist + } + return value, nil +} + +func (r invocationResolver) ResolveValueValidator(reference, version string) (controlprogram.ResolvedValueValidator, error) { + if version != "1" { + return controlprogram.ResolvedValueValidator{}, os.ErrNotExist + } + value, ok := r.validators[reference] + if !ok { + return controlprogram.ResolvedValueValidator{}, os.ErrNotExist + } + return value, nil +} + func (r delegationResolver) ResolveOperator(string, string) (controlprogram.ResolvedOperator, error) { return controlprogram.ResolvedOperator{}, nil } @@ -30,6 +70,14 @@ func (r delegationResolver) ResolveDelegation(reference, version string) (contro return controlprogram.ResolvedDelegation{Fingerprint: r.fingerprint, Authorities: r.authorities, Delegable: r.delegable}, nil } +func (r delegationResolver) ResolveParameterResolver(string, string) (controlprogram.ResolvedParameterResolver, error) { + return controlprogram.ResolvedParameterResolver{}, os.ErrNotExist +} + +func (r delegationResolver) ResolveValueValidator(string, string) (controlprogram.ResolvedValueValidator, error) { + return controlprogram.ResolvedValueValidator{}, os.ErrNotExist +} + func incidentProgram() controlprogram.Document { mitigated := "mitigated" return controlprogram.Document{ @@ -60,6 +108,307 @@ func incidentProgram() controlprogram.Document { } } +func parameterProgram() controlprogram.Document { + document := incidentProgram() + document.Declarations.Authorities = append(document.Declarations.Authorities, "human") + document.Operators[0].Parameters = []controlprogram.OperatorParameter{{ + ID: "channel", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, + AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceHostInput}, + Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, + }} + document.Transitions[0].Parameters = []controlprogram.TransitionParameterBinding{{ + Parameter: "channel", + Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ + ID: "channel", Description: "Select the response channel.", Authorities: []string{"human"}, Scope: "transition", + }}, + }} + return document +} + +func TestInvocationCompletenessRequiresExactlyOneAdmissibleProducer(t *testing.T) { + // control-law: required-transition-parameters-have-exactly-one-admissible-producer-before-publication + if _, err := controlprogram.Compile(parameterProgram(), nil); err != nil { + t.Fatal(err) + } + for name, test := range map[string]struct { + mutate func(*controlprogram.Document) + witness string + }{ + "missing": {func(value *controlprogram.Document) { value.Transitions[0].Parameters = nil }, "has no producer"}, + "duplicate": {func(value *controlprogram.Document) { + value.Transitions[0].Parameters = append(value.Transitions[0].Parameters, value.Transitions[0].Parameters[0]) + }, "multiple producers"}, + "unknown-parameter": {func(value *controlprogram.Document) { value.Transitions[0].Parameters[0].Parameter = "missing" }, "not declared"}, + "disallowed-kind": {func(value *controlprogram.Document) { + value.Transitions[0].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "incident"} + }, "does not allow producer kind"}, + "wrong-source-type": {func(value *controlprogram.Document) { + value.Operators[0].Parameters[0].Type = controlprogram.ValueTypeDefinition{Kind: "integer"} + value.Operators[0].Parameters[0].AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceEntryInput} + value.Operators[0].Parameters[0].Authority = controlprogram.AuthorityRequirement{} + value.Transitions[0].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "incident"} + }, "unavailable or incompatible"}, + "authority-weakening": {func(value *controlprogram.Document) { + value.Transitions[0].Parameters[0].Producer.Request.Authorities = nil + }, "weakens parameter authority"}, + } { + t.Run(name, func(t *testing.T) { + value := parameterProgram() + test.mutate(&value) + if _, err := controlprogram.Compile(value, nil); err == nil || !strings.Contains(err.Error(), test.witness) { + t.Fatalf("result = %v, want %q", err, test.witness) + } + }) + } +} + +func TestInvocationCompletenessRequiresAuthorityReceiptProducer(t *testing.T) { + // control-law: producer metadata cannot stand in for an authority receipt + // attached to the admitted parameter value. + for name, test := range map[string]struct { + authority controlprogram.AuthorityRequirement + producer controlprogram.ParameterProducer + }{ + "entry-input-any-of": {controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "incident"}}, + "state-all-of": {controlprogram.AuthorityRequirement{AllOf: []string{"human"}}, controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "service", AvailableWhen: func() *controlprogram.Predicate { value := fact("service", "degraded"); return &value }()}}, + "receipt-any-of": {controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceReceipt, Transition: "observe-channel", Field: "channel"}}, + "work-output-all-of": {controlprogram.AuthorityRequirement{AllOf: []string{"human"}}, controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "plan", Output: "channel"}}, + "trusted-resolver-any-of": {controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{Reference: "incident/channel", Version: "1"}}}, + } { + t.Run(name, func(t *testing.T) { + value := parameterProgram() + value.Operators[0].Parameters[0].Authority = test.authority + value.Operators[0].Parameters[0].AllowedSources = []controlprogram.ParameterSourceKind{test.producer.Kind} + value.Transitions[0].Parameters[0].Producer = test.producer + if _, err := controlprogram.Compile(value, nil); err == nil || !strings.Contains(err.Error(), "requires an authority-receipt-producing host-input producer") { + t.Fatalf("result = %v", err) + } + }) + } +} + +func TestInvocationCompletenessRejectsUntrustedReceiptAndUnknownResolver(t *testing.T) { + receiptBound := parameterProgram() + receiptBound.Operators[0].Parameters[0].Authority = controlprogram.AuthorityRequirement{} + receiptBound.Operators[0].Parameters[0].AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceReceipt} + priorOperator := receiptBound.Operators[0] + priorOperator.ID, priorOperator.Parameters = "observe-channel", nil + receiptBound.Operators = append(receiptBound.Operators, priorOperator) + truth := true + receiptBound.Transitions = append([]controlprogram.Transition{{ + ID: "observe-channel", Operator: "observe-channel", Guard: controlprogram.Predicate{True: &truth}, Target: fact("service", "healthy"), Priority: 1, + }}, receiptBound.Transitions...) + receiptBound.Transitions[1].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceReceipt, Transition: "observe-channel", Field: "channel"} + if _, err := controlprogram.Compile(receiptBound, nil); err == nil || !strings.Contains(err.Error(), "receipt availability is not guaranteed by the trusted operator binding") { + t.Fatalf("untrusted receipt result = %v", err) + } + + unknown := parameterProgram() + unknown.Operators[0].Parameters[0].Authority = controlprogram.AuthorityRequirement{} + unknown.Operators[0].Parameters[0].AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + unknown.Transitions[0].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{Reference: "incident/missing", Version: "1"}} + if _, err := controlprogram.Compile(unknown, invocationResolver{}); err == nil || !strings.Contains(err.Error(), "trusted resolver is unknown") { + t.Fatalf("unknown resolver result = %v", err) + } +} + +func TestInvocationCompletenessAcceptsOnlyExactTrustedReceiptProvenance(t *testing.T) { + // control-law: state predicates cannot stand in for a committed receipt; + // the consumer binding must guarantee the exact producer transition field. + document := incidentProgram() + document.Operators = []controlprogram.Operator{ + {ID: "observe-channel", Binding: &controlprogram.OperatorBinding{Reference: "incident/observe-channel", Version: "1"}}, + {ID: "restart", Binding: &controlprogram.OperatorBinding{Reference: "incident/restart", Version: "1"}}, + } + truth := true + document.Transitions = []controlprogram.Transition{ + {ID: "observe-channel", Operator: "observe-channel", Guard: controlprogram.Predicate{True: &truth}, Target: fact("service", "healthy"), Priority: 1}, + {ID: "restart", Operator: "restart", Guard: fact("service", "healthy"), Target: fact("incident", "mitigated"), Priority: 10, Parameters: []controlprogram.TransitionParameterBinding{{ + Parameter: "channel", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceReceipt, Transition: "observe-channel", Field: "channel"}, + }}}, + } + mitigated := "mitigated" + healthy := "healthy" + resolver := invocationResolver{operators: map[string]controlprogram.ResolvedOperator{ + "incident/observe-channel": { + Fingerprint: strings.Repeat("a", 64), Verifier: "healthcheck", ExecutionContext: "preserve", + StateEffect: controlprogram.StateEffect{Kind: "assignments", Assignments: []controlprogram.StateAssignment{{Facet: "service", Value: &healthy}}}, + Outputs: []controlprogram.OperatorOutput{{ID: "channel", Type: controlprogram.ValueTypeDefinition{Kind: "string"}}}, + }, + "incident/restart": { + Fingerprint: strings.Repeat("b", 64), Capabilities: []string{"service.restart"}, Effects: []string{"service.restart"}, Verifier: "healthcheck", Recovery: "restart", ExecutionContext: "preserve", + StateEffect: controlprogram.StateEffect{Kind: "assignments", Assignments: []controlprogram.StateAssignment{{Facet: "incident", Value: &mitigated}}}, + Parameters: []controlprogram.OperatorParameter{{ID: "channel", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceReceipt}}}, + }, + }} + if _, err := controlprogram.Compile(clone(t, document), resolver); err == nil || !strings.Contains(err.Error(), "receipt availability is not guaranteed by the trusted operator binding") { + t.Fatalf("missing trusted receipt input result = %v", err) + } + consumer := resolver.operators["incident/restart"] + consumer.ReceiptInputs = []controlprogram.OperatorReceiptInput{{Parameter: "channel", Transition: "observe-channel", Field: "channel", Guaranteed: true}} + resolver.operators["incident/restart"] = consumer + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + tampered := clone(t, compiled.Document) + for index := range tampered.Operators { + if tampered.Operators[index].ID == "restart" { + tampered.Operators[index].ReceiptInputs[0].Field = "other" + } + } + if _, err := controlprogram.Compile(tampered, resolver); err == nil || !strings.Contains(err.Error(), "compiled binding semantics drift") { + t.Fatalf("receipt-input provenance drift result = %v", err) + } +} + +func TestInvocationCompletenessChecksEntryAndStateAvailabilityPerEntry(t *testing.T) { + entryBound := parameterProgram() + entryBound.Operators[0].Parameters[0].Authority = controlprogram.AuthorityRequirement{} + entryBound.Entries[0].Inputs[0].Type = "string" + entryBound.Operators[0].Parameters[0].AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceEntryInput} + entryBound.Transitions[0].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "incident"} + if _, err := controlprogram.Compile(entryBound, nil); err != nil { + t.Fatal(err) + } + missing := clone(t, entryBound) + missing.Entries = append(missing.Entries, controlprogram.Entry{ID: "alternate", Target: "mitigated"}) + if _, err := controlprogram.Compile(missing, nil); err == nil || !strings.Contains(err.Error(), `reachable entry "alternate"`) { + t.Fatalf("missing reachable input result = %v", err) + } + + stateBound := parameterProgram() + stateBound.Operators[0].Parameters[0].Authority = controlprogram.AuthorityRequirement{} + stateBound.Operators[0].Parameters[0].AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + truth := true + alwaysAvailable := controlprogram.Predicate{True: &truth} + stateBound.Transitions[0].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "service", AvailableWhen: &alwaysAvailable} + if _, err := controlprogram.Compile(stateBound, nil); err == nil || !strings.Contains(err.Error(), "does not prove the produced facet is known") { + t.Fatalf("unproved state producer result = %v", err) + } + availability := fact("service", "degraded") + stateBound.Transitions[0].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "service", AvailableWhen: &availability} + if _, err := controlprogram.Compile(stateBound, nil); err == nil || !strings.Contains(err.Error(), "state availability is not implied") { + t.Fatalf("unimplied state producer result = %v", err) + } + stateBound.Transitions[0].Guard = controlprogram.Predicate{All: []controlprogram.Predicate{stateBound.Transitions[0].Guard, availability}} + if _, err := controlprogram.Compile(stateBound, nil); err != nil { + t.Fatal(err) + } +} + +func TestInvocationCompletenessProvesWorkOutputProducerPrecedence(t *testing.T) { + // control-law: selected work-output consumers identify one completed producer + base := incidentWorkProgram() + consumerOperator := base.Operators[0] + consumerOperator.ID = "dispatch" + consumerOperator.Parameters = []controlprogram.OperatorParameter{{ + ID: "diagnosis", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, + AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceWorkOutput}, + }} + base.Operators = append(base.Operators, consumerOperator) + producerTarget := base.Transitions[0].Target + base.Transitions = append(base.Transitions, controlprogram.Transition{ + ID: "dispatch", Operator: "dispatch", Priority: 20, Guard: producerTarget, + Target: fact("incident", "mitigated"), Parameters: []controlprogram.TransitionParameterBinding{{ + Parameter: "diagnosis", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "diagnose", Output: "diagnosis"}, + }}, + }) + if _, err := controlprogram.Compile(base, nil); err != nil { + t.Fatal(err) + } + + ambiguous := clone(t, base) + var duplicateProducer controlprogram.Transition + for _, candidate := range ambiguous.Transitions { + if candidate.Work == "diagnose" { + duplicateProducer = candidate + break + } + } + duplicateProducer.ID = "diagnose-again" + var duplicateOperator controlprogram.Operator + for _, candidate := range ambiguous.Operators { + if candidate.ID == duplicateProducer.Operator { + duplicateOperator = candidate + break + } + } + duplicateOperator.ID = "diagnose-again" + duplicateProducer.Operator = duplicateOperator.ID + ambiguous.Operators = append(ambiguous.Operators, duplicateOperator) + ambiguous.Transitions = append(ambiguous.Transitions, duplicateProducer) + if _, err := controlprogram.Compile(ambiguous, nil); err == nil || !strings.Contains(err.Error(), "exactly one producer transition") { + t.Fatalf("ambiguous work producer result = %v", err) + } + + shadowed := clone(t, base) + producerPriority := 0 + for _, candidate := range shadowed.Transitions { + if candidate.Work == "diagnose" { + producerPriority = candidate.Priority + } + } + for index := range shadowed.Transitions { + if shadowed.Transitions[index].ID == "dispatch" { + shadowed.Transitions[index].Priority = producerPriority + } + } + if _, err := controlprogram.Compile(shadowed, nil); err == nil || !strings.Contains(err.Error(), "not guaranteed before") { + t.Fatalf("shadowed work producer result = %v", err) + } + + unproved := clone(t, base) + truth := true + for index := range unproved.Transitions { + if unproved.Transitions[index].ID == "dispatch" { + unproved.Transitions[index].Guard = controlprogram.Predicate{True: &truth} + } + } + if _, err := controlprogram.Compile(unproved, nil); err == nil || !strings.Contains(err.Error(), "not guaranteed before") { + t.Fatalf("unproved work producer result = %v", err) + } +} + +func TestInvocationCompletenessRejectsResolverDriftCyclesAndValidatorOverride(t *testing.T) { + fingerprintA, fingerprintB := strings.Repeat("a", 64), strings.Repeat("b", 64) + resolver := invocationResolver{ + parameterResolvers: map[string]controlprogram.ResolvedParameterResolver{ + "incident/channel": {Fingerprint: fingerprintA, OutputType: controlprogram.ValueTypeDefinition{Kind: "string"}, SourceKind: controlprogram.ParameterSourceTrustedResolver, Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, Dependencies: []string{"channel"}, StabilityScope: "invocation"}, + }, + validators: map[string]controlprogram.ResolvedValueValidator{ + "incident/channel-validator": {Fingerprint: fingerprintB, Type: controlprogram.ValueTypeDefinition{Kind: "string"}}, + }, + } + value := parameterProgram() + value.Operators[0].Parameters[0].Authority = controlprogram.AuthorityRequirement{} + value.Operators[0].Parameters[0].AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + value.Transitions[0].Parameters[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{Reference: "incident/channel", Version: "1"}} + if _, err := controlprogram.Compile(value, resolver); err == nil || !strings.Contains(err.Error(), "dependency cycle") { + t.Fatalf("resolver cycle result = %v", err) + } + resolver.parameterResolvers["incident/channel"] = controlprogram.ResolvedParameterResolver{Fingerprint: fingerprintA, OutputType: controlprogram.ValueTypeDefinition{Kind: "string"}, SourceKind: controlprogram.ParameterSourceTrustedResolver, Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}, StabilityScope: "invocation"} + compiled, err := controlprogram.Compile(value, resolver) + if err != nil { + t.Fatal(err) + } + compiled.Document.Transitions[0].Parameters[0].Producer.Binding.Fingerprint = fingerprintB + if _, err := controlprogram.Compile(compiled.Document, resolver); err == nil || !strings.Contains(err.Error(), "fingerprint drift") { + t.Fatalf("resolver drift result = %v", err) + } + + validated := parameterProgram() + validated.Operators[0].Parameters[0].Type.Validator = &controlprogram.TrustedValidatorBinding{Reference: "incident/channel-validator", Version: "1"} + compiled, err = controlprogram.Compile(validated, resolver) + if err != nil { + t.Fatal(err) + } + compiled.Document.Operators[0].Parameters[0].Type.Validator.Fingerprint = fingerprintA + if _, err := controlprogram.Compile(compiled.Document, resolver); err == nil || !strings.Contains(err.Error(), "fingerprint drift") { + t.Fatalf("validator override result = %v", err) + } +} + func fact(facet, value string) controlprogram.Predicate { return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}, Values: []string{value}}} } diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index c4cc884..5989d1b 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -86,7 +86,7 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { {"product-delivery-a.flow.ts", 1, 1}, {"product-delivery-b.flow.ts", 2, 2}, {"product-delivery-c.flow.ts", 1, 6}, - {"product-delivery-planning-package.flow.ts", 1, 21}, + {"product-delivery-planning-package.flow.ts", 2, 22}, } for _, test := range cases { t.Run(test.fixture, func(t *testing.T) { @@ -111,6 +111,49 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { t.Fatalf("entries=%d transitions=%d", len(compiled.Document.Entries), len(manifest.Transitions)) } if test.fixture == "product-delivery-planning-package.flow.ts" { + referenceRaw, readErr := os.ReadFile(filepath.Join(moduleRoot, "testdata", "control-programs", "product-delivery-planning-package.raw.json")) + if readErr != nil { + t.Fatal(readErr) + } + reference, referenceErr := controlprogram.LoadWithAssets(bytes.NewReader(referenceRaw), resolver, controlprogram.RepositoryAssetResolver{Repository: filepath.Dir(moduleRoot)}) + if referenceErr != nil { + t.Fatal(referenceErr) + } + if compiled.Fingerprint != reference.Fingerprint { + t.Fatalf("checked product-delivery IR is stale: frontend=%s checked=%s", compiled.Fingerprint, reference.Fingerprint) + } + var packageProducer, publicationProducer controlprogram.ParameterProducer + publicationStateInputDeclared, publicationReceiptInputDeclared := false, false + for _, operator := range compiled.Document.Operators { + if operator.ID == "publication.observe" && len(operator.StateInputs) == 1 && operator.StateInputs[0].Parameter == "publication_id" && operator.StateInputs[0].Facet == "publication_id" { + publicationStateInputDeclared = true + } + if operator.ID == "publication.observe" && len(operator.ReceiptInputs) == 1 && operator.ReceiptInputs[0].Parameter == "publication_id" && operator.ReceiptInputs[0].Transition == "publication.execute" && operator.ReceiptInputs[0].Field == "publication_id" && !operator.ReceiptInputs[0].Guaranteed { + publicationReceiptInputDeclared = true + } + } + for _, transition := range compiled.Document.Transitions { + for _, parameter := range transition.Parameters { + if parameter.Producer.Kind == controlprogram.ParameterSourceHostInput && (transition.ID != "delivery.slice.advance" || parameter.Parameter != "slice_id") { + t.Fatalf("deterministic parameter requires human text input: %s/%s", transition.ID, parameter.Parameter) + } + if transition.ID == "planning.package.approve" && parameter.Parameter == "package_fingerprint" { + packageProducer = parameter.Producer + } + if transition.ID == "publication.observe" && parameter.Parameter == "publication_id" { + publicationProducer = parameter.Producer + } + } + } + if packageProducer.Kind != controlprogram.ParameterSourceTrustedResolver || packageProducer.Binding == nil || packageProducer.Binding.Reference != "software-delivery/admitted-planning-package-fingerprint" { + t.Fatalf("planning package producer = %#v", packageProducer) + } + if publicationProducer.Kind != controlprogram.ParameterSourceStateOrReceipt || publicationProducer.Facet != "publication_id" || publicationProducer.Transition != "publication.execute" || publicationProducer.Field != "publication_id" { + t.Fatalf("publication identity producer = %#v", publicationProducer) + } + if !publicationStateInputDeclared || !publicationReceiptInputDeclared { + t.Fatal("publication identity producer lacks exact trusted alternative provenance") + } if _, compileErr := delivery.Compile(context.Background(), delivery.CompileRequest{KernelVersion: boatstack.Version, Core: core.System(), Runtime: definition, Settings: map[string]string{"fixture": test.fixture}}); compileErr != nil { t.Fatalf("compile repository Flow runtime: %v", compileErr) } @@ -148,6 +191,44 @@ func TestDomainNeutralFrontendDeclaresForegroundWorkWithoutSoftwareDelivery(t *t } } +func TestDomainNeutralInvocationFixtureCompilesAndMissingProducerFails(t *testing.T) { + // control-law: generic invocation completeness does not depend on the + // software-delivery authoring package. + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate invocation fixtures") + } + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..")) + frontend := filepath.Join(filepath.Dir(moduleRoot), "node_modules", ".bin", "boatstack-flow-frontend") + if runtime.GOOS == "windows" { + frontend += ".cmd" + } + if _, err := os.Stat(frontend); err != nil { + t.Skip("Flow frontend dependencies are not installed") + } + compile := func(name string) ([]byte, error) { + return exec.Command(frontend, filepath.Join(moduleRoot, "testdata", "control-programs", name)).CombinedOutput() + } + raw, err := compile("incident-response-invocation.flow.ts") + if err != nil { + t.Fatalf("compile domain-neutral fixture: %v\n%s", err, raw) + } + compiled, err := controlprogram.Load(bytes.NewReader(raw), nil) + if err != nil { + t.Fatal(err) + } + if _, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}); err != nil { + t.Fatalf("generate domain-neutral entry drivers: %v", err) + } + missingRaw, err := compile("incident-response-invocation-missing.flow.ts") + if err != nil { + t.Fatalf("lower negative fixture: %v\n%s", err, missingRaw) + } + if _, err := controlprogram.Load(bytes.NewReader(missingRaw), nil); err == nil || !strings.Contains(err.Error(), "CONTROL_PROGRAM_INVOCATION_INCOMPLETE") { + t.Fatalf("missing producer result = %v", err) + } +} + func TestTypeScriptFrontendRejectsRepositoryCodeWithoutExecutingIt(t *testing.T) { // control-law: authoring-frontends-parse-repository-declarations-without-module-execution _, file, _, ok := runtime.Caller(0) diff --git a/boatstack/controlprogram/invocation_compile.go b/boatstack/controlprogram/invocation_compile.go new file mode 100644 index 0000000..6ec2e9d --- /dev/null +++ b/boatstack/controlprogram/invocation_compile.go @@ -0,0 +1,563 @@ +package controlprogram + +import ( + "encoding/json" + "fmt" + "sort" +) + +var parameterSourceKinds = map[ParameterSourceKind]bool{ + ParameterSourceEntryInput: true, ParameterSourceState: true, + ParameterSourceReceipt: true, ParameterSourceStateOrReceipt: true, ParameterSourceWorkOutput: true, + ParameterSourceTrustedResolver: true, ParameterSourceHostInput: true, +} + +func normalizeOperatorParameters(op *Operator, resolver BindingResolver) error { + seen := map[string]bool{} + for i := range op.Parameters { + parameter := &op.Parameters[i] + field := fmt.Sprintf("operators.%s.parameters[%d]", op.ID, i) + if !validID(parameter.ID) || seen[parameter.ID] { + return invalid(field+".id", "invalid or duplicate parameter") + } + seen[parameter.ID] = true + if err := normalizeValueType(¶meter.Type, resolver, field+".type"); err != nil { + return err + } + if len(parameter.AllowedSources) == 0 { + return invalid(field+".allowed_sources", "at least one source kind is required") + } + sort.Slice(parameter.AllowedSources, func(i, j int) bool { return parameter.AllowedSources[i] < parameter.AllowedSources[j] }) + for j, kind := range parameter.AllowedSources { + if !parameterSourceKinds[kind] || (j > 0 && parameter.AllowedSources[j-1] == kind) { + return invalid(field+".allowed_sources", "unknown or duplicate source kind") + } + } + var err error + parameter.Authority.AnyOf, err = normalizedReferenceSet(field+".authority.any_of", parameter.Authority.AnyOf) + if err != nil { + return err + } + parameter.Authority.AllOf, err = normalizedReferenceSet(field+".authority.all_of", parameter.Authority.AllOf) + if err != nil { + return err + } + } + sort.Slice(op.Parameters, func(i, j int) bool { return op.Parameters[i].ID < op.Parameters[j].ID }) + return nil +} + +func normalizeOperatorOutputs(op *Operator, resolver BindingResolver) error { + if op.Binding == nil && len(op.Outputs) != 0 { + return invalid("operators."+op.ID+".outputs", "only trusted bindings may declare committed receipt outputs") + } + seen := map[string]bool{} + for index := range op.Outputs { + output := &op.Outputs[index] + field := fmt.Sprintf("operators.%s.outputs[%d]", op.ID, index) + if !validID(output.ID) || seen[output.ID] { + return invalid(field+".id", "invalid or duplicate output") + } + seen[output.ID] = true + if err := normalizeValueType(&output.Type, resolver, field+".type"); err != nil { + return err + } + } + sort.Slice(op.Outputs, func(i, j int) bool { return op.Outputs[i].ID < op.Outputs[j].ID }) + return nil +} + +func normalizeOperatorStateInputs(op *Operator, facets map[string]Facet) error { + if op.Binding == nil && len(op.StateInputs) != 0 { + return invalid("operators."+op.ID+".state_inputs", "only trusted bindings may declare state-input provenance") + } + contracts := map[string]OperatorParameter{} + for _, parameter := range op.Parameters { + contracts[parameter.ID] = parameter + } + seen := map[string]bool{} + for index := range op.StateInputs { + input := &op.StateInputs[index] + field := fmt.Sprintf("operators.%s.state_inputs[%d]", op.ID, index) + contract, ok := contracts[input.Parameter] + if !ok || seen[input.Parameter] || (!containsSource(contract.AllowedSources, ParameterSourceState) && !containsSource(contract.AllowedSources, ParameterSourceStateOrReceipt)) { + return invalid(field+".parameter", "state input requires one state-capable operator parameter") + } + seen[input.Parameter] = true + facet, ok := facets[input.Facet] + if !ok || !compatibleFacetType(facet.Kind, contract.Type.Kind) { + return invalid(field+".facet", "state input facet is unknown or incompatible") + } + if err := normalizePredicate(&input.AvailableWhen, facets); err != nil || !predicateRequiresKnownFacet(input.AvailableWhen, input.Facet) { + return invalid(field+".available_when", "state input must prove its exact facet is known") + } + } + sort.Slice(op.StateInputs, func(i, j int) bool { return op.StateInputs[i].Parameter < op.StateInputs[j].Parameter }) + return nil +} + +func normalizeOperatorReceiptInputs(op *Operator) error { + if op.Binding == nil && len(op.ReceiptInputs) != 0 { + return invalid("operators."+op.ID+".receipt_inputs", "only trusted bindings may declare receipt-input provenance") + } + contracts := map[string]OperatorParameter{} + for _, parameter := range op.Parameters { + contracts[parameter.ID] = parameter + } + seen := map[string]bool{} + for index := range op.ReceiptInputs { + input := &op.ReceiptInputs[index] + field := fmt.Sprintf("operators.%s.receipt_inputs[%d]", op.ID, index) + contract, ok := contracts[input.Parameter] + if !ok || seen[input.Parameter] || (!containsSource(contract.AllowedSources, ParameterSourceReceipt) && !containsSource(contract.AllowedSources, ParameterSourceStateOrReceipt)) { + return invalid(field+".parameter", "receipt input requires one receipt-capable operator parameter") + } + seen[input.Parameter] = true + if !validID(input.Transition) { + return invalid(field+".transition", "receipt input transition is invalid") + } + if !validID(input.Field) { + return invalid(field+".field", "receipt input field is invalid") + } + } + sort.Slice(op.ReceiptInputs, func(i, j int) bool { return op.ReceiptInputs[i].Parameter < op.ReceiptInputs[j].Parameter }) + return nil +} + +func normalizeValueType(value *ValueTypeDefinition, resolver BindingResolver, field string) error { + switch value.Kind { + case "string": + if value.Minimum != nil || value.Maximum != nil || value.Schema != nil { + return invalid(field, "string type contains fields owned by another type") + } + if err := resolveValidator(&value.Validator, resolver, *value, field+".validator"); err != nil { + return err + } + case "boolean": + if value.Validator != nil || value.Minimum != nil || value.Maximum != nil || value.Schema != nil { + return invalid(field, "boolean type does not accept validator or bounds") + } + case "integer": + if value.Validator != nil || value.Schema != nil { + return invalid(field, "integer type contains fields owned by another type") + } + if value.Minimum != nil && value.Maximum != nil && *value.Minimum > *value.Maximum { + return invalid(field, "integer minimum exceeds maximum") + } + case "json": + if value.Validator != nil || value.Minimum != nil || value.Maximum != nil { + return invalid(field, "json type contains fields owned by another type") + } + if err := resolveValidator(&value.Schema, resolver, *value, field+".schema"); err != nil { + return err + } + default: + return invalid(field+".kind", "must be string, boolean, integer, or json") + } + return nil +} + +func resolveValidator(binding **TrustedValidatorBinding, resolver BindingResolver, parameterType ValueTypeDefinition, field string) error { + if *binding == nil { + return nil + } + value := *binding + if !semanticReference.MatchString(value.Reference) || value.Version == "" || resolver == nil { + return invalid(field, "trusted validator binding requires a resolver") + } + resolved, err := resolver.ResolveValueValidator(value.Reference, value.Version) + if err != nil { + return invalid(field, err.Error()) + } + if len(resolved.Fingerprint) != 64 || resolved.Type.Kind != parameterType.Kind { + return invalid(field, "trusted validator type or fingerprint is invalid") + } + if value.Fingerprint != "" && value.Fingerprint != resolved.Fingerprint { + return invalid(field, "trusted validator fingerprint drift") + } + value.Fingerprint = resolved.Fingerprint + return nil +} + +func normalizeInvocationCompleteness(document *Document, operators map[string]Operator, work map[string]WorkContract, facets map[string]Facet, resolver BindingResolver) error { + entries := map[string]map[string]EntryInput{} + for _, entry := range document.Entries { + inputs := map[string]EntryInput{} + for _, input := range entry.Inputs { + inputs[input.ID] = input + } + entries[entry.ID] = inputs + } + transitions := map[string]Transition{} + for _, transition := range document.Transitions { + transitions[transition.ID] = transition + } + for i := range document.Transitions { + transition := &document.Transitions[i] + operator := operators[transition.Operator] + contracts := map[string]OperatorParameter{} + for _, parameter := range operator.Parameters { + contracts[parameter.ID] = parameter + } + seen := map[string]bool{} + dependencies := map[string][]string{} + for j := range transition.Parameters { + binding := &transition.Parameters[j] + field := fmt.Sprintf("transitions.%s.parameters[%d]", transition.ID, j) + contract, exists := contracts[binding.Parameter] + if !exists { + return invocationIncomplete(transition.ID, binding.Parameter, "is not declared by the trusted operator") + } + if seen[binding.Parameter] { + return invocationIncomplete(transition.ID, binding.Parameter, "has multiple producers") + } + seen[binding.Parameter] = true + if !containsSource(contract.AllowedSources, binding.Producer.Kind) { + return invocationIncomplete(transition.ID, binding.Parameter, fmt.Sprintf("does not allow producer kind %q", binding.Producer.Kind)) + } + deps, err := normalizeProducer(&binding.Producer, contract, *transition, entries, work, facets, transitions, operators, resolver, field) + if err != nil { + return err + } + dependencies[binding.Parameter] = deps + } + for _, contract := range operator.Parameters { + if contract.Required && !seen[contract.ID] { + return invocationIncomplete(transition.ID, contract.ID, "has no producer") + } + } + if cycle := parameterDependencyCycle(dependencies); cycle != "" { + return invocationIncomplete(transition.ID, cycle, "participates in a producer dependency cycle") + } + sort.Slice(transition.Parameters, func(i, j int) bool { return transition.Parameters[i].Parameter < transition.Parameters[j].Parameter }) + } + return nil +} + +func normalizeProducer(producer *ParameterProducer, contract OperatorParameter, transition Transition, entries map[string]map[string]EntryInput, work map[string]WorkContract, facets map[string]Facet, transitions map[string]Transition, operators map[string]Operator, resolver BindingResolver, field string) ([]string, error) { + if !parameterSourceKinds[producer.Kind] { + return nil, invocationIncomplete(transition.ID, contract.ID, "has an unknown producer kind") + } + canonicalFields, err := json.Marshal(producer) + if err != nil { + return nil, err + } + _ = canonicalFields + if err := rejectProducerExtraneousFields(*producer, field); err != nil { + return nil, err + } + if parameterRequiresAuthority(contract.Authority) && producer.Kind != ParameterSourceHostInput { + return nil, invocationIncomplete(transition.ID, contract.ID, "requires an authority-receipt-producing host-input producer") + } + switch producer.Kind { + case ParameterSourceEntryInput: + if !validID(producer.Input) { + return nil, invocationIncomplete(transition.ID, contract.ID, "references an invalid entry input") + } + for entryID, inputs := range entries { + input, ok := inputs[producer.Input] + if !ok || !compatibleEntryInputType(input.Type, contract.Type.Kind) { + return nil, invocationIncomplete(transition.ID, contract.ID, fmt.Sprintf("entry-input %q is unavailable or incompatible for reachable entry %q", producer.Input, entryID)) + } + } + case ParameterSourceState: + facet, ok := facets[producer.Facet] + if !ok || producer.AvailableWhen == nil || !compatibleFacetType(facet.Kind, contract.Type.Kind) { + return nil, invocationIncomplete(transition.ID, contract.ID, "has an invalid state producer") + } + if err := normalizePredicate(producer.AvailableWhen, facets); err != nil { + return nil, invocationIncomplete(transition.ID, contract.ID, "has an invalid state availability predicate: "+err.Error()) + } + if !predicateRequiresKnownFacet(*producer.AvailableWhen, producer.Facet) { + return nil, invocationIncomplete(transition.ID, contract.ID, "state availability does not prove the produced facet is known") + } + operator := operators[transition.Operator] + trustedAvailability := false + for _, input := range operator.StateInputs { + if input.Parameter == contract.ID && input.Facet == producer.Facet && predicateImplies(*producer.AvailableWhen, input.AvailableWhen) { + trustedAvailability = true + } + } + if !trustedAvailability && !predicateImplies(transition.Guard, *producer.AvailableWhen) { + return nil, invocationIncomplete(transition.ID, contract.ID, "state availability is not implied by the transition guard or trusted operator binding") + } + case ParameterSourceReceipt: + prior, ok := transitions[producer.Transition] + if !ok || !validID(producer.Field) || prior.Priority >= transition.Priority { + return nil, invocationIncomplete(transition.ID, contract.ID, "receipt producer is not ordered before the consuming transition") + } + consumerOperator := operators[transition.Operator] + trustedAvailability := false + for _, input := range consumerOperator.ReceiptInputs { + if input.Parameter == contract.ID && input.Transition == producer.Transition && input.Field == producer.Field && input.Guaranteed { + trustedAvailability = true + } + } + if !trustedAvailability { + return nil, invocationIncomplete(transition.ID, contract.ID, "receipt availability is not guaranteed by the trusted operator binding") + } + priorOperator := operators[prior.Operator] + outputFound := false + for _, output := range priorOperator.Outputs { + if output.ID == producer.Field && sameValueType(output.Type, contract.Type) { + outputFound = true + } + } + if !outputFound { + return nil, invocationIncomplete(transition.ID, contract.ID, "references an undeclared or incompatible committed receipt output") + } + case ParameterSourceStateOrReceipt: + facet, ok := facets[producer.Facet] + if !ok || producer.AvailableWhen == nil || !compatibleFacetType(facet.Kind, contract.Type.Kind) { + return nil, invocationIncomplete(transition.ID, contract.ID, "has an invalid state alternative") + } + if err := normalizePredicate(producer.AvailableWhen, facets); err != nil || !predicateRequiresKnownFacet(*producer.AvailableWhen, producer.Facet) { + return nil, invocationIncomplete(transition.ID, contract.ID, "state alternative does not prove the produced facet is known") + } + consumerOperator := operators[transition.Operator] + trustedState := false + for _, input := range consumerOperator.StateInputs { + if input.Parameter == contract.ID && input.Facet == producer.Facet && predicateImplies(*producer.AvailableWhen, input.AvailableWhen) { + trustedState = true + } + } + trustedReceipt := false + for _, input := range consumerOperator.ReceiptInputs { + if input.Parameter == contract.ID && input.Transition == producer.Transition && input.Field == producer.Field { + trustedReceipt = true + } + } + prior, priorFound := transitions[producer.Transition] + if !trustedState || !trustedReceipt || !priorFound || !validID(producer.Field) || prior.Priority >= transition.Priority { + return nil, invocationIncomplete(transition.ID, contract.ID, "state-or-receipt alternatives are not guaranteed by the trusted operator binding") + } + priorOperator := operators[prior.Operator] + outputFound := false + for _, output := range priorOperator.Outputs { + if output.ID == producer.Field && sameValueType(output.Type, contract.Type) { + outputFound = true + } + } + if !outputFound { + return nil, invocationIncomplete(transition.ID, contract.ID, "references an undeclared or incompatible committed receipt alternative") + } + case ParameterSourceWorkOutput: + contractWork, ok := work[producer.Work] + if !ok { + return nil, invocationIncomplete(transition.ID, contract.ID, "references unknown foreground work") + } + found := false + for _, output := range contractWork.Outputs { + if output.ID == producer.Output { + found = output.Required && compatibleWorkOutputType(output.MediaType, contract.Type.Kind) + } + } + if !found { + return nil, invocationIncomplete(transition.ID, contract.ID, "references an optional, unknown, or incompatible work output") + } + var producerTransitions []Transition + for _, candidate := range transitions { + if candidate.Work == producer.Work { + producerTransitions = append(producerTransitions, candidate) + } + } + if len(producerTransitions) != 1 { + return nil, invocationIncomplete(transition.ID, contract.ID, "work output does not have exactly one producer transition") + } + prior := producerTransitions[0] + if prior.ID != transition.ID && (prior.Priority >= transition.Priority || !predicateImplies(transition.Guard, prior.Target)) { + return nil, invocationIncomplete(transition.ID, contract.ID, "work output is not guaranteed before the consuming transition") + } + case ParameterSourceTrustedResolver: + if producer.Binding == nil || resolver == nil || !semanticReference.MatchString(producer.Binding.Reference) || producer.Binding.Version == "" { + return nil, invocationIncomplete(transition.ID, contract.ID, "has an invalid trusted resolver binding") + } + resolved, resolveErr := resolver.ResolveParameterResolver(producer.Binding.Reference, producer.Binding.Version) + if resolveErr != nil { + return nil, invocationIncomplete(transition.ID, contract.ID, "trusted resolver is unknown: "+resolveErr.Error()) + } + if len(resolved.Fingerprint) != 64 || resolved.SourceKind != ParameterSourceTrustedResolver || !sameValueType(resolved.OutputType, contract.Type) { + return nil, invocationIncomplete(transition.ID, contract.ID, "trusted resolver metadata is incompatible") + } + if producer.Binding.Fingerprint != "" && producer.Binding.Fingerprint != resolved.Fingerprint { + return nil, invocationIncomplete(transition.ID, contract.ID, "trusted resolver fingerprint drift") + } + producer.Binding.Fingerprint = resolved.Fingerprint + return append([]string(nil), resolved.Dependencies...), nil + case ParameterSourceHostInput: + if producer.Request == nil || !validID(producer.Request.ID) || producer.Request.Description == "" || producer.Request.Scope != "transition" { + return nil, invocationIncomplete(transition.ID, contract.ID, "has an invalid host-input request") + } + var normalizeErr error + producer.Request.Authorities, normalizeErr = normalizedReferenceSet(field+".request.authorities", producer.Request.Authorities) + if normalizeErr != nil || !authorityListSatisfies(producer.Request.Authorities, contract.Authority) { + return nil, invocationIncomplete(transition.ID, contract.ID, "host-input request weakens parameter authority") + } + } + return nil, nil +} + +func rejectProducerExtraneousFields(value ParameterProducer, field string) error { + invalidFields := false + switch value.Kind { + case ParameterSourceEntryInput: + invalidFields = value.Facet != "" || value.AvailableWhen != nil || value.Transition != "" || value.Field != "" || value.Work != "" || value.Output != "" || value.Binding != nil || value.Request != nil + case ParameterSourceState: + invalidFields = value.Input != "" || value.Transition != "" || value.Field != "" || value.Work != "" || value.Output != "" || value.Binding != nil || value.Request != nil + case ParameterSourceReceipt: + invalidFields = value.Input != "" || value.Facet != "" || value.AvailableWhen != nil || value.Work != "" || value.Output != "" || value.Binding != nil || value.Request != nil + case ParameterSourceStateOrReceipt: + invalidFields = value.Input != "" || value.Facet == "" || value.AvailableWhen == nil || value.Transition == "" || value.Field == "" || value.Work != "" || value.Output != "" || value.Binding != nil || value.Request != nil + case ParameterSourceWorkOutput: + invalidFields = value.Input != "" || value.Facet != "" || value.AvailableWhen != nil || value.Transition != "" || value.Field != "" || value.Binding != nil || value.Request != nil + case ParameterSourceTrustedResolver: + invalidFields = value.Input != "" || value.Facet != "" || value.AvailableWhen != nil || value.Transition != "" || value.Field != "" || value.Work != "" || value.Output != "" || value.Request != nil + case ParameterSourceHostInput: + invalidFields = value.Input != "" || value.Facet != "" || value.AvailableWhen != nil || value.Transition != "" || value.Field != "" || value.Work != "" || value.Output != "" || value.Binding != nil + } + if invalidFields { + return invalid(field, "producer contains fields owned by another source kind") + } + return nil +} + +func invocationIncomplete(transition, parameter, detail string) error { + return fmt.Errorf("CONTROL_PROGRAM_INVOCATION_INCOMPLETE: transition %q parameter %q %s", transition, parameter, detail) +} + +func containsSource(values []ParameterSourceKind, wanted ParameterSourceKind) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func sameValueType(left, right ValueTypeDefinition) bool { + leftRaw, leftErr := json.Marshal(left) + rightRaw, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && string(leftRaw) == string(rightRaw) +} + +func compatibleEntryInputType(input, wanted string) bool { + if input == wanted { + return true + } + return wanted == "string" && (input == "markdown-file" || input == "text" || input == "string") +} + +func compatibleFacetType(facet, wanted string) bool { + if facet == "enum" { + facet = "string" + } + return facet == wanted +} + +func compatibleWorkOutputType(mediaType, wanted string) bool { + if mediaType == "application/json" { + return wanted == "json" + } + return wanted == "string" +} + +func predicateImplies(guard, condition Predicate) bool { + if condition.True != nil && *condition.True { + return true + } + wanted, _ := json.Marshal(condition) + actual, _ := json.Marshal(guard) + if string(wanted) == string(actual) { + return true + } + for _, child := range guard.All { + encoded, _ := json.Marshal(child) + if string(encoded) == string(wanted) { + return true + } + } + return false +} + +func predicateRequiresKnownFacet(predicate Predicate, facet string) bool { + if predicate.Fact != nil { + if predicate.Fact.Facet != facet { + return false + } + if len(predicate.Fact.Values) != 0 { + return true + } + return len(predicate.Fact.Statuses) == 1 && predicate.Fact.Statuses[0] == "known" + } + if len(predicate.All) != 0 { + for _, child := range predicate.All { + if predicateRequiresKnownFacet(child, facet) { + return true + } + } + return false + } + if len(predicate.Any) != 0 { + for _, child := range predicate.Any { + if !predicateRequiresKnownFacet(child, facet) { + return false + } + } + return true + } + return false +} + +func authorityListSatisfies(actual []string, required AuthorityRequirement) bool { + set := map[string]bool{} + for _, value := range actual { + set[value] = true + } + for _, value := range required.AllOf { + if !set[value] { + return false + } + } + if len(required.AnyOf) == 0 { + return true + } + for _, value := range required.AnyOf { + if set[value] { + return true + } + } + return false +} + +func parameterRequiresAuthority(requirement AuthorityRequirement) bool { + return len(requirement.AnyOf) != 0 || len(requirement.AllOf) != 0 +} + +func parameterDependencyCycle(graph map[string][]string) string { + visiting, visited := map[string]bool{}, map[string]bool{} + var visit func(string) string + visit = func(node string) string { + if visiting[node] { + return node + } + if visited[node] { + return "" + } + visiting[node] = true + for _, dependency := range graph[node] { + if _, local := graph[dependency]; local { + if cycle := visit(dependency); cycle != "" { + return cycle + } + } + } + visiting[node], visited[node] = false, true + return "" + } + for node := range graph { + if cycle := visit(node); cycle != "" { + return cycle + } + } + return "" +} diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 86cc7ab..73d4c80 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,7 +7,7 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 3 + SchemaRevision = 4 ) type Document struct { @@ -112,17 +112,116 @@ type AuthorityRequirement struct { AllOf []string `json:"all_of,omitempty"` } +type ParameterSourceKind string + +const ( + ParameterSourceEntryInput ParameterSourceKind = "entry-input" + ParameterSourceState ParameterSourceKind = "state" + ParameterSourceReceipt ParameterSourceKind = "receipt" + ParameterSourceStateOrReceipt ParameterSourceKind = "state-or-receipt" + ParameterSourceWorkOutput ParameterSourceKind = "work-output" + ParameterSourceTrustedResolver ParameterSourceKind = "trusted-resolver" + ParameterSourceHostInput ParameterSourceKind = "host-input" +) + +type TrustedValidatorBinding struct { + Reference string `json:"reference"` + Version string `json:"version"` + Fingerprint string `json:"fingerprint"` +} + +// ValueTypeDefinition is the closed canonical parameter value-type model. +// Only fields owned by the selected kind may be populated. +type ValueTypeDefinition struct { + Kind string `json:"kind"` + Validator *TrustedValidatorBinding `json:"validator,omitempty"` + Minimum *int64 `json:"minimum,omitempty"` + Maximum *int64 `json:"maximum,omitempty"` + Schema *TrustedValidatorBinding `json:"schema,omitempty"` +} + +type OperatorParameter struct { + ID string `json:"id"` + Type ValueTypeDefinition `json:"type"` + Required bool `json:"required"` + Secret bool `json:"secret"` + AllowedSources []ParameterSourceKind `json:"allowed_sources"` + Authority AuthorityRequirement `json:"authority"` +} + +type ParameterResolverBinding struct { + Reference string `json:"reference"` + Version string `json:"version"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type HostInputRequest struct { + ID string `json:"id"` + Description string `json:"description"` + Authorities []string `json:"authorities"` + Scope string `json:"scope"` +} + +// ParameterProducer is a closed tagged union. The compiler rejects fields +// that do not belong to Kind and resolves trusted bindings before publication. +type ParameterProducer struct { + Kind ParameterSourceKind `json:"kind"` + Input string `json:"input,omitempty"` + Facet string `json:"facet,omitempty"` + AvailableWhen *Predicate `json:"available_when,omitempty"` + Transition string `json:"transition,omitempty"` + Field string `json:"field,omitempty"` + Work string `json:"work,omitempty"` + Output string `json:"output,omitempty"` + Binding *ParameterResolverBinding `json:"binding,omitempty"` + Request *HostInputRequest `json:"request,omitempty"` +} + +type TransitionParameterBinding struct { + Parameter string `json:"parameter"` + Producer ParameterProducer `json:"producer"` +} + type Operator struct { - ID string `json:"id"` - Binding *OperatorBinding `json:"binding,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - Authority AuthorityRequirement `json:"authority"` - Effects []string `json:"effects,omitempty"` - Verifier string `json:"verifier,omitempty"` - Recovery string `json:"recovery,omitempty"` - StateEffect *StateEffect `json:"state_effect,omitempty"` - ExecutionContext string `json:"execution_context"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Binding *OperatorBinding `json:"binding,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Authority AuthorityRequirement `json:"authority"` + Effects []string `json:"effects,omitempty"` + Verifier string `json:"verifier,omitempty"` + Recovery string `json:"recovery,omitempty"` + StateEffect *StateEffect `json:"state_effect,omitempty"` + ExecutionContext string `json:"execution_context"` + Parameters []OperatorParameter `json:"parameters,omitempty"` + Outputs []OperatorOutput `json:"outputs,omitempty"` + StateInputs []OperatorStateInput `json:"state_inputs,omitempty"` + ReceiptInputs []OperatorReceiptInput `json:"receipt_inputs,omitempty"` + Description string `json:"description,omitempty"` +} + +// OperatorOutput declares a committed transition-receipt field that later +// transitions may consume. Trusted bindings own this metadata. +type OperatorOutput struct { + ID string `json:"id"` + Type ValueTypeDefinition `json:"type"` +} + +// OperatorStateInput binds a state-sourced parameter to the exact canonical +// facet and availability predicate owned by a trusted operator adapter. +type OperatorStateInput struct { + Parameter string `json:"parameter"` + Facet string `json:"facet"` + AvailableWhen Predicate `json:"available_when"` +} + +// OperatorReceiptInput binds a receipt-capable parameter to one exact +// committed transition output owned by a trusted operator adapter. Guaranteed +// is required when that receipt is the parameter's only source. +type OperatorReceiptInput struct { + Parameter string `json:"parameter"` + Transition string `json:"transition"` + Field string `json:"field"` + Guaranteed bool `json:"guaranteed,omitempty"` } type StateEffect struct { @@ -150,14 +249,15 @@ type ValueReference struct { } type Transition struct { - ID string `json:"id"` - Operator string `json:"operator"` - Guard Predicate `json:"guard"` - Target Predicate `json:"target"` - Priority int `json:"priority"` - Requires TransitionRequirements `json:"requires,omitempty"` - Work string `json:"work,omitempty"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Operator string `json:"operator"` + Guard Predicate `json:"guard"` + Target Predicate `json:"target"` + Priority int `json:"priority"` + Requires TransitionRequirements `json:"requires,omitempty"` + Work string `json:"work,omitempty"` + Parameters []TransitionParameterBinding `json:"parameters,omitempty"` + Description string `json:"description,omitempty"` } type TransitionRequirements struct { @@ -207,6 +307,8 @@ type EntryInput struct { type BindingResolver interface { ResolveOperator(reference, version string) (ResolvedOperator, error) ResolveDelegation(reference, version string) (ResolvedDelegation, error) + ResolveParameterResolver(reference, version string) (ResolvedParameterResolver, error) + ResolveValueValidator(reference, version string) (ResolvedValueValidator, error) } type ResolvedOperator struct { @@ -218,6 +320,25 @@ type ResolvedOperator struct { Recovery string StateEffect StateEffect ExecutionContext string + Parameters []OperatorParameter + Outputs []OperatorOutput + StateInputs []OperatorStateInput + ReceiptInputs []OperatorReceiptInput +} + +type ResolvedParameterResolver struct { + Fingerprint string + OutputType ValueTypeDefinition + SourceKind ParameterSourceKind + Authority AuthorityRequirement + Dependencies []string + StabilityScope string + MaySuspend bool +} + +type ResolvedValueValidator struct { + Fingerprint string + Type ValueTypeDefinition } type ResolvedDelegation struct { diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index 4073ed7..a1565bc 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -94,11 +94,15 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro } func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) { - response := surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID} + response := surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Invocation: request.InvocationEvidence} if err := request.Validate(k.clock.Now()); err != nil { response.Error = err.Error() return response, err } + if request.InputRequest != nil { + response.InputRequest = request.InputRequest + return response, nil + } if request.Operation == surfaces.OperationCatalog { response.Catalog = k.registry.All() return response, nil @@ -126,7 +130,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} + 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} resolution, resolveErr := k.engine.Resolve(ctx, resolveRequest) if !explain && resolveErr == nil && resolution.Decision.Kind == supervisor.DecisionCandidate && resolution.Decision.Transition != nil && resolution.Decision.Transition.Work != nil { record, workErr := k.work.Ensure(ctx, invocation, request.FlowID, request.ProgramID, request.EntryID, resolution.Objective, resolution.Snapshot, *resolution.Decision.Transition, request.WorkInputs) @@ -183,7 +187,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}, + ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID, Work: work, ControlBundle: request.ControlBundle, 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/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index db0ff01..1c53319 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -19,6 +19,7 @@ import ( const BindingPrefix = "software-delivery/" const DelegationPrefix = BindingPrefix + "delegation/" +const ParameterResolverPrefix = BindingPrefix type Resolver struct { transitions map[string]delivery.Transition @@ -56,7 +57,10 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res return controlprogram.ResolvedOperator{}, fmt.Errorf("operator %q requires binding version %d", id, transition.Version) } transition.ExecutionContext = executionContextFor(transition) - fingerprint, err := transitionFingerprint(transition) + outputs := projectOperatorOutputs(transition) + stateInputs := projectOperatorStateInputs(transition) + receiptInputs := projectOperatorReceiptInputs(transition) + fingerprint, err := transitionFingerprint(transition, outputs, stateInputs, receiptInputs) if err != nil { return controlprogram.ResolvedOperator{}, err } @@ -85,9 +89,72 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res Authority: controlprogram.AuthorityRequirement{AnyOf: anyOf, AllOf: allOf}, Effects: effects, Verifier: transition.Verifier, Recovery: string(transition.Interruption.Recovery), StateEffect: projectStateEffect(transition.StateEffect), ExecutionContext: executionContextFor(transition), + Parameters: projectOperatorParameters(transition), + Outputs: outputs, + StateInputs: stateInputs, + ReceiptInputs: receiptInputs, }, nil } +func (r Resolver) ResolveParameterResolver(reference, version string) (controlprogram.ResolvedParameterResolver, error) { + if version != "1" { + return controlprogram.ResolvedParameterResolver{}, fmt.Errorf("parameter resolver %q requires binding version 1", reference) + } + value := controlprogram.ResolvedParameterResolver{ + OutputType: controlprogram.ValueTypeDefinition{Kind: "string"}, + SourceKind: controlprogram.ParameterSourceTrustedResolver, + StabilityScope: "invocation", + } + switch { + case reference == ParameterResolverPrefix+"admitted-planning-package-fingerprint": + value.Dependencies = []string{"repository", "delivery_id", "admitted-planning-package-manifest"} + case reference == ParameterResolverPrefix+"repository-default-branch": + value.Dependencies = []string{"repository", "verified-configuration"} + case reference == ParameterResolverPrefix+"delivery-branch": + value.Dependencies = []string{"repository", "delivery_id", "repository-policy"} + case reference == ParameterResolverPrefix+"managed-worktree-destination": + value.Dependencies = []string{"repository", "git-common", "run_id", "delivery_id", "source-worktree"} + case reference == ParameterResolverPrefix+"current-source-revision": + value.Dependencies = []string{"repository", "committed-head"} + case strings.HasPrefix(reference, ParameterResolverPrefix+"gate-evidence-path/"): + if gateEvidenceInputPath(strings.TrimPrefix(reference, ParameterResolverPrefix+"gate-evidence-path/")) == "" { + return controlprogram.ResolvedParameterResolver{}, fmt.Errorf("unknown software-delivery parameter resolver %q", reference) + } + value.Dependencies = []string{"repository", "delivery_id", "gate-evidence"} + case strings.HasPrefix(reference, ParameterResolverPrefix+"gate-evidence-fingerprint/"): + if gateEvidenceInputPath(strings.TrimPrefix(reference, ParameterResolverPrefix+"gate-evidence-fingerprint/")) == "" { + return controlprogram.ResolvedParameterResolver{}, fmt.Errorf("unknown software-delivery parameter resolver %q", reference) + } + value.Dependencies = []string{"repository", "delivery_id", "gate-evidence"} + case reference == ParameterResolverPrefix+"visual-evidence-manifest-path": + value.Dependencies = []string{"repository", "delivery_id", "visual-evidence"} + case reference == ParameterResolverPrefix+"visual-evidence-privacy-receipt": + value.Dependencies = []string{"repository", "delivery_id", "visual-evidence"} + case reference == ParameterResolverPrefix+"publication-body-path": + value.Dependencies = []string{"repository", "delivery_id", "publication-body"} + case reference == ParameterResolverPrefix+"publication-body-sha256": + value.Dependencies = []string{"repository", "delivery_id", "publication-body"} + default: + return controlprogram.ResolvedParameterResolver{}, fmt.Errorf("unknown software-delivery parameter resolver %q", reference) + } + payload := struct { + Reference string `json:"reference"` + Version string `json:"version"` + Metadata controlprogram.ResolvedParameterResolver `json:"metadata"` + }{Reference: reference, Version: version, Metadata: value} + encoded, err := json.Marshal(payload) + if err != nil { + return controlprogram.ResolvedParameterResolver{}, err + } + digest := sha256.Sum256(encoded) + value.Fingerprint = hex.EncodeToString(digest[:]) + return value, nil +} + +func (r Resolver) ResolveValueValidator(reference, version string) (controlprogram.ResolvedValueValidator, error) { + return controlprogram.ResolvedValueValidator{}, fmt.Errorf("unknown software-delivery value validator %q at version %q", reference, version) +} + func (r Resolver) ResolveDelegation(reference, version string) (controlprogram.ResolvedDelegation, error) { authority, ok := strings.CutPrefix(reference, DelegationPrefix) if !ok || authority != string(delivery.AuthorityAutonomy) || version != "1" { @@ -155,8 +222,94 @@ func projectStateEffect(value delivery.StateEffect) controlprogram.StateEffect { return result } -func transitionFingerprint(value delivery.Transition) (string, error) { - encoded, err := json.Marshal(value) +func projectOperatorParameters(transition delivery.Transition) []controlprogram.OperatorParameter { + result := make([]controlprogram.OperatorParameter, 0, len(transition.Parameters)) + for _, parameter := range transition.Parameters { + allowed := []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceHostInput} + switch { + case transition.ID == PlanningPackageApprove && parameter.Name == "package_fingerprint": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case transition.ID == "workspace.cut": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case strings.HasPrefix(string(transition.ID), "gate.") && strings.HasSuffix(string(transition.ID), ".record"): + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case transition.ID == "evidence.visual.attach": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case transition.ID == "delivery.slice.advance" && parameter.Name == "source_revision": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case transition.ID == "publication.preview" && (parameter.Name == "base_ref" || parameter.Name == "body_path"): + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case transition.ID == "publication.preview" && parameter.Name == "head_ref": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + case transition.ID == "publication.execute" && parameter.Name == "preview_fingerprint": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + case transition.ID == "publication.observe" && parameter.Name == "publication_id": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState, controlprogram.ParameterSourceStateOrReceipt} + case transition.ID == "publication.correct" && parameter.Name == "publication_id": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + case transition.ID == "publication.correct" && (parameter.Name == "body_path" || parameter.Name == "body_sha256"): + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case (transition.ID == "workspace.reconcile" || transition.ID == "publication.reconcile") && parameter.Name == "transaction_id": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + case transition.ID == "publication.reconcile" && parameter.Name == "publication_id": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + case parameter.Name == "branch": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + } + result = append(result, controlprogram.OperatorParameter{ + ID: parameter.Name, Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: parameter.Required, Secret: parameter.Secret, + AllowedSources: allowed, + }) + } + return result +} + +func projectOperatorOutputs(transition delivery.Transition) []controlprogram.OperatorOutput { + if transition.ID == "publication.execute" { + return []controlprogram.OperatorOutput{{ID: "publication_id", Type: controlprogram.ValueTypeDefinition{Kind: "string"}}} + } + return nil +} + +func projectOperatorStateInputs(transition delivery.Transition) []controlprogram.OperatorStateInput { + known := func(parameter, facet string) controlprogram.OperatorStateInput { + return controlprogram.OperatorStateInput{ + Parameter: parameter, Facet: facet, + AvailableWhen: controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}}}, + } + } + switch transition.ID { + case "workspace.activate", "workspace.sync", "workspace.publish": + return []controlprogram.OperatorStateInput{known("branch", "workspace_branch")} + case "publication.preview": + return []controlprogram.OperatorStateInput{known("head_ref", "workspace_branch")} + case "publication.execute": + return []controlprogram.OperatorStateInput{known("preview_fingerprint", "preview_fingerprint")} + case "publication.observe", "publication.correct": + return []controlprogram.OperatorStateInput{known("publication_id", "publication_id")} + case "workspace.reconcile": + return []controlprogram.OperatorStateInput{known("transaction_id", RecoveryTransactionFacet)} + case "publication.reconcile": + return []controlprogram.OperatorStateInput{known("transaction_id", RecoveryTransactionFacet)} + default: + return nil + } +} + +func projectOperatorReceiptInputs(transition delivery.Transition) []controlprogram.OperatorReceiptInput { + if transition.ID == "publication.observe" { + return []controlprogram.OperatorReceiptInput{{Parameter: "publication_id", Transition: "publication.execute", Field: "publication_id"}} + } + return nil +} + +func transitionFingerprint(value delivery.Transition, outputs []controlprogram.OperatorOutput, stateInputs []controlprogram.OperatorStateInput, receiptInputs []controlprogram.OperatorReceiptInput) (string, error) { + encoded, err := json.Marshal(struct { + Transition delivery.Transition `json:"transition"` + Outputs []controlprogram.OperatorOutput `json:"outputs,omitempty"` + StateInputs []controlprogram.OperatorStateInput `json:"state_inputs,omitempty"` + ReceiptInputs []controlprogram.OperatorReceiptInput `json:"receipt_inputs,omitempty"` + }{Transition: value, Outputs: outputs, StateInputs: stateInputs, ReceiptInputs: receiptInputs}) if err != nil { return "", err } diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index 8841f6d..988e6f5 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -98,7 +98,7 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim if !exists { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q references unknown foreground work %q", declaration.ID, declaration.Work) } - transition.Work, err = runtimeWorkContract(work) + transition.Work, err = RuntimeWorkContract(work) if err != nil { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) } @@ -181,7 +181,9 @@ func requireReachableEntryInputs(transition delivery.Transition, entriesByTarget return nil } -func runtimeWorkContract(declaration controlprogram.WorkContract) (*delivery.WorkContract, error) { +// RuntimeWorkContract projects one exact canonical foreground-work contract +// for both runtime admission and invocation producer validation. +func RuntimeWorkContract(declaration controlprogram.WorkContract) (*delivery.WorkContract, error) { work := &delivery.WorkContract{ ID: declaration.ID, InstructionPath: declaration.Instructions.Path, InstructionSHA256: declaration.Instructions.SHA256, InstructionContent: declaration.Instructions.Content, diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index ca9fbf7..3276afe 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -26,10 +26,10 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, Facets: []controlprogram.Facet{ {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, - {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, + {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, {ID: "publication_id", Kind: "string"}, }, Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, - Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: guard, Target: controlprogram.Predicate{True: &truth}, Priority: 77}}, + Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: guard, Target: controlprogram.Predicate{True: &truth}, Priority: 77, Parameters: publicationIDStateParameter(guard)}}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ fact("verification", "current"), fact("configuration", "verified"), fact("runtime", "verified"), fact("publication", "open"), }}}}, @@ -42,6 +42,13 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. return compiled, resolver } +func publicationIDStateParameter(_ controlprogram.Predicate) []controlprogram.TransitionParameterBinding { + available := controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: "publication_id", Statuses: []string{"known"}}} + return []controlprogram.TransitionParameterBinding{{Parameter: "publication_id", Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceState, Facet: "publication_id", AvailableWhen: &available, + }}} +} + func fact(facet, value string) controlprogram.Predicate { return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}, Values: []string{value}}} } @@ -95,6 +102,7 @@ func TestRepositoryGuardCanOnlyStrengthenTrustedBinding(t *testing.T) { } invalid := compiled.Document invalid.Transitions[0].Guard = controlprogram.Predicate{Any: []controlprogram.Predicate{fact("publication", "candidate"), fact("publication", "open")}} + invalid.Transitions[0].Parameters = publicationIDStateParameter(invalid.Transitions[0].Guard) nonConjunctive, err := controlprogram.Compile(invalid, resolver) if err != nil { t.Fatal(err) @@ -147,6 +155,22 @@ func TestPublicationBindingPreservesProviderAsMandatory(t *testing.T) { } } +func TestPublicationReconcileBindingDoesNotRequireUncommittedPublicationOutput(t *testing.T) { + // control-law: recovery inputs survive an interrupted external effect + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + resolved, err := resolver.ResolveOperator("software-delivery/publication.reconcile", "1") + if err != nil { + t.Fatal(err) + } + if len(resolved.Parameters) != 1 || resolved.Parameters[0].ID != "transaction_id" || + len(resolved.StateInputs) != 1 || resolved.StateInputs[0].Parameter != "transaction_id" || resolved.StateInputs[0].Facet != softwareflow.RecoveryTransactionFacet { + t.Fatalf("publication reconciliation inputs = parameters %#v state %#v", resolved.Parameters, resolved.StateInputs) + } +} + func contains(values []string, wanted string) bool { for _, value := range values { if value == wanted { @@ -301,14 +325,14 @@ func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) Facets: []controlprogram.Facet{ {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, - {ID: "delivery", Kind: "string"}, {ID: "workspace", Kind: "string"}, + {ID: "delivery", Kind: "string"}, {ID: "workspace", Kind: "string"}, {ID: "publication_id", Kind: "string"}, }, Operators: []controlprogram.Operator{ {ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}, {ID: "plan.abandon", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/plan.abandon", Version: "1"}}, }, Transitions: []controlprogram.Transition{ - {ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77}, + {ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77, Parameters: publicationIDStateParameter(controlprogram.Predicate{True: &truth})}, {ID: "plan.abandon", Operator: "plan.abandon", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 31}, }, Targets: []controlprogram.Target{ diff --git a/boatstack/flow/softwaredelivery/parameter_runtime.go b/boatstack/flow/softwaredelivery/parameter_runtime.go new file mode 100644 index 0000000..8af2121 --- /dev/null +++ b/boatstack/flow/softwaredelivery/parameter_runtime.go @@ -0,0 +1,272 @@ +package softwaredelivery + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/invocation" + general "github.com/operatorstack/boatstack/boatstack/kernel" +) + +var managedBranchSegment = regexp.MustCompile(`[^a-z0-9._-]+`) + +const RecoveryTransactionFacet = "recovery_transaction_id" + +// RuntimeParameterResolver executes only trusted software-delivery resolver +// bindings copied into canonical IR. Repository Flow source cannot provide +// executable resolver code through this boundary. +type RuntimeParameterResolver struct { + Context context.Context + Repository string + DeliveryID string + SourceRevision string + Binding Resolver +} + +// StateParameterValues projects software-delivery durable fields into the +// domain-neutral invocation value interface. +func StateParameterValues(state durable.State) map[string]invocation.Value { + values := map[string]string{ + "workspace_branch": state.WorkspaceBranch, + "preview_fingerprint": state.PreviewFingerprint, + "publication_id": state.PublicationID, + "transaction_id": state.TransactionID, + } + result := map[string]invocation.Value{} + for facet, value := range values { + if value != "" { + result[facet] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value, Provenance: "durable-state:" + facet} + } + } + return result +} + +// UsesObservationParameterValues reports whether a transition declares a +// trusted state producer whose value is owned by the current plant observation +// rather than durable state. +func UsesObservationParameterValues(bindings []controlprogram.TransitionParameterBinding) bool { + for _, binding := range bindings { + if binding.Producer.Kind == controlprogram.ParameterSourceState && binding.Producer.Facet == RecoveryTransactionFacet { + return true + } + } + return false +} + +// ObservationParameterValues projects current recovery context into the +// domain-neutral invocation value interface. The pending-journal observation +// remains authoritative when an interrupted effect did not update durable +// transaction state. +func ObservationParameterValues(observation model.Observation) map[string]invocation.Value { + result := map[string]invocation.Value{} + if observation.RecoveryInfo.Status == model.FactKnown && observation.RecoveryInfo.Value.TransactionID != "" { + result[RecoveryTransactionFacet] = invocation.Value{ + Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: observation.RecoveryInfo.Value.TransactionID, + Provenance: "observation:recovery-info", + } + } + return result +} + +func (r RuntimeParameterResolver) ResolveParameter(binding controlprogram.ParameterResolverBinding, materialization invocation.Context) (invocation.Value, error) { + resolved, err := r.Binding.ResolveParameterResolver(binding.Reference, binding.Version) + if err != nil { + return invocation.Value{}, err + } + if binding.Fingerprint == "" || binding.Fingerprint != resolved.Fingerprint { + return invocation.Value{}, fmt.Errorf("trusted parameter resolver fingerprint drift") + } + value := invocation.Value{Type: resolved.OutputType, ProducerFingerprint: resolved.Fingerprint} + switch { + case binding.Reference == ParameterResolverPrefix+"admitted-planning-package-fingerprint": + fingerprint, fingerprintErr := PlanningPackageFingerprint(r.Repository, r.DeliveryID) + if fingerprintErr != nil { + return invocation.Value{}, fingerprintErr + } + value.Canonical, value.Provenance = fingerprint, "planning-package-manifest:"+fingerprint + case binding.Reference == ParameterResolverPrefix+"repository-default-branch": + configPath := filepath.Join(r.Repository, ".boatstack", "project.json") + raw, readErr := os.ReadFile(configPath) + if readErr != nil { + return invocation.Value{}, fmt.Errorf("read verified repository configuration: %w", readErr) + } + config, decodeErr := protocol.DecodeProjectConfig(raw) + if decodeErr != nil { + return invocation.Value{}, fmt.Errorf("decode verified repository configuration: %w", decodeErr) + } + _, configFingerprint, fingerprintErr := protocol.ProjectConfigFingerprint(raw) + if fingerprintErr != nil || config.Project.DefaultBranch == "" { + return invocation.Value{}, fmt.Errorf("repository default branch configuration is missing or unverified") + } + value.Canonical, value.Provenance = config.Project.DefaultBranch, "project-config:"+configFingerprint + case binding.Reference == ParameterResolverPrefix+"delivery-branch": + segment := strings.Trim(managedBranchSegment.ReplaceAllString(strings.ToLower(r.DeliveryID), "-"), "-.") + if segment == "" || r.DeliveryID == "" { + return invocation.Value{}, fmt.Errorf("delivery identity cannot produce a managed branch") + } + branch := "feat/" + segment + plantResolver, resolverErr := plant.NewResolver("") + if resolverErr != nil { + return invocation.Value{}, resolverErr + } + exists, inspectErr := plantResolver.BranchExists(r.Context, r.Repository, branch) + if inspectErr != nil { + return invocation.Value{}, fmt.Errorf("inspect managed branch: %w", inspectErr) + } + if exists { + return invocation.Value{}, fmt.Errorf("managed branch %q already exists and cannot be silently reused", branch) + } + value.Canonical, value.Provenance = branch, "delivery:"+r.DeliveryID + case binding.Reference == ParameterResolverPrefix+"managed-worktree-destination": + repository, canonicalErr := filepath.Abs(r.Repository) + if canonicalErr != nil { + return invocation.Value{}, canonicalErr + } + if resolvedRepository, resolveErr := filepath.EvalSymlinks(repository); resolveErr == nil { + repository = resolvedRepository + } + root := filepath.Dir(repository) + segment := strings.Trim(managedBranchSegment.ReplaceAllString(strings.ToLower(r.DeliveryID), "-"), "-.") + if segment == "" { + return invocation.Value{}, fmt.Errorf("delivery identity cannot produce a managed destination") + } + destination := filepath.Clean(filepath.Join(root, filepath.Base(repository)+"-"+segment)) + relative, relErr := filepath.Rel(root, destination) + if relErr != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return invocation.Value{}, fmt.Errorf("managed destination escapes trusted worktree root") + } + if _, statErr := os.Lstat(destination); statErr == nil { + return invocation.Value{}, fmt.Errorf("managed destination already exists and conflicts with this run") + } else if !os.IsNotExist(statErr) { + return invocation.Value{}, statErr + } + plantResolver, resolverErr := plant.NewResolver("") + if resolverErr != nil { + return invocation.Value{}, resolverErr + } + invoking, resolveErr := plantResolver.ResolveInvocation(r.Context, r.Repository, "cli", "parameter-resolver") + if resolveErr != nil { + return invocation.Value{}, resolveErr + } + scopeFingerprint, fingerprintErr := general.Fingerprint(struct { + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + WorktreeID string `json:"worktree_id"` + Ref string `json:"ref"` + }{invoking.RepositoryID, invoking.GitCommonID, invoking.WorktreeID, invoking.Ref}) + if fingerprintErr != nil || scopeFingerprint != materialization.ExecutionScopeFingerprint { + return invocation.Value{}, fmt.Errorf("managed destination invocation scope drift") + } + identity := sha256.Sum256(bytes.Join([][]byte{[]byte(invoking.RepositoryID), []byte(invoking.GitCommonID), []byte(materialization.RunID), []byte(r.DeliveryID), []byte(invoking.WorktreeID), []byte(invoking.Ref)}, []byte{0})) + value.Canonical, value.Provenance = destination, "workspace-layout:"+hex.EncodeToString(identity[:]) + case binding.Reference == ParameterResolverPrefix+"current-source-revision": + if (len(r.SourceRevision) != 40 && len(r.SourceRevision) != 64) || strings.Trim(r.SourceRevision, "0123456789abcdef") != "" { + return invocation.Value{}, fmt.Errorf("current committed source revision is unavailable") + } + value.Canonical, value.Provenance = r.SourceRevision, "repository-head" + case strings.HasPrefix(binding.Reference, ParameterResolverPrefix+"gate-evidence-path/"): + gate := strings.TrimPrefix(binding.Reference, ParameterResolverPrefix+"gate-evidence-path/") + path, _, readErr := readCanonicalParameterArtifact(r.Repository, r.DeliveryID, gateEvidenceInputPath(gate)) + if readErr != nil { + return invocation.Value{}, readErr + } + value.Canonical, value.Provenance = path, "gate-evidence:"+gate + case strings.HasPrefix(binding.Reference, ParameterResolverPrefix+"gate-evidence-fingerprint/"): + gate := strings.TrimPrefix(binding.Reference, ParameterResolverPrefix+"gate-evidence-fingerprint/") + _, raw, readErr := readCanonicalParameterArtifact(r.Repository, r.DeliveryID, gateEvidenceInputPath(gate)) + if readErr != nil { + return invocation.Value{}, readErr + } + digest := sha256.Sum256(raw) + value.Canonical, value.Provenance = hex.EncodeToString(digest[:]), "gate-evidence:"+gate + case binding.Reference == ParameterResolverPrefix+"visual-evidence-manifest-path": + path, _, readErr := readCanonicalParameterArtifact(r.Repository, r.DeliveryID, "visual-manifest.input.json") + if readErr != nil { + return invocation.Value{}, readErr + } + value.Canonical, value.Provenance = path, "visual-evidence-manifest" + case binding.Reference == ParameterResolverPrefix+"visual-evidence-privacy-receipt": + _, raw, readErr := readCanonicalParameterArtifact(r.Repository, r.DeliveryID, "visual-manifest.input.json") + if readErr != nil { + return invocation.Value{}, readErr + } + digest := sha256.Sum256(raw) + value.Canonical, value.Provenance = hex.EncodeToString(digest[:]), "visual-evidence-manifest" + case binding.Reference == ParameterResolverPrefix+"publication-body-path": + path, _, readErr := readCanonicalPublicationBody(r.Repository, r.DeliveryID) + if readErr != nil { + return invocation.Value{}, readErr + } + value.Canonical, value.Provenance = path, "publication-body" + case binding.Reference == ParameterResolverPrefix+"publication-body-sha256": + _, raw, readErr := readCanonicalPublicationBody(r.Repository, r.DeliveryID) + if readErr != nil { + return invocation.Value{}, readErr + } + digest := sha256.Sum256(raw) + value.Canonical, value.Provenance = hex.EncodeToString(digest[:]), "publication-body" + default: + return invocation.Value{}, fmt.Errorf("unknown runtime parameter resolver %q", binding.Reference) + } + return value, nil +} + +func gateEvidenceInputPath(gate string) string { + switch gate { + case "build", "test", "review", "change", "journey": + return gate + ".input.json" + default: + return "" + } +} + +func readCanonicalParameterArtifact(repository, deliveryID, name string) (string, []byte, error) { + if !planningPackageSegment.MatchString(deliveryID) || name == "" || filepath.Base(name) != name { + return "", nil, fmt.Errorf("canonical parameter artifact identity is invalid") + } + return readRegularParameterArtifact(repository, filepath.Join(".boatstack", "evidence", deliveryID, name)) +} + +func readCanonicalPublicationBody(repository, deliveryID string) (string, []byte, error) { + if !planningPackageSegment.MatchString(deliveryID) { + return "", nil, fmt.Errorf("publication body delivery identity is invalid") + } + return readRegularParameterArtifact(repository, filepath.Join(".boatstack", "publication", deliveryID+".body.md")) +} + +func readRegularParameterArtifact(repository, relative string) (string, []byte, error) { + root, err := filepath.Abs(repository) + if err != nil { + return "", nil, err + } + path := filepath.Join(root, relative) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return "", nil, fmt.Errorf("canonical parameter artifact is unavailable: %s", path) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", nil, err + } + rel, err := filepath.Rel(root, resolved) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", nil, fmt.Errorf("canonical parameter artifact escapes the repository") + } + raw, err := os.ReadFile(resolved) + if err != nil || len(raw) == 0 { + return "", nil, fmt.Errorf("canonical parameter artifact is empty or unreadable: %s", resolved) + } + return resolved, raw, nil +} diff --git a/boatstack/flow/softwaredelivery/parameter_runtime_test.go b/boatstack/flow/softwaredelivery/parameter_runtime_test.go new file mode 100644 index 0000000..aa54765 --- /dev/null +++ b/boatstack/flow/softwaredelivery/parameter_runtime_test.go @@ -0,0 +1,76 @@ +package softwaredelivery + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/invocation" +) + +func TestAdmittedPlanningPackageFingerprintMaterializesManifestIdentity(t *testing.T) { + repository := t.TempDir() + deliveryID := "todo-plan" + manifestFingerprint := strings.Repeat("a", 64) + workResultFingerprint := strings.Repeat("b", 64) + root := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + manifest := []byte(`{"work_result_fingerprint":"` + workResultFingerprint + `","fingerprint":"` + manifestFingerprint + `"}`) + if err := os.WriteFile(filepath.Join(root, "manifest.json"), manifest, 0o600); err != nil { + t.Fatal(err) + } + resolver, err := NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + metadata, err := resolver.ResolveParameterResolver(ParameterResolverPrefix+"admitted-planning-package-fingerprint", "1") + if err != nil { + t.Fatal(err) + } + producer := controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{ + Reference: ParameterResolverPrefix + "admitted-planning-package-fingerprint", Version: "1", Fingerprint: metadata.Fingerprint, + }} + materialization, err := invocation.Materialize( + []controlprogram.OperatorParameter{{ID: "package_fingerprint", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver}}}, + []controlprogram.TransitionParameterBinding{{Parameter: "package_fingerprint", Producer: producer}}, + invocation.Context{ + RunID: "run-package", ProgramFingerprint: strings.Repeat("c", 64), ExecutionProgramFingerprint: strings.Repeat("d", 64), + EntryID: "run", TargetID: "published-pr", TransitionID: PlanningPackageApprove, StateRevision: 7, + ContextFingerprint: strings.Repeat("e", 64), ExecutionScopeFingerprint: strings.Repeat("f", 64), + }, + RuntimeParameterResolver{Context: context.Background(), Repository: repository, DeliveryID: deliveryID, Binding: resolver}, + ) + if err != nil { + t.Fatal(err) + } + if materialization.Request != nil || materialization.Blocker != nil || materialization.Ready == nil { + t.Fatalf("materialization = %#v", materialization) + } + parameters := materialization.Ready.Parameters + if len(parameters) != 1 || parameters[0].Value != manifestFingerprint || parameters[0].Value == workResultFingerprint || parameters[0].ProducerKind != controlprogram.ParameterSourceTrustedResolver { + t.Fatalf("package fingerprint parameters = %#v", parameters) + } +} + +func TestObservationParameterValuesUseExactRecoveryTransaction(t *testing.T) { + evidence := model.Evidence{Source: "journal", Fingerprint: strings.Repeat("a", 64), ObservedAt: time.Unix(10, 0).UTC()} + observation := model.Observation{RecoveryInfo: model.Known(model.RecoveryContext{ + TransactionID: "adm-interrupted", Cause: "provider outcome unknown", SourcePhase: model.PhaseExecutingExternal, + Permitted: []string{"publication.reconcile"}, BudgetRemaining: 3, Resumption: model.PhaseActive, + }, evidence)} + values := ObservationParameterValues(observation) + value, ok := values[RecoveryTransactionFacet] + if !ok || value.Canonical != "adm-interrupted" || value.Provenance != "observation:recovery-info" { + t.Fatalf("observed recovery parameter = %#v", values) + } + if values["transaction_id"].Canonical != "" { + t.Fatalf("observation invented durable transaction state: %#v", values) + } +} diff --git a/boatstack/flow/softwaredelivery/planning_package.go b/boatstack/flow/softwaredelivery/planning_package.go index 95aca83..8ab08ac 100644 --- a/boatstack/flow/softwaredelivery/planning_package.go +++ b/boatstack/flow/softwaredelivery/planning_package.go @@ -37,6 +37,9 @@ func planningPackageTransitions(transitions map[string]delivery.Transition) ([]d return nil, err } admit.ID, admit.Effect = PlanningPackageAdmit, PlanningPackageAdmit + // The planning-package effect consumes exact foreground-work evidence. The + // plan.create parameter contract is not part of this derived operation. + admit.Parameters = nil admit.LocalEffects = []delivery.EffectID{PlanningPackageAdmit} admit.Prescription.Operation = PlanningPackageAdmit admit.Prescription.ExpectedPostcondition = "a schema-valid planning package is admitted" diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 793a4d2..d2f3847 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -57,7 +57,60 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s delegation := "" diagnostics := "" workProtocol := "" + inputProtocol := "" + gateEvidenceProtocol := "" + entryInputProtocol := "" + 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 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 { + var required []string + for _, input := range entry.Inputs { + if input.Required { + required = append(required, input.ID) + } + } + if len(required) != 0 { + entryInputProtocol = fmt.Sprintf(` +Supply each required entry input exactly once on the first command with a +repeatable `+"`--input name=value`"+` flag. Required input IDs: %s. Preserve the +same values when explicitly restating them after restart; never substitute an +input on an existing run. +`, strings.Join(required, ", ")) + } + } + } else { + programReconciliation = fmt.Sprintf(` +If Boatstack returns `+"`UNRESOLVED`"+` solely because the selected compiled +program differs from the admitted program, treat it as an installation-authority +suspension before product work, not as terminal Flow failure. Preserve the same +run ID, but do not request or reuse product delegation before reconciliation. +Display the exact prior program fingerprint, candidate program fingerprint, +program-delta fingerprint, required transition, and acceptance flag. Ask for +explicit human acceptance of that exact delta separately from delegation +approval. Never infer acceptance from repository authority, autonomy, +installation, or a previous program change. + +Continue only when the response names `+"`installation.reconcile-update`"+` and +`+"`--accept-program-change`"+`, and the user accepts the displayed exact delta. +Then run: + +`+"`boatstack reconcile-update --repo . --flow %s --entry %s --run-id --accept-program-change --human --host %s --format json`"+` + +Require a committed `+"`installation.reconcile-update`"+` receipt whose prior, +candidate, and delta fingerprints match the accepted suspension and whose +program-change acceptance is true. If the receipt changes tracked control-bundle +files, verify that only its declared installation result changed, then commit +those exact files separately before product work. Rerun the same Flow run. Ask +for product delegation only after Boatstack returns the new exact delegation +request bound to the accepted bundle; then resume with that one delegation. +If the user declines, any fingerprint changes, the required transition differs, +reconciliation does not commit, or unrelated files changed, stop without +performing product effects. +`, compiled.Document.Program.ID, entry.ID, host) + } if len(compiled.Document.Work) != 0 { workProtocol = fmt.Sprintf(` When a response contains a `+"`work`"+` request, treat it as foreground work for @@ -79,6 +132,69 @@ and run ID afterward. Never edit the work record directly or continue in the background while a question is open. `, compiled.Document.Program.ID, entry.ID, host) } + if hasHostInputProducer(compiled.Document.Transitions) { + inputProtocol = fmt.Sprintf(` +When Boatstack returns `+"`TRANSITION_INPUT_REQUIRED`"+`, preserve the exact run, +program, entry, target, transition, state, context, control-bundle, and request +fingerprints. Inspect the runtime-owned request with: + +`+"`boatstack flow input show --repo . --flow %s --entry %s --run-id --request-fingerprint --host %s --format json`"+` + +Ask the user only for the bounded values in that request. Write a temporary +JSON answer object outside repository-tracked paths and submit it only with: + +`+"`boatstack flow input answer --repo . --flow %s --entry %s --run-id --request-fingerprint --answer --human --host %s --format json`"+` + +Resume the same run after the receipt is recorded. Never guess a value, pass a +Flow `+"`--param`"+`, reuse `+"`flow work answer`"+`, or edit runtime input receipts. + +If transition preflight semantically rejects an already recorded free-form +answer, preserve that request and receipt. Ask the user for the corrected value, +then create a new immutable request generation with: + +`+"`boatstack flow input supersede --repo . --flow %s --entry %s --run-id --request-fingerprint --reason --human --host %s --format json`"+` + +Answer only the new request fingerprint. Never overwrite or delete the rejected +generation. +`, compiled.Document.Program.ID, entry.ID, host, compiled.Document.Program.ID, entry.ID, host, compiled.Document.Program.ID, entry.ID, host) + } + if entry.Target == "published-pr" && hasGateEvidenceProducer(compiled.Document.Transitions) { + gateEvidenceProtocol = ` +If Boatstack returns ` + "`TRANSITION_INPUT_BLOCKED`" + ` because a canonical +gate-evidence input is unavailable, treat it as a bounded product-work +suspension before gate admission, not as terminal Flow failure and not as a +request for human text. Stay in the exact managed worktree named by the current +snapshot. Never continue product work in the parked source worktree. + +For the first gate, implement only the exact approved plan under the active +delegation. Run the repository's real check for each named gate. Commit the +intended product change on the managed branch before preparing gate evidence, +so ` + "`source_revision`" + ` names the exact checked commit. Do not claim a +passed outcome from model confidence or from an unexecuted check. + +After a successful check, prepare the exact ignored input +` + "`.boatstack/evidence//.input.json`" + ` as strict JSON: + +` + "```json" + ` +{ + "schema_version": 1, + "gate": "", + "source_revision": "", + "outcome": "passed", + "producer": "", + "completed_at": "" +} +` + "```" + ` + +Resume this same entry and run. Boatstack binds the canonical path and bytes, +reruns configured build or test commands at the admitted transition, and +records its own evidence receipt. Never pass these values with ` + "`--param`" + `, +write a passed input after a failed check, edit controller state, or substitute +one gate's evidence for another. If the check cannot pass within the approved +plan, preserve the failure and report the blocker. +` + } + inputProtocol += gateEvidenceProtocol if entry.Diagnostics != nil && entry.Diagnostics.ExplainOnSuspend { diagnostics = fmt.Sprintf(` If Boatstack suspends this run without reaching the target or prescribing an @@ -140,7 +256,7 @@ Boatstack does not interpret the entry name. %s -Start with `+"`boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json`"+`. +Start with `+"`%s`"+`. Preserve the returned program fingerprint, entry, run ID, delivery, repository, worktree, host, actor, authority receipts, prescription, and receipts through every `+"`next`"+`, `+"`apply`"+`, recovery, question, and re-resolution. @@ -154,11 +270,42 @@ background while input is missing. Never synthesize authority. %s %s %s +%s +%s +%s Stop only when Boatstack reports the marked target, a typed blocker, refusal, unresolved recovery, or missing authority. This entry grants no merge or deploy authority. -`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics, workProtocol, publication)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), startCommand, delegation, supersession, diagnostics, workProtocol, inputProtocol, entryInputProtocol, programReconciliation, publication)) +} + +func declarativeProgram(operators []controlprogram.Operator) bool { + return len(operators) != 0 && operators[0].Binding == nil +} + +func hasHostInputProducer(transitions []controlprogram.Transition) bool { + for _, transition := range transitions { + for _, binding := range transition.Parameters { + if binding.Producer.Kind == controlprogram.ParameterSourceHostInput { + return true + } + } + } + return false +} + +func hasGateEvidenceProducer(transitions []controlprogram.Transition) bool { + for _, transition := range transitions { + for _, parameter := range transition.Parameters { + producer := parameter.Producer + if producer.Kind == controlprogram.ParameterSourceTrustedResolver && producer.Binding != nil && + strings.HasPrefix(producer.Binding.Reference, ParameterResolverPrefix+"gate-evidence-") { + return true + } + } + } + return false } func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 35d5e09..3606c76 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -39,6 +39,10 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { "--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", + "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", + "Ask\nfor product delegation only after Boatstack returns the new exact delegation", } { if !strings.Contains(value, contract) { t.Fatalf("generated skill lacks %q", contract) @@ -51,6 +55,38 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } } +func TestGeneratedSoftwareDeliverySkillMakesProgramDriftCoreachableWithoutImplicitAcceptance(t *testing.T) { + // control-law: generated-driver-program-drift-has-an-exact-human-authorized-resumption + truth := true + compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "incident-response"}, + Operators: []controlprogram.Operator{{ + ID: "respond", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/respond", Version: "1"}, + }}, + Targets: []controlprogram.Target{{ID: "mitigated", Predicate: controlprogram.Predicate{True: &truth}}}, + Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated"}}, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + codex := string(files[".agents/skills/incident-response-respond/SKILL.md"]) + claude := string(files[".claude/skills/incident-response-respond/SKILL.md"]) + for _, contract := range []string{ + "UNRESOLVED", "solely because the selected compiled\nprogram differs", "exact prior program fingerprint", + "candidate program fingerprint", "program-delta fingerprint", "Ask for\nexplicit human acceptance", + "Never infer acceptance", "installation.reconcile-update", "--accept-program-change", + "--human ", "program-change acceptance is true", "bound to the accepted bundle", "stop without\nperforming product effects", + } { + if !strings.Contains(codex, contract) { + t.Fatalf("generated program-reconciliation protocol lacks %q", contract) + } + } + if strings.ReplaceAll(codex, "--host codex", "--host HOST") != strings.ReplaceAll(claude, "--host claude", "--host HOST") { + t.Fatal("Codex and Claude program-reconciliation projections differ") + } +} + func TestGeneratedSkillDescriptionIsQuotedYAML(t *testing.T) { description := "Implement: parser\n# heading\n---\nnext" compiled := controlprogram.Compiled{Document: controlprogram.Document{ @@ -142,6 +178,47 @@ func TestGeneratedSkillsProjectForegroundWorkProtocolWithHostParity(t *testing.T } } +func TestGeneratedSkillsProjectGateEvidenceWorkSuspensionWithHostParity(t *testing.T) { + // control-law: missing deterministic gate evidence suspends bounded work + // without becoming human input, fabricated evidence, or a terminal stop. + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "product-delivery"}, + Transitions: []controlprogram.Transition{{ + ID: "gate.build.record", + Parameters: []controlprogram.TransitionParameterBinding{{ + Parameter: "evidence_path", + Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceTrustedResolver, + Binding: &controlprogram.ParameterResolverBinding{ + Reference: softwareflow.ParameterResolverPrefix + "gate-evidence-path/build", + Version: "1", + }, + }, + }}, + }}, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr"}}, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + codex := string(files[".agents/skills/product-delivery-run/SKILL.md"]) + claude := string(files[".claude/skills/product-delivery-run/SKILL.md"]) + for _, contract := range []string{ + "TRANSITION_INPUT_BLOCKED", "bounded product-work\nsuspension", "exact managed worktree", "parked source worktree", + "Commit the\nintended product change", ".boatstack/evidence//.input.json", + "exact committed HEAD", "actual check or reviewer", "Do not claim a\npassed outcome from model confidence", + "Never pass these values with `--param`", "edit controller state", "preserve the failure", + } { + if !strings.Contains(codex, contract) { + t.Fatalf("generated gate-evidence protocol lacks %q", contract) + } + } + if strings.ReplaceAll(codex, "--host codex", "--host HOST") != strings.ReplaceAll(claude, "--host claude", "--host HOST") { + t.Fatal("Codex and Claude gate-evidence projections differ") + } +} + func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testing.T) { compiled := controlprogram.Compiled{Document: controlprogram.Document{ Program: controlprogram.Program{ID: "product-delivery"}, diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index c47d897..e050a86 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -451,6 +451,7 @@ func TestPackageImportsPreserveControlProgramDependencyDirection(t *testing.T) { func classifiedProductionFile(relative string) bool { return relative == "delivery_controller.go" || relative == "program_effects.go" || relative == "program_observer.go" || strings.HasPrefix(relative, "cmd/boatstack-helper/") || strings.HasPrefix(relative, "controlprogram/") || + strings.HasPrefix(relative, "invocation/") || strings.HasPrefix(relative, "delivery/") || strings.HasPrefix(relative, "core/") || strings.HasPrefix(relative, "flow/") || strings.HasPrefix(relative, "distribution/") || strings.HasPrefix(relative, "extension/") || strings.HasPrefix(relative, "internal/softwaredelivery/") || diff --git a/boatstack/flow/standard/supervisor_parity_test.go b/boatstack/flow/standard/supervisor_parity_test.go index eda0f44..09bd640 100644 --- a/boatstack/flow/standard/supervisor_parity_test.go +++ b/boatstack/flow/standard/supervisor_parity_test.go @@ -77,6 +77,8 @@ func openPRSnapshot(t *testing.T, recordedGates ...string) (model.Snapshot, mode snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) objective := model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} evidence := snapshot.Verification.Evidence[0] + evidence.Revision = "current-revision" + snapshot.Delivery.Evidence = append(snapshot.Delivery.Evidence, evidence) snapshot.Objective = model.Known(objective, evidence) snapshot.Plan = model.Known(model.PlanLocked, evidence) snapshot.Publication = model.Known(model.PublicationCandidate, evidence) @@ -89,6 +91,20 @@ func openPRSnapshot(t *testing.T, recordedGates ...string) (model.Snapshot, mode return recanonicalize(t, snapshot), objective } +func TestUntargetedResolutionRetainsPerGateProgressWhileAggregateVerificationIsStale(t *testing.T) { + // control-law: aggregate-staleness-does-not-discard-current-gate-identity + authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} + snapshot, objective := openPRSnapshot(t, "build") + snapshot.Verification.Value = model.VerificationStale + snapshot.Publication = model.Known(model.PublicationNone, snapshot.Publication.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + + decision := New(testprogram.StandardRegistry(), testObjectiveContracts()).Resolve(snapshot, objective, authority, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "gate.test.record" { + t.Fatalf("one-current-gate stale aggregate decision = %#v, want gate.test.record", decision) + } +} + func TestTerminalObjectiveOutranksLocalTransitions(t *testing.T) { // control-law: configured-terminal-outranks-local-lifecycle s := New(testprogram.StandardRegistry(), testObjectiveContracts()) diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json index 8b4fe5b..3b1379a 100644 --- a/boatstack/flow/standard/transitions.json +++ b/boatstack/flow/standard/transitions.json @@ -3762,7 +3762,8 @@ "known" ], "values": [ - "current" + "current", + "stale" ] } ], @@ -3998,7 +3999,8 @@ "known" ], "values": [ - "current" + "current", + "stale" ] } ], @@ -4242,7 +4244,8 @@ "known" ], "values": [ - "current" + "current", + "stale" ] } ], @@ -5518,6 +5521,15 @@ }, "source_predicate": "predicate:source-phase:publication.preview", "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, { "facet": "plan", "statuses": [ @@ -6224,11 +6236,6 @@ ], "idempotent": true, "parameters": [ - { - "name": "publication_id", - "required": true, - "secret": false - }, { "name": "transaction_id", "required": true, @@ -6355,7 +6362,7 @@ "objective_scope": "optional-preserve" }, "priority": 1, - "authority_fingerprint_parameter": "publication_id", + "authority_fingerprint_parameter": "transaction_id", "owned_facets": [ "control", "product" diff --git a/boatstack/internal/runtime/control_bundle.go b/boatstack/internal/runtime/control_bundle.go index 3870494..46e584a 100644 --- a/boatstack/internal/runtime/control_bundle.go +++ b/boatstack/internal/runtime/control_bundle.go @@ -152,6 +152,24 @@ func (c ControlBundleContract) Validate() error { if err := c.validateFields(); err != nil { return err } + return c.validateFingerprint() +} + +// ValidateCommittedHistory accepts the current contract encoding or the exact +// earlier schema-1 snapshot encoding. The earlier encoding hashed the canonical +// file array directly, before executable directory member sets were added. +// It is valid only through a committed-history admission validator. +func (c ControlBundleContract) ValidateCommittedHistory() error { + if err := c.Validate(); err == nil { + return nil + } + if err := c.validateHistoricalFields(); err != nil { + return err + } + return c.validateFingerprint() +} + +func (c ControlBundleContract) validateFingerprint() error { identity := c want := identity.Fingerprint identity.Fingerprint = "" @@ -162,6 +180,36 @@ func (c ControlBundleContract) Validate() error { return nil } +func (c ControlBundleContract) validateHistoricalFields() error { + if c.SchemaVersion != ControlBundleSchemaVersion { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: unsupported schema") + } + if err := c.Source.validateHistorical(); err != nil { + return err + } + if c.Target != nil { + if err := c.Target.validateHistorical(); err != nil { + return err + } + } else if c.TargetRevision != "" { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target revision has no target bundle") + } + if err := validateBoundRuntimePin(c.Source, c.SourceRuntimePin); err != nil { + return err + } + if c.Target != nil { + if err := validateBoundRuntimePin(*c.Target, c.TargetRuntimePin); err != nil { + return err + } + } else if c.TargetRuntimePin != nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target runtime pin has no target bundle") + } + if c.TargetRevision != "" && !validObjectIdentity(c.TargetRevision) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target revision is not an exact object identity") + } + return nil +} + func (c ControlBundleContract) validateFields() error { if c.SchemaVersion != ControlBundleSchemaVersion { return fmt.Errorf("CONTROL_BUNDLE_INVALID: unsupported schema") @@ -242,6 +290,24 @@ func (s ControlBundleSnapshot) validate() error { return nil } +func (s ControlBundleSnapshot) validateHistorical() error { + if !validControlDigest(s.Fingerprint) || len(s.Files) == 0 || len(s.MemberSets) != 0 { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: historical snapshot is incomplete") + } + prior := "" + for _, file := range s.Files { + if !safeProjectionRelative(file.Path) || file.Path <= prior || (file.Absent && file.SHA256 != "") || (!file.Absent && !validControlDigest(file.SHA256)) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: historical file bindings are not canonical") + } + prior = file.Path + } + fingerprint, err := controlBundleDigest(s.Files) + if err != nil || fingerprint != s.Fingerprint { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: historical snapshot fingerprint mismatch") + } + return nil +} + func canonicalControlBundleMemberSets(values []ControlBundleMemberSet, files []ControlBundleFile) ([]ControlBundleMemberSet, error) { sets := make([]ControlBundleMemberSet, len(values)) copy(sets, values) @@ -474,6 +540,28 @@ func ResolveCommitRevision(ctx context.Context, repository, reference string) (s return revision, nil } +// ResolveWorkspaceBaseRevision binds a configured branch name to an exact +// commit without requiring a local branch. A local ref remains authoritative; +// repositories cloned without local default branches may fall back to the +// corresponding origin remote-tracking ref. +func ResolveWorkspaceBaseRevision(ctx context.Context, repository, reference string) (string, error) { + revision, localErr := ResolveCommitRevision(ctx, repository, reference) + if localErr == nil { + return revision, nil + } + check := exec.CommandContext(ctx, "git", "check-ref-format", "--branch", reference) + check.Dir = repository + if err := check.Run(); err != nil { + return "", localErr + } + remoteReference := "refs/remotes/origin/" + reference + revision, remoteErr := ResolveCommitRevision(ctx, repository, remoteReference) + if remoteErr != nil { + return "", fmt.Errorf("resolve workspace base %q locally or at origin: local: %v; origin: %w", reference, localErr, remoteErr) + } + return revision, nil +} + // VerifyControlBundleHead binds a newly created execution context to both the // admitted control bytes and the exact commit resolved before the effect. func VerifyControlBundleHead(ctx context.Context, repository, revision string, snapshot ControlBundleSnapshot) error { diff --git a/boatstack/internal/runtime/control_bundle_test.go b/boatstack/internal/runtime/control_bundle_test.go index e70f1fb..f5e5a4b 100644 --- a/boatstack/internal/runtime/control_bundle_test.go +++ b/boatstack/internal/runtime/control_bundle_test.go @@ -9,6 +9,40 @@ import ( "testing" ) +func TestCommittedHistoryAcceptsOnlyExactEarlierSnapshotFingerprint(t *testing.T) { + files := []ControlBundleFile{{Path: ".boatstack/project.json", SHA256: strings.Repeat("a", 64)}} + historicalFingerprint, err := controlBundleDigest(files) + if err != nil { + t.Fatal(err) + } + snapshot := ControlBundleSnapshot{Fingerprint: historicalFingerprint, Files: files} + contract := ControlBundleContract{SchemaVersion: ControlBundleSchemaVersion, Source: snapshot} + identity := contract + identity.Fingerprint = "" + contract.Fingerprint, err = controlBundleDigest(identity) + if err != nil { + t.Fatal(err) + } + if err := contract.Validate(); err == nil || !strings.Contains(err.Error(), "snapshot fingerprint mismatch") { + t.Fatalf("current validation accepted historical snapshot: %v", err) + } + if err := contract.ValidateCommittedHistory(); err != nil { + t.Fatalf("committed-history validation rejected exact historical snapshot: %v", err) + } + + tampered := contract + tampered.Source.Files = append([]ControlBundleFile(nil), contract.Source.Files...) + tampered.Source.Files[0].SHA256 = strings.Repeat("b", 64) + if err := tampered.ValidateCommittedHistory(); err == nil { + t.Fatal("committed-history validation accepted tampered file identity") + } + withMembers := contract + withMembers.Source.MemberSets = []ControlBundleMemberSet{{Root: ".boatstack", Suffix: ".json", Paths: []string{".boatstack/project.json"}}} + if err := withMembers.ValidateCommittedHistory(); err == nil { + t.Fatal("historical fingerprint encoding accepted a member-set snapshot") + } +} + func TestControlBundleCanonicalizesFilesAndBindsAbsence(t *testing.T) { left, err := NewControlBundleSnapshotWithAbsent(map[string][]byte{ ".boatstack/project.json": []byte("project"), @@ -73,6 +107,57 @@ func TestControlBundleVerifiesRootRevisionAndExactHead(t *testing.T) { } } +func TestResolveWorkspaceBaseRevisionFallsBackToOriginTrackingBranch(t *testing.T) { + repository := t.TempDir() + runBundleGit(t, repository, "init", "-q") + runBundleGit(t, repository, "config", "user.email", "bundle@example.invalid") + runBundleGit(t, repository, "config", "user.name", "Bundle Test") + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + runBundleGit(t, repository, "add", "README.md") + runBundleGit(t, repository, "commit", "-q", "-m", "base") + runBundleGit(t, repository, "branch", "-M", "main") + want := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + runBundleGit(t, repository, "update-ref", "refs/remotes/origin/main", want) + runBundleGit(t, repository, "switch", "-q", "-c", "feature") + runBundleGit(t, repository, "branch", "-D", "main") + + got, err := ResolveWorkspaceBaseRevision(context.Background(), repository, "main") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("resolved revision = %s, want %s", got, want) + } +} + +func TestResolveWorkspaceBaseRevisionPrefersLocalBranch(t *testing.T) { + repository := t.TempDir() + runBundleGit(t, repository, "init", "-q") + runBundleGit(t, repository, "config", "user.email", "bundle@example.invalid") + runBundleGit(t, repository, "config", "user.name", "Bundle Test") + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("local\n"), 0o644); err != nil { + t.Fatal(err) + } + runBundleGit(t, repository, "add", "README.md") + runBundleGit(t, repository, "commit", "-q", "-m", "local") + runBundleGit(t, repository, "branch", "-M", "main") + localRevision := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + runBundleGit(t, repository, "commit", "--allow-empty", "-q", "-m", "remote") + remoteRevision := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + runBundleGit(t, repository, "update-ref", "refs/remotes/origin/main", remoteRevision) + runBundleGit(t, repository, "reset", "--hard", "-q", localRevision) + + got, err := ResolveWorkspaceBaseRevision(context.Background(), repository, "main") + if err != nil { + t.Fatal(err) + } + if got != localRevision { + t.Fatalf("resolved revision = %s, want local %s", got, localRevision) + } +} + func TestControlBundleBindsCompleteExecutableDirectoryMembership(t *testing.T) { repository := t.TempDir() runBundleGit(t, repository, "init", "-q") diff --git a/boatstack/internal/softwaredelivery/delegation/record.go b/boatstack/internal/softwaredelivery/delegation/record.go index db49135..30923fb 100644 --- a/boatstack/internal/softwaredelivery/delegation/record.go +++ b/boatstack/internal/softwaredelivery/delegation/record.go @@ -21,6 +21,7 @@ const ( ) var identity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +var fingerprint = regexp.MustCompile(`^[0-9a-f]{64}$`) type Request struct { RunID string `json:"run_id"` @@ -85,6 +86,15 @@ func Path(flowRoot, runID string) (string, error) { return filepath.Join(flowRoot, "delegations", runID+".json"), nil } +// SupersededPath preserves an earlier exact authorization record before a +// verified installation boundary accepts a fresh delegation request. +func SupersededPath(flowRoot, runID, requestFingerprint string) (string, error) { + if !identity.MatchString(runID) || !fingerprint.MatchString(requestFingerprint) { + return "", fmt.Errorf("DELEGATION_RUN_INVALID: invalid superseded authorization identity") + } + return filepath.Join(flowRoot, "delegations", runID+".superseded-"+requestFingerprint[:12]+".json"), nil +} + func LockPath(lockRoot, runID string) (string, error) { if !identity.MatchString(runID) { return "", fmt.Errorf("DELEGATION_RUN_INVALID: invalid run identity") diff --git a/boatstack/internal/softwaredelivery/delegation/record_test.go b/boatstack/internal/softwaredelivery/delegation/record_test.go index 43ac4fc..e8de6eb 100644 --- a/boatstack/internal/softwaredelivery/delegation/record_test.go +++ b/boatstack/internal/softwaredelivery/delegation/record_test.go @@ -1,6 +1,7 @@ package delegation_test import ( + "path/filepath" "strings" "testing" @@ -16,6 +17,20 @@ func request() delegation.Request { } } +func TestSupersededPathBindsRunAndExactRequest(t *testing.T) { + fingerprint := strings.Repeat("a", 64) + path, err := delegation.SupersededPath("/controller", "run-example", fingerprint) + if err != nil { + t.Fatal(err) + } + if path != filepath.Join("/controller", "delegations", "run-example.superseded-aaaaaaaaaaaa.json") { + t.Fatalf("superseded path = %q", path) + } + if _, err := delegation.SupersededPath("/controller", "../run", fingerprint); err == nil { + t.Fatal("unsafe run identity produced a supersession path") + } +} + func TestRequestFingerprintCanonicalizesSetsAndBindsSemantics(t *testing.T) { left := request() right := request() diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index f40abed..bd4b660 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -176,6 +176,36 @@ func (s State) Canonical() State { return result } +// HasCurrentGates reports whether every named gate is bound to the exact +// source revision currently controlled by the durable state. +func (s State) HasCurrentGates(names ...string) bool { + if s.SourceRevision == "" { + return false + } + found := make(map[string]bool, len(names)) + for _, gate := range s.Gates { + if gate.Revision == s.SourceRevision { + found[gate.Gate] = true + } + } + for _, name := range names { + if !found[name] { + return false + } + } + return true +} + +// RequiredGateEvidenceCurrent defines the standard software-delivery +// verification set. Optional visual evidence becomes mandatory only when the +// repository policy declares it required. +func (s State) RequiredGateEvidenceCurrent() bool { + if !s.HasCurrentGates("build", "test", "review") { + return false + } + return s.VisualEvidencePolicy != "required" || s.HasCurrentGates("visual") +} + func (s State) ConfigurationPolicy() model.ConfigurationPolicy { return model.ConfigurationPolicy{ PlanApproval: s.PlanApprovalPolicy, IndependentReviewForHighRisk: s.IndependentReview, diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index 3f01277..3aa9163 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "time" "unicode" @@ -151,11 +152,18 @@ func (b NativeBoundary) PrepareObservation(ctx context.Context, admission protoc } case "publication.observe", "publication.reconcile": publicationID, _ := admission.Parameters.Get("publication_id") - if state.PublicationID != "" && state.PublicationID != publicationID { + if transition.ID == "publication.reconcile" && publicationID == "" { + publicationID = state.PublicationID + } + if state.PublicationID != "" && publicationID != "" && state.PublicationID != publicationID { state.Publication = model.PublicationConflicting return nil } - output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "view", "--json", "state,url,number,mergedAt,baseRefName,headRefName,headRefOid,isCrossRepository", "--", publicationID) + arguments := []string{"pr", "view", "--json", "state,url,number,mergedAt,baseRefName,headRefName,headRefOid,isCrossRepository"} + if publicationID != "" { + arguments = append(arguments, "--", publicationID) + } + output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", arguments...) if err != nil { state.Publication, state.PublicationID, state.PublicationURL = model.PublicationUnavailable, publicationID, "" return nil @@ -328,9 +336,15 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "origin", refspec); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } - if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "create", "--base", preview.BaseRef, "--head", preview.HeadRef, "--fill-first", "--body-file", preview.BodyPath); err != nil { + output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "create", "--base", preview.BaseRef, "--head", preview.HeadRef, "--fill-first", "--body-file", preview.BodyPath) + if err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } + publicationID, parseErr := publicationIDFromCreateOutput(output) + if parseErr != nil { + return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: parseErr.Error()}, nil + } + return ports.EffectResult{Settlement: ports.EffectSettled, Outputs: protocol.Parameters{{Name: "publication_id", Value: publicationID}}}, nil case "publication.correct": publicationID, _ := admission.Parameters.Get("publication_id") bodyPath, _ := admission.Parameters.Get("body_path") @@ -357,6 +371,24 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio return settled, nil } +func publicationIDFromCreateOutput(output []byte) (string, error) { + lines := strings.Fields(string(output)) + for index := len(lines) - 1; index >= 0; index-- { + parsed, err := url.Parse(lines[index]) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + continue + } + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(segments) < 2 || segments[len(segments)-2] != "pull" { + continue + } + if number, err := strconv.ParseUint(segments[len(segments)-1], 10, 64); err == nil && number > 0 { + return strconv.FormatUint(number, 10), nil + } + } + return "", fmt.Errorf("publication provider did not return an exact pull-request identity") +} + func publicationProductStatus(status string) string { records := strings.Split(status, "\x00") kept := make([]string, 0, len(records)) diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index 19116ee..6fd077d 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -225,6 +225,34 @@ func TestPublicationObservationTerminatesOptionsBeforeIdentifier(t *testing.T) { } } +func TestPublicationReconciliationDiscoversUnknownPublicationByCurrentBranch(t *testing.T) { + // control-law: uncertain publication recovery remains materializable without + // an output that the interrupted publication effect never committed. + runner := &boundaryRunner{output: []byte(`{"state":"OPEN","url":"https://example.invalid/pull/9","number":9,"mergedAt":null,"baseRefName":"main","headRefName":"feature","headRefOid":"revision","isCrossRepository":false}`)} + boundary, err := NewNativeBoundaryWithRunner(runner) + if err != nil { + t.Fatal(err) + } + transition, _ := testprogram.StandardRegistry().Lookup("publication.reconcile") + admission := protocol.Admission{ + Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision", + Parameters: protocol.Parameters{{Name: "transaction_id", Value: "transaction-9"}}, + } + admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) + admission.EffectiveCapabilities = admission.RequiredCapabilities + state := durable.State{} + if err := boundary.PrepareObservation(context.Background(), admission, transition, writeBoundaryConfig(t, "go test ./..."), &state); err != nil { + t.Fatal(err) + } + want := []string{"pr", "view", "--json", "state,url,number,mergedAt,baseRefName,headRefName,headRefOid,isCrossRepository"} + if runner.name != "gh" || strings.Join(runner.arguments, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("reconciliation command = %s %q, want gh %q", runner.name, runner.arguments, want) + } + if state.Publication != model.PublicationOpen || state.PublicationID != "9" { + t.Fatalf("reconciled publication = %s %q", state.Publication, state.PublicationID) + } +} + func TestPublicationObservationRejectsUnrelatedProviderIdentity(t *testing.T) { runner := &boundaryRunner{output: []byte(`{"state":"OPEN","url":"https://example.invalid/pull/8","number":8,"baseRefName":"main","headRefName":"other","headRefOid":"revision","isCrossRepository":false}`)} boundary, _ := NewNativeBoundaryWithRunner(runner) @@ -277,7 +305,7 @@ func TestPublicationPreviewRejectsFieldTamperingUnderAnOldFingerprint(t *testing } func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { - runner := &boundaryRunner{} + runner := &boundaryRunner{outputs: [][]byte{[]byte("push complete"), []byte("https://github.com/operatorstack/boatstack/pull/222\n")}} boundary, err := NewNativeBoundaryWithRunner(runner) if err != nil { t.Fatal(err) @@ -318,9 +346,13 @@ func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { } admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) admission.EffectiveCapabilities = admission.RequiredCapabilities - if _, err := boundary.Execute(context.Background(), admission, transition, layout, durable.State{}); err != nil { + result, err := boundary.Execute(context.Background(), admission, transition, layout, durable.State{}) + if err != nil { t.Fatal(err) } + if publicationID, ok := result.Outputs.Get("publication_id"); !ok || publicationID != "222" { + t.Fatalf("publication effect outputs = %#v", result.Outputs) + } want := []string{"pr", "create", "--base", "main", "--head", "feature", "--fill-first", "--body-file", bodyPath} if runner.name != "gh" || strings.Join(runner.arguments, "\x00") != strings.Join(want, "\x00") { t.Fatalf("publication command = %s %q, want gh %q", runner.name, runner.arguments, want) diff --git a/boatstack/internal/softwaredelivery/effects/delegation_record.go b/boatstack/internal/softwaredelivery/effects/delegation_record.go index bee402f..335e87b 100644 --- a/boatstack/internal/softwaredelivery/effects/delegation_record.go +++ b/boatstack/internal/softwaredelivery/effects/delegation_record.go @@ -1,13 +1,62 @@ package effects import ( + "bytes" "encoding/json" + "fmt" "os" "path/filepath" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" ) +// ArchiveDelegationRecord writes one immutable prior authorization record. +// Repeating the exact archive is idempotent; conflicting bytes fail closed. +func ArchiveDelegationRecord(path string, record delegation.Record) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + temporary, err := os.CreateTemp(filepath.Dir(path), ".delegation-archive-*.tmp") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(raw); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Link(temporaryPath, path); os.IsExist(err) { + existing, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if !bytes.Equal(existing, raw) { + return fmt.Errorf("delegation supersession archive conflicts with existing evidence") + } + return nil + } else if err != nil { + return err + } + return nil +} + // StoreDelegationRecord is the single mutation boundary for runtime-owned // delegation authority. The caller must hold the corresponding run lock. func StoreDelegationRecord(path string, record delegation.Record) error { diff --git a/boatstack/internal/softwaredelivery/effects/delegation_record_test.go b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go new file mode 100644 index 0000000..e9abb6f --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go @@ -0,0 +1,47 @@ +package effects + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" +) + +func TestDelegationSupersessionArchiveIsImmutableAndIdempotent(t *testing.T) { + request := delegation.Request{ + RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), + EntryID: "run", TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"input"}, + RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", + BindingFingerprint: strings.Repeat("c", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run the program", + } + fingerprint, err := request.Fingerprint() + if err != nil { + t.Fatal(err) + } + record := delegation.Record{ + Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: request, RequestFingerprint: fingerprint, + ReceiptID: "authorization-one", Actor: "operator", AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 1, Status: "revoked", + } + path := filepath.Join(t.TempDir(), "prior.json") + if err := ArchiveDelegationRecord(path, record); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := ArchiveDelegationRecord(path, record); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(path) + if err != nil || string(first) != string(second) { + t.Fatalf("idempotent archive changed: %v", err) + } + record.Actor = "other" + if err := ArchiveDelegationRecord(path, record); err == nil { + t.Fatal("conflicting archive overwrote prior authority evidence") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go new file mode 100644 index 0000000..a4e8449 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go @@ -0,0 +1,233 @@ +package effects + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" +) + +func legacyContentID(t *testing.T, prefix string, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(raw) + return prefix + hex.EncodeToString(digest[:]) +} + +func legacyCommittedRecord(t *testing.T, transitionID catalog.TransitionID, class catalog.EventClass, sequence, priorRevision uint64, objective model.Objective, invocation model.InvocationContext) journalRecord { + t.Helper() + now := time.Unix(1_700_000_000+int64(sequence), 0).UTC() + authority := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ + ID: "policy", Class: catalog.AuthorityRepository, Subject: "configuration:/repo/.boatstack/project.json", Fingerprint: "configuration-fingerprint", + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} + authorityFingerprint, err := authority.Fingerprint() + if err != nil { + t.Fatal(err) + } + granted := authority.GrantedCapabilities(now) + transition := catalog.Transition{ + ID: transitionID, Version: 1, Owner: "product-delivery", Effect: catalog.EffectID(transitionID), Class: class, + TargetPredicate: "legacy target", Verifier: "legacy.verifier", + Policy: catalog.PolicyContract{ObjectiveScope: catalog.ObjectiveScopeBoundExact}, + } + admission := protocol.Admission{ + SchemaVersion: protocol.PreviousAdmissionSchemaVersion, + PrescriptionID: "prx-legacy-" + string(transitionID), TransitionID: transitionID, TransitionVersion: transition.Version, + ExpectedStateRevision: priorRevision, ExpectedProgramFingerprint: strings.Repeat("a", 64), + ExpectedSnapshotFingerprint: strings.Repeat("b", 64), ExpectedObjectiveBindingFingerprint: strings.Repeat("c", 64), + SourceRevision: "legacy-head", WorktreeFingerprint: "legacy-worktree", SourcePhase: model.PhaseActive, Invocation: invocation, + Objective: objective, ObjectiveScope: catalog.ObjectiveScopeBoundExact, Authority: authority, AuthorityFingerprint: authorityFingerprint, + RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, GrantedCapabilities: granted, + EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, IdempotencyKey: "idem-legacy-" + string(transitionID), + IssuedAt: now, ExpiresAt: now.Add(time.Minute), + } + // The immediately preceding admission encoding carried control-bundle + // schema 1 before member-set completeness changed the snapshot digest from + // the canonical file array to a structured snapshot payload. + bundleFiles := []boatstackruntime.ControlBundleFile{{Path: ".boatstack/project.json", SHA256: strings.Repeat("d", 64)}} + bundle := boatstackruntime.ControlBundleContract{ + SchemaVersion: boatstackruntime.ControlBundleSchemaVersion, + Source: boatstackruntime.ControlBundleSnapshot{ + Fingerprint: legacyContentID(t, "", bundleFiles), + Files: bundleFiles, + }, + } + bundle.Fingerprint = legacyContentID(t, "", bundle) + admission.ControlBundle = &bundle + admission.ID = legacyContentID(t, "adm-", admission) + if err := admission.ValidateCommittedHistoryIdentity(); err != nil { + t.Fatalf("legacy admission fixture: %v", err) + } + + mutation := ports.ResourceMutation{ + Resource: "software-delivery.state", Owner: transition.Owner, Path: "/repo/.boatstack/state.json", + Prior: []byte("prior"), Target: []byte("target"), PriorExists: true, Mode: 0o600, + StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, + } + effects := []protocol.EffectFact{{ + Kind: protocol.EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: mutation.Resource, + Target: mutation.Path, Operation: "update", + PriorFingerprint: mutationStateFingerprint(true, mutation.Prior, "", mutation.Mode), ResultingFingerprint: mutationStateFingerprint(true, mutation.Target, "", mutation.Mode), + }} + if class == catalog.EventOwnedExternal { + effects = append(effects, protocol.EffectFact{ + Kind: protocol.EffectBoundarySettled, EffectID: transition.Effect, Owner: transition.Owner, + Target: string(transition.ID), Operation: "settled", PriorFingerprint: strings.Repeat("d", 64), ResultingFingerprint: strings.Repeat("e", 64), + }) + } + target := model.Snapshot{ + Observation: model.Observation{StateRevision: priorRevision + 1, Objective: model.Known(objective, model.Evidence{Source: "legacy", Fingerprint: "objective", ObservedAt: now})}, + Fingerprint: strings.Repeat("f", 64), + } + receipt, err := protocol.NewReceipt( + "run-legacy", sequence, protocol.ProgramIdentity{ID: "product-delivery", Version: "1.0.0", Fingerprint: admission.ExpectedProgramFingerprint}, + admission, transition, target, mutation.StateFacets, effects, nil, nil, now, now.Add(time.Second), + ) + if err != nil { + t.Fatal(err) + } + receipt.SchemaVersion = protocol.PreviousReceiptSchemaVersion + receipt.ID = "" + receipt.ID = legacyContentID(t, "trc-", receipt) + if err := receipt.ValidateCommittedHistory(); err != nil { + t.Fatalf("legacy receipt fixture: %v", err) + } + return journalRecord{ + SchemaVersion: protocol.JournalSchemaVersion, Admission: admission, TransitionID: transitionID, TransitionClass: class, + AllowedStateFacets: mutation.StateFacets, Status: "committed", Mutations: []ports.ResourceMutation{mutation}, ReceiptID: receipt.ID, Receipt: &receipt, + CreatedAt: now, UpdatedAt: now.Add(time.Second), + } +} + +func writeLegacyJournal(t *testing.T, root string, record journalRecord, suffix string) string { + t.Helper() + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, record.Admission.ID+suffix) + raw, err := encodeJSON(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestPreviousCommittedHistoryRemainsReadableAndResumable(t *testing.T) { + // This fixture uses the exact admission-8/receipt-12 wire shape written by + // the immediately preceding release, including a publication commit with no + // effect_outputs field. + root := t.TempDir() + invocation := model.InvocationContext{ + RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/feature/legacy", + ControllerID: "controller", InvokingPath: filepath.Join(root, "repo"), RuntimeVersion: "1.0.0", RuntimePath: filepath.Join(root, "runtime"), RuntimeFingerprint: "runtime", + Topology: model.TopologyEmbedded, Host: "cursor", Correlation: "legacy-run", + } + objective := model.Objective{ID: "objective-legacy", TargetID: model.ObjectiveOpenPR, TrustedClass: model.ObjectiveOpenPR, DeliveryID: "legacy"} + binding := legacyCommittedRecord(t, "objective.bind", catalog.EventOwnedLocal, 1, 41, objective, invocation) + published := legacyCommittedRecord(t, "publication.execute", catalog.EventOwnedExternal, 2, 56, objective, invocation) + writeLegacyJournal(t, root, binding, ".committed") + publishedPath := writeLegacyJournal(t, root, published, ".committed") + legacyRaw, err := os.ReadFile(publishedPath) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(legacyRaw, []byte(`"effect_outputs"`)) || bytes.Contains(legacyRaw, []byte(`"invocation_fingerprint"`)) { + t.Fatalf("legacy wire fixture contains fields absent from the base encoding: %s", legacyRaw) + } + + if _, err := readJournal(publishedPath); err != nil { + t.Fatalf("read base-version publication history: %v", err) + } + layout := ports.ControllerLayout{JournalRoot: root} + active, found, err := FindLatestCommittedFlowForObjective(layout, invocation, objective, 57) + if err != nil || !found || active.FlowID != "run-legacy" { + t.Fatalf("active legacy run = %#v found=%t err=%v", active, found, err) + } + store := ReceiptStore{currentInvocation: map[string]receiptBinding{"run-legacy": {admission: published.Admission, layout: layout}}} + sequence, err := store.NextSequence(context.Background(), "run-legacy") + if err != nil || sequence != 3 { + t.Fatalf("next legacy sequence = %d err=%v", sequence, err) + } + locator, receiptID, found, err := FindLatestCommittedTransitionOutput(layout, "run-legacy", invocation, "publication.execute", "publication_id", 57) + if err != nil || !found || locator != "feature/legacy" || receiptID != published.ReceiptID { + t.Fatalf("legacy publication locator=%q receipt=%q found=%t err=%v", locator, receiptID, found, err) + } + runner := &boundaryRunner{output: []byte(`{"state":"OPEN","url":"https://example.invalid/pull/222","number":222,"mergedAt":null,"baseRefName":"main","headRefName":"feature/legacy","headRefOid":"legacy-head","isCrossRepository":false}`)} + boundary, err := NewNativeBoundaryWithRunner(runner) + if err != nil { + t.Fatal(err) + } + observe, _ := testprogram.StandardRegistry().Lookup("publication.observe") + observationAdmission := protocol.Admission{ + Invocation: invocation, SourceRevision: published.Admission.SourceRevision, + Parameters: protocol.Parameters{{Name: "publication_id", Value: locator}}, + RequiredCapabilities: catalog.RequiredCapabilities(observe), + } + observationAdmission.EffectiveCapabilities = observationAdmission.RequiredCapabilities + state := durable.State{Publication: model.PublicationPublishedNotLanded} + if err := boundary.PrepareObservation(context.Background(), observationAdmission, observe, writeBoundaryConfig(t, "go test ./..."), &state); err != nil { + t.Fatal(err) + } + if state.Publication != model.PublicationOpen || state.PublicationID != "222" || runner.arguments[len(runner.arguments)-1] != locator { + t.Fatalf("legacy publication observation state=%s id=%q selector=%q", state.Publication, state.PublicationID, runner.arguments[len(runner.arguments)-1]) + } +} + +func TestPreviousHistoryCompatibilityIsCommittedOnlyAndExact(t *testing.T) { + root := t.TempDir() + invocation := model.InvocationContext{ + RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/feature/legacy", + ControllerID: "controller", InvokingPath: filepath.Join(root, "repo"), RuntimeVersion: "1.0.0", RuntimePath: filepath.Join(root, "runtime"), RuntimeFingerprint: "runtime", + Topology: model.TopologyEmbedded, Host: "cursor", Correlation: "legacy-run", + } + objective := model.Objective{ID: "objective-legacy", TargetID: model.ObjectiveOpenPR, TrustedClass: model.ObjectiveOpenPR, DeliveryID: "legacy"} + record := legacyCommittedRecord(t, "publication.execute", catalog.EventOwnedExternal, 2, 56, objective, invocation) + + t.Run("pending", func(t *testing.T) { + path := writeLegacyJournal(t, t.TempDir(), record, ".pending") + if _, err := readJournal(path); err == nil { + t.Fatal("legacy pending journal was accepted") + } + }) + t.Run("mixed schema pair", func(t *testing.T) { + mixed := record + receipt := *mixed.Receipt + receipt.SchemaVersion = protocol.ReceiptSchemaVersion + receipt.ID = "" + receipt.ID = legacyContentID(t, "trc-", receipt) + mixed.Receipt, mixed.ReceiptID = &receipt, receipt.ID + path := writeLegacyJournal(t, t.TempDir(), mixed, ".committed") + if _, err := readJournal(path); err == nil { + t.Fatal("mixed legacy/current schema pair was accepted") + } + }) + t.Run("tampered content", func(t *testing.T) { + tampered := record + tampered.Admission.SourceRevision = "substituted" + path := writeLegacyJournal(t, t.TempDir(), tampered, ".committed") + if _, err := readJournal(path); err == nil { + t.Fatal("tampered legacy content identity was accepted") + } + }) +} diff --git a/boatstack/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go index ef11793..ef64a63 100644 --- a/boatstack/internal/softwaredelivery/effects/journal.go +++ b/boatstack/internal/softwaredelivery/effects/journal.go @@ -118,8 +118,16 @@ func readJournal(path string) (journalRecord, error) { if record.SchemaVersion != protocol.JournalSchemaVersion || record.Admission.ID == "" || record.TransitionID == "" || !record.TransitionClass.Valid() || !record.TransitionClass.Controllable() || record.Status == "" { return journalRecord{}, fmt.Errorf("invalid transaction journal %s", path) } - if err := record.Admission.ValidateIdentity(); err != nil || record.Admission.TransitionID != record.TransitionID { - return journalRecord{}, fmt.Errorf("invalid transaction admission in %s: %v", path, err) + legacyCommitted := strings.HasSuffix(path, ".committed") && record.Status == "committed" && record.Receipt != nil && + record.Admission.SchemaVersion == protocol.PreviousAdmissionSchemaVersion && record.Receipt.SchemaVersion == protocol.PreviousReceiptSchemaVersion + var admissionErr error + if legacyCommitted { + admissionErr = record.Admission.ValidateCommittedHistoryIdentity() + } else { + admissionErr = record.Admission.ValidateIdentity() + } + if admissionErr != nil || record.Admission.TransitionID != record.TransitionID { + return journalRecord{}, fmt.Errorf("invalid transaction admission in %s: %v", path, admissionErr) } allowed, err := model.NormalizeStateFacets("journal allowed state facets", record.AllowedStateFacets) if err != nil || len(allowed) == 0 || !slices.Equal(allowed, record.AllowedStateFacets) { @@ -132,12 +140,19 @@ func readJournal(path string) (journalRecord, error) { } } if record.Receipt != nil { - if err := record.Receipt.Validate(); err != nil || record.Receipt.ID != record.ReceiptID || record.Receipt.AdmissionID != record.Admission.ID || record.Receipt.TransitionID != record.TransitionID { - return journalRecord{}, fmt.Errorf("invalid committed transition fact in %s: %v", path, err) + var receiptErr error + if legacyCommitted { + receiptErr = record.Receipt.ValidateCommittedHistory() + } else { + receiptErr = record.Receipt.Validate() + } + if receiptErr != nil || record.Receipt.ID != record.ReceiptID || record.Receipt.AdmissionID != record.Admission.ID || record.Receipt.TransitionID != record.TransitionID { + return journalRecord{}, fmt.Errorf("invalid committed transition fact in %s: %v", path, receiptErr) } receipt := record.Receipt admission := record.Admission if receipt.PrescriptionID != admission.PrescriptionID || receipt.TransitionVersion != admission.TransitionVersion || receipt.Program.Fingerprint != admission.ExpectedProgramFingerprint || + receipt.InvocationFingerprint != admission.InvocationFingerprint || receipt.PriorStateRevision != admission.ExpectedStateRevision || receipt.SourceFingerprint != admission.ExpectedSnapshotFingerprint || receipt.AuthorityFingerprint != admission.AuthorityFingerprint || !slices.Equal(receipt.RequiredCapabilities, admission.RequiredCapabilities) || !slices.Equal(receipt.GrantedCapabilities, admission.GrantedCapabilities) || receipt.ObjectiveID != admission.Objective.ID || receipt.TargetID != admission.Objective.TargetID || receipt.TrustedClass != admission.Objective.TrustedClass || diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index cbdafa9..3b834ab 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -136,6 +136,109 @@ func findLatestCommittedFlowForObjective(records []journalRecord, invocation mod return found, found.ID != "", nil } +// FindLatestCommittedTransitionOutput returns one effect output only from a +// committed receipt in the current Flow lineage. The sole historical adapter +// projects the exact admitted branch from a schema-12 publication commit as an +// observation locator; provider observation must still establish the PR ID. +func FindLatestCommittedTransitionOutput(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, transitionID catalog.TransitionID, field string, maximumRevision uint64) (string, string, bool, error) { + records := []journalRecord{} + if err := scanCommittedReceipts(layout, func(record journalRecord) error { + records = append(records, record) + return nil + }); err != nil { + return "", "", false, err + } + return findLatestCommittedTransitionOutput(records, flowID, invocation, transitionID, field, maximumRevision) +} + +func findLatestCommittedTransitionOutput(records []journalRecord, flowID string, invocation model.InvocationContext, transitionID catalog.TransitionID, field string, maximumRevision uint64) (string, string, bool, error) { + var found protocol.TransitionReceipt + var value string + for _, record := range records { + receipt := *record.Receipt + if receipt.FlowID != flowID || receipt.TransitionID != transitionID || receipt.ResultingStateRevision > maximumRevision { + continue + } + output, exists := committedTransitionOutput(record, transitionID, field) + if !exists { + continue + } + authorized := sameStateLineage(record.Admission.Invocation, invocation) + if !authorized && record.Admission.Invocation.ControllerID == invocation.ControllerID { + var err error + authorized, err = invocationAuthorizedByRecords(records, flowID, record.Admission.Invocation, invocation) + if err != nil { + return "", "", false, err + } + } + if !authorized { + continue + } + if found.ID == "" || receipt.Sequence > found.Sequence { + found, value = receipt, output + } + } + return value, found.ID, found.ID != "", nil +} + +func committedTransitionOutput(record journalRecord, transitionID catalog.TransitionID, field string) (string, bool) { + if output, exists := record.Receipt.EffectOutputs.Get(field); exists { + return output, true + } + if record.Admission.SchemaVersion != protocol.PreviousAdmissionSchemaVersion || record.Receipt.SchemaVersion != protocol.PreviousReceiptSchemaVersion || + transitionID != "publication.execute" || field != "publication_id" || record.Receipt.TransitionID != transitionID { + return "", false + } + const branchPrefix = "refs/heads/" + if !strings.HasPrefix(record.Admission.Invocation.Ref, branchPrefix) { + return "", false + } + branch := strings.TrimPrefix(record.Admission.Invocation.Ref, branchPrefix) + if err := protocol.ValidateGitBranch(branch); err != nil { + return "", false + } + return branch, true +} + +// InstallationReprojectionAdmits reports whether the exact current control +// bundle was committed by an installation transition in this Flow lineage. +// The bundle binds the Flow artifact and runtime program together. Installation +// may preserve a prior durable objective, so objective continuity is enforced +// between the old and new delegation requests rather than against this receipt. +// This permits a fresh delegation request; it never carries prior authority. +func InstallationReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, controlBundleFingerprint string) (bool, error) { + records := []journalRecord{} + if err := scanCommittedReceipts(layout, func(record journalRecord) error { + records = append(records, record) + return nil + }); err != nil { + return false, err + } + return installationReprojectionAdmits(records, flowID, invocation, controlBundleFingerprint) +} + +func installationReprojectionAdmits(records []journalRecord, flowID string, invocation model.InvocationContext, controlBundleFingerprint string) (bool, error) { + for _, record := range records { + receipt := *record.Receipt + installationTransition := receipt.TransitionID == "installation.update" || (receipt.TransitionID == "installation.reconcile-update" && receipt.ProgramChangeAccepted) + if !installationTransition || receipt.FlowID != flowID || receipt.ControlBundleTargetFingerprint != controlBundleFingerprint { + continue + } + authorized := sameStateLineage(record.Admission.Invocation, invocation) + if !authorized && record.Admission.Invocation.ControllerID == invocation.ControllerID { + var err error + authorized, err = invocationAuthorizedByRecords(records, flowID, record.Admission.Invocation, invocation) + if err != nil { + return false, err + } + } + if authorized { + return true, nil + } + } + return false, nil +} + // InvocationAuthorizedByFlow reconstructs worktree lineage only from valid, // committed transition receipts. Mutable delegation records cannot invent a // context transfer. @@ -161,9 +264,24 @@ func invocationAuthorizedByRecords(records []journalRecord, flowID string, initi contextKey := func(invocation model.InvocationContext) string { return invocation.WorktreeID + "\x00" + invocation.Ref } + // A fresh delegation can be issued after an accepted installation update in + // the current managed worktree. Earlier execution-context advances establish + // that approved starting context; they are not descendants of it. Anchor the + // replay after the latest committed advance into the approved context, then + // continue to require an unbroken receipt lineage for every later advance. + anchorSequence := uint64(0) + for _, receipt := range receipts { + if receipt.ExecutionContext != "advance" || receipt.ResultingInvocation == nil { + continue + } + resulting := *receipt.ResultingInvocation + if resulting.RepositoryID == initial.RepositoryID && resulting.GitCommonID == initial.GitCommonID && contextKey(resulting) == contextKey(initial) && receipt.Sequence > anchorSequence { + anchorSequence = receipt.Sequence + } + } authorized := map[string]bool{contextKey(initial): true} for _, receipt := range receipts { - if receipt.ExecutionContext != "advance" { + if receipt.ExecutionContext != "advance" || receipt.Sequence <= anchorSequence { continue } prior, resulting := receipt.PriorInvocation, receipt.ResultingInvocation @@ -248,6 +366,7 @@ type processEvent struct { RequiredCapabilities []catalog.Capability `json:"required_capabilities"` GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` CommittedEffects []protocol.EffectFact `json:"committed_effects"` + EffectOutputs protocol.Parameters `json:"effect_outputs,omitempty"` ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` Verification protocol.VerificationFact `json:"verification"` Recovery string `json:"recovery,omitempty"` @@ -300,6 +419,7 @@ func (s *ReceiptStore) Project(ctx context.Context, receipt protocol.TransitionR RequiredCapabilities: append([]catalog.Capability(nil), receipt.RequiredCapabilities...), GrantedCapabilities: append([]catalog.Capability(nil), receipt.GrantedCapabilities...), CommittedEffects: append([]protocol.EffectFact(nil), receipt.CommittedEffects...), + EffectOutputs: append(protocol.Parameters(nil), receipt.EffectOutputs...), ChangedStateFacets: append([]model.StateFacet(nil), receipt.ChangedStateFacets...), Verification: receipt.Verification, } diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index 4c5fec5..ef2cfc3 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -3,6 +3,7 @@ package effects import ( "testing" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) @@ -27,6 +28,60 @@ func TestActiveFlowIdentityComesFromObjectiveBindingReceipt(t *testing.T) { } } +func TestTransitionOutputComesFromLatestCommittedReceiptInFlowLineage(t *testing.T) { + source := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "source", Ref: "refs/heads/feature", ControllerID: "controller"} + destination := source + destination.WorktreeID, destination.Ref = "destination", "refs/heads/feature-two" + execute := protocol.TransitionReceipt{ + ID: "execute", FlowID: "run-one", Sequence: 7, TransitionID: "publication.execute", ResultingStateRevision: 9, + EffectOutputs: protocol.Parameters{{Name: "publication_id", Value: "222"}}, + } + cut := protocol.TransitionReceipt{ + ID: "cut", FlowID: "run-one", Sequence: 8, TransitionID: "workspace.cut", ResultingStateRevision: 10, + ExecutionContext: "advance", PriorInvocation: &source, ResultingInvocation: &destination, + } + foreign := execute + foreign.ID, foreign.FlowID, foreign.Sequence, foreign.EffectOutputs = "foreign", "run-two", 9, protocol.Parameters{{Name: "publication_id", Value: "999"}} + records := []journalRecord{ + {Admission: protocol.Admission{Invocation: source}, Receipt: &execute}, + {Admission: protocol.Admission{Invocation: source}, Receipt: &cut}, + {Admission: protocol.Admission{Invocation: source}, Receipt: &foreign}, + } + value, receiptID, found, err := findLatestCommittedTransitionOutput(records, "run-one", destination, catalog.TransitionID("publication.execute"), "publication_id", 10) + if err != nil || !found || value != "222" || receiptID != "execute" { + t.Fatalf("transition output = %q receipt=%q found=%t err=%v", value, receiptID, found, err) + } + if _, _, found, err := findLatestCommittedTransitionOutput(records, "run-one", destination, catalog.TransitionID("publication.execute"), "publication_id", 8); err != nil || found { + t.Fatalf("future output crossed revision boundary: found=%t err=%v", found, err) + } +} + +func TestAcceptedProgramReconciliationAuthorizesFreshDelegationRequestOnly(t *testing.T) { + invoking := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main", ControllerID: "controller"} + first := protocol.TransitionReceipt{ + ID: "reconcile-one", FlowID: "run-one", Sequence: 7, TransitionID: "installation.reconcile-update", ProgramChangeAccepted: true, + PriorProgramFingerprint: "program-a", Program: protocol.ProgramIdentity{Fingerprint: "program-b"}, + ControlBundleTargetFingerprint: "bundle-b", + ObjectiveID: "objective-prior-abandoned", TargetID: "safely-abandoned", TrustedClass: model.ObjectiveAbandoned, DeliveryID: "prior-delivery", + } + second := first + second.ID, second.Sequence, second.PriorProgramFingerprint, second.Program.Fingerprint, second.ControlBundleTargetFingerprint = "reconcile-two", 8, "program-b", "program-c", "bundle-c" + records := []journalRecord{ + {Admission: protocol.Admission{Invocation: invoking}, Receipt: &first}, + {Admission: protocol.Admission{Invocation: invoking}, Receipt: &second}, + } + admitted, err := installationReprojectionAdmits(records, "run-one", invoking, "bundle-c") + if err != nil || !admitted { + t.Fatalf("installation reprojection admitted=%t err=%v", admitted, err) + } + if admitted, err := installationReprojectionAdmits(records, "run-other", invoking, "bundle-c"); err != nil || admitted { + t.Fatalf("foreign Flow installation admitted=%t err=%v", admitted, err) + } + if admitted, err := installationReprojectionAdmits(records, "run-one", invoking, "bundle-other"); err != nil || admitted { + t.Fatalf("foreign bundle installation admitted=%t err=%v", admitted, err) + } +} + func TestActiveFlowIdentityRequiresExactWorktreeLineage(t *testing.T) { current := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree-a", ControllerID: "controller"} otherWorktree := current @@ -82,3 +137,59 @@ func TestActiveFlowIdentityFollowsCommittedWorkspaceTransfer(t *testing.T) { t.Fatalf("other controller inherited Flow identity = %#v, %t, %v", found, ok, err) } } + +func TestReprojectedDelegationAnchorsAtCurrentCommittedWorktree(t *testing.T) { + source := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "source", Ref: "refs/heads/main", ControllerID: "controller"} + managed := source + managed.WorktreeID, managed.Ref = "managed", "refs/heads/feature" + next := managed + next.WorktreeID, next.Ref = "next", "refs/heads/next" + + cut := protocol.TransitionReceipt{ + ID: "cut", FlowID: "run-one", Sequence: 8, TransitionID: "workspace.cut", ExecutionContext: "advance", + PriorInvocation: &source, ResultingInvocation: &managed, + } + records := []journalRecord{{Admission: protocol.Admission{Invocation: source}, Receipt: &cut}} + + admitted, err := invocationAuthorizedByRecords(records, "run-one", managed, managed) + if err != nil || !admitted { + t.Fatalf("fresh managed-worktree delegation admitted=%t err=%v", admitted, err) + } + + advance := protocol.TransitionReceipt{ + ID: "advance", FlowID: "run-one", Sequence: 9, TransitionID: "workspace.advance", ExecutionContext: "advance", + PriorInvocation: &managed, ResultingInvocation: &next, + } + records = append(records, journalRecord{Admission: protocol.Admission{Invocation: managed}, Receipt: &advance}) + admitted, err = invocationAuthorizedByRecords(records, "run-one", managed, next) + if err != nil || !admitted { + t.Fatalf("post-delegation advance admitted=%t err=%v", admitted, err) + } +} + +func TestReprojectedDelegationRejectsDisconnectedAdvanceAfterAnchor(t *testing.T) { + source := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "source", Ref: "refs/heads/main", ControllerID: "controller"} + managed := source + managed.WorktreeID, managed.Ref = "managed", "refs/heads/feature" + foreign := source + foreign.WorktreeID, foreign.Ref = "foreign", "refs/heads/foreign" + next := foreign + next.WorktreeID, next.Ref = "next", "refs/heads/next" + + cut := protocol.TransitionReceipt{ + ID: "cut", FlowID: "run-one", Sequence: 8, TransitionID: "workspace.cut", ExecutionContext: "advance", + PriorInvocation: &source, ResultingInvocation: &managed, + } + disconnected := protocol.TransitionReceipt{ + ID: "disconnected", FlowID: "run-one", Sequence: 9, TransitionID: "workspace.advance", ExecutionContext: "advance", + PriorInvocation: &foreign, ResultingInvocation: &next, + } + records := []journalRecord{ + {Admission: protocol.Admission{Invocation: source}, Receipt: &cut}, + {Admission: protocol.Admission{Invocation: foreign}, Receipt: &disconnected}, + } + + if admitted, err := invocationAuthorizedByRecords(records, "run-one", managed, next); err == nil || admitted { + t.Fatalf("disconnected post-anchor advance admitted=%t err=%v", admitted, err) + } +} diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index a31048e..19c54d5 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -446,7 +446,10 @@ func gateStateHandler(gate string) nativeStateHandler { upsertGate(state, durable.GateEvidence{Gate: gate, Revision: revision, Fingerprint: fingerprint}) state.SourceRevision, state.WorktreeFingerprint = admission.SourceRevision, admission.WorktreeFingerprint state.Terminal, state.Delivery = model.TerminalNonterminal, model.DeliveryActive - state.Verification, state.Phase = model.VerificationCurrent, model.PhaseActive + state.Verification, state.Phase = model.VerificationStale, model.PhaseActive + if state.RequiredGateEvidenceCurrent() { + state.Verification = model.VerificationCurrent + } if verifiedObjectiveSatisfied(*state, admission.Objective) { state.Delivery = model.DeliveryGatesPassed establishTerminal(state, model.PhaseTerminal) @@ -464,7 +467,10 @@ func applyVisualEvidence(state *durable.State, admission protocol.Admission, _ c if admission.Objective.TrustedObjectiveClass() == model.ObjectiveVerified { state.Delivery = model.DeliveryActive } - state.Verification, state.Phase = model.VerificationCurrent, model.PhaseActive + state.Verification, state.Phase = model.VerificationStale, model.PhaseActive + if state.RequiredGateEvidenceCurrent() { + state.Verification = model.VerificationCurrent + } if verifiedObjectiveSatisfied(*state, admission.Objective) { state.Delivery = model.DeliveryGatesPassed establishTerminal(state, model.PhaseTerminal) @@ -541,23 +547,12 @@ func upsertGate(state *durable.State, evidence durable.GateEvidence) { } func hasGates(state durable.State, names ...string) bool { - found := map[string]bool{} - for _, gate := range state.Gates { - if gate.Revision == state.SourceRevision { - found[gate.Gate] = true - } - } - for _, name := range names { - if !found[name] { - return false - } - } - return true + return state.HasCurrentGates(names...) } func verifiedObjectiveSatisfied(state durable.State, objective model.Objective) bool { if objective.TrustedObjectiveClass() != model.ObjectiveVerified || !hasGates(state, "build", "test", "review") { return false } - return state.VisualEvidencePolicy != "required" || hasGates(state, "visual") + return state.RequiredGateEvidenceCurrent() } diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go index dc1f88c..e5e800b 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go @@ -49,6 +49,37 @@ func TestRequiredVisualEvidenceParticipatesInVerifiedTerminal(t *testing.T) { } } +func TestVerificationCurrentRequiresEveryMandatoryGateAtCurrentRevision(t *testing.T) { + objective := model.Objective{ID: "publication-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} + state := durable.State{ + SchemaVersion: durable.StateSchemaVersion, RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree", Revision: 1, + Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryActive, Workspace: model.WorkspaceActive, + Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationNone, + Verification: model.VerificationCurrent, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, + Objective: objective, SourceRevision: "old", Gates: []durable.GateEvidence{{Gate: "test", Revision: "old", Fingerprint: "old-test"}}, + } + apply := func(gate string) { + t.Helper() + transition, ok := testprogram.StandardRegistry().Lookup(catalog.TransitionID("gate." + gate + ".record")) + if !ok { + t.Fatalf("missing %s gate transition", gate) + } + parameters := protocol.Parameters{{Name: "source_revision", Value: "current"}, {Name: "evidence_fingerprint", Value: "evidence-" + gate}} + if err := applyStateTransition(&state, protocol.Admission{Objective: objective, Parameters: parameters, SourceRevision: "current", WorktreeFingerprint: "tree"}, transition); err != nil { + t.Fatalf("apply %s: %v", gate, err) + } + } + apply("build") + apply("review") + if state.Verification != model.VerificationStale { + t.Fatalf("stale test gate produced verification=%s, want stale", state.Verification) + } + apply("test") + if state.Verification != model.VerificationCurrent || !state.RequiredGateEvidenceCurrent() { + t.Fatalf("complete exact-revision gate set produced state %#v", state) + } +} + func TestPublicationCorrectionRequiresIndependentObservationForTerminal(t *testing.T) { // control-law: external-writer-cannot-self-certify-provider-state objective := model.Objective{ID: "publication-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "publication-delivery"} diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index f183312..f561fc4 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -13,6 +13,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" + "github.com/operatorstack/boatstack/boatstack/invocation" general "github.com/operatorstack/boatstack/boatstack/kernel" ) @@ -40,14 +41,15 @@ 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 + 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 } type Resolution struct { @@ -157,7 +159,21 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution if bundleErr != nil { return Resolution{}, bundleErr } - prescription, prescriptionErr := protocol.NewPrescriptionWithWorkAndBundle(snapshot, *decision.Transition, capabilities, request.Work, bundle) + invocationFingerprint := "" + if request.InvocationEvidence != nil { + if request.InvocationEvidence.TransitionID != string(decision.Transition.ID) || request.InvocationEvidence.ExecutionProgramFingerprint != e.program.Fingerprint || request.InvocationEvidence.StateRevision != snapshot.StateRevision { + decision.Kind, decision.Reason, decision.Transition = supervisor.DecisionRefused, "INVOCATION_DRIFT: invocation evidence does not match the selected transition, executable program, or state", nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } + if parameterErr := validateInvocationParameters(*request.InvocationEvidence, request.Parameters); parameterErr != nil { + decision.Kind, decision.Reason, decision.Transition = supervisor.DecisionRefused, "INVOCATION_DRIFT: "+parameterErr.Error(), nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } + invocationFingerprint = request.InvocationEvidence.InvocationFingerprint + } + prescription, prescriptionErr := protocol.NewPrescriptionWithInvocation(snapshot, *decision.Transition, capabilities, request.Work, bundle, invocationFingerprint) if prescriptionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = prescriptionErr.Error() @@ -184,6 +200,29 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } +func validateInvocationParameters(evidence invocation.Evidence, parameters protocol.Parameters) error { + bound := make(map[string]string, len(evidence.Parameters)) + for _, parameter := range evidence.Parameters { + if parameter.SecretReference != "" { + continue + } + if _, exists := bound[parameter.Name]; exists { + return fmt.Errorf("invocation evidence duplicates parameter %q", parameter.Name) + } + bound[parameter.Name] = parameter.Value + } + if len(bound) != len(parameters) { + return fmt.Errorf("invocation evidence parameters do not match the admitted parameter set") + } + for _, parameter := range parameters { + value, ok := bound[parameter.Name] + if !ok || value != parameter.Value { + return fmt.Errorf("invocation evidence parameter %q does not match the admitted value", parameter.Name) + } + } + return nil +} + func (e Engine) decisionTrace(snapshot model.Snapshot, requestedObjective model.Objective, authorityFingerprint string, requested catalog.TransitionID, decision supervisor.Decision, candidates []general.CandidateTrace) *general.DecisionTrace { trace := &general.DecisionTrace{ SchemaVersion: general.DecisionTraceSchemaVersion, InstanceID: snapshot.Invocation.RepositoryID, @@ -357,6 +396,13 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, nil } } + invocationFingerprint := "" + if request.InvocationEvidence != nil { + invocationFingerprint = request.InvocationEvidence.InvocationFingerprint + } + if err := request.Prescription.ValidateInvocation(invocationFingerprint); err != nil { + return result, err + } if request.AdmissionLifetime <= 0 { request.AdmissionLifetime = 2 * time.Minute } @@ -473,6 +519,12 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, err } defer e.receipts.Unbind(request.FlowID) + // Validate the entire committed history and allocate from it before any + // effect or transaction journal can cross the mutation boundary. + sequence, err := e.receipts.NextSequence(ctx, request.FlowID) + if err != nil { + return result, fmt.Errorf("preflight committed receipt history: %w", err) + } if err := e.journal.Begin(ctx, admission, transition); err != nil { return result, fmt.Errorf("begin transaction journal: %w", err) } @@ -547,12 +599,8 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe } return result, abort("postcondition failed and effect rolled back", postcondition) } - sequence, err := e.receipts.NextSequence(ctx, request.FlowID) - if err != nil { - return result, requireRecovery("sequence allocation failed after verified effect", err) - } completedAt := e.clock.Now() - receipt, err := protocol.NewReceipt(request.FlowID, sequence, e.program, admission, transition, target, prepared.ChangedStateFacets(), prepared.CommittedEffects(), nil, startedAt, completedAt) + receipt, err := protocol.NewReceipt(request.FlowID, sequence, e.program, admission, transition, target, prepared.ChangedStateFacets(), prepared.CommittedEffects(), effectResult.Outputs, nil, startedAt, completedAt) if err != nil { return result, requireRecovery("receipt construction failed after verified effect", err) } @@ -604,6 +652,9 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques if prior.PrescriptionID != request.Prescription.ID { return fmt.Errorf("idempotency receipt belongs to a different prescription") } + if prior.InvocationFingerprint != request.Prescription.InvocationFingerprint { + return fmt.Errorf("idempotency receipt belongs to a different transition invocation") + } if prior.ObjectiveScope != catalog.ObjectiveScopeOptionalPreserve && request.Objective.Validate() == nil { if prior.ObjectiveID != request.Objective.ID || prior.TargetID != request.Objective.TargetID || prior.TrustedClass != request.Objective.TrustedClass || prior.DeliveryID != request.Objective.DeliveryID { return fmt.Errorf("idempotency receipt belongs to a different configured objective") @@ -619,11 +670,7 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques if prior.WorkResultFingerprint != workFingerprint { return fmt.Errorf("idempotency receipt belongs to a different foreground work result") } - if bundle == nil { - if prior.ControlBundleSourceFingerprint != "" || prior.ControlBundleTargetFingerprint != "" { - return fmt.Errorf("idempotency receipt belongs to a repository control bundle") - } - } else { + if bundle != nil { if prior.ControlBundleTargetFingerprint == "" { if prior.ControlBundleSourceFingerprint != bundle.Source.Fingerprint || bundle.Target != nil { return fmt.Errorf("idempotency receipt belongs to a different repository control bundle") diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index 89dab5b..6f081ec 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -18,6 +18,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" + "github.com/operatorstack/boatstack/boatstack/invocation" general "github.com/operatorstack/boatstack/boatstack/kernel" ) @@ -149,15 +150,19 @@ func (e *fakeEffects) Rollback(context.Context) error { } type memoryReceipts struct { - next uint64 - values []protocol.TransitionReceipt - projectErr error + next uint64 + values []protocol.TransitionReceipt + projectErr error + sequenceErr error } func (s *memoryReceipts) Bind(context.Context, string, protocol.Admission) error { return nil } func (s *memoryReceipts) Unbind(string) {} func (s *memoryReceipts) NextSequence(context.Context, string) (uint64, error) { + if s.sequenceErr != nil { + return 0, s.sequenceErr + } s.next++ return s.next, nil } @@ -324,6 +329,80 @@ func TestRequiredObserverFailureReturnsTypedUnresolvedDecision(t *testing.T) { } } +func TestApplyRejectsInvocationDriftBeforePrepareOrEffect(t *testing.T) { + // control-law: an old prescription cannot cross the effect boundary after + // effect-time invocation rematerialization produces a different identity. + now := time.Unix(30, 0).UTC() + journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, &fakeLock{} + kernel, err := New( + testRegistry(t), syntheticObjectiveContracts(t), syntheticProgram, + &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}}, fixedClock{now}, + fakeLocker{lock}, journal, effects, receipts, + ) + if err != nil { + t.Fatal(err) + } + apply := request(t, now) + snapshot, err := model.CanonicalizeForProgram(observation(model.PhaseObserved, "source"), syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + transition, _ := testRegistry(t).Lookup("test.advance") + capabilities, err := protocol.ProjectCapabilities(snapshot, transition, apply.Authority, now) + if err != nil { + t.Fatal(err) + } + apply.Prescription, err = protocol.NewPrescriptionWithInvocation(snapshot, transition, capabilities, nil, nil, strings.Repeat("a", 64)) + if err != nil { + t.Fatal(err) + } + apply.InvocationEvidence = &invocation.Evidence{ + ProgramFingerprint: strings.Repeat("a", 64), ExecutionProgramFingerprint: syntheticProgramFingerprint, + TransitionID: "test.advance", StateRevision: snapshot.StateRevision, InvocationFingerprint: strings.Repeat("c", 64), + } + + if _, err := kernel.Apply(context.Background(), apply); err == nil || !strings.Contains(err.Error(), "INVOCATION_DRIFT") { + t.Fatalf("drift result = %v", err) + } + if effects.transition.ID != "" || effects.executions != 0 || journal.begun != 0 || len(receipts.values) != 0 || lock.released { + t.Fatalf("invocation drift crossed the effect boundary: prepared=%q effects=%d journal=%d receipts=%d lock=%t", effects.transition.ID, effects.executions, journal.begun, len(receipts.values), lock.released) + } +} + +func TestResolutionSeparatesDefinitionAndExecutableProgramIdentity(t *testing.T) { + // control-law: repository definition identity and executable control-program + // identity are both bound, but only the latter is compared to the engine. + now := time.Unix(30, 0).UTC() + kernel, err := New( + testRegistry(t), syntheticObjectiveContracts(t), syntheticProgram, + &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}}, fixedClock{now}, + fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, + ) + if err != nil { + t.Fatal(err) + } + request := request(t, now).ResolveRequest + request.InvocationEvidence = &invocation.Evidence{ + ProgramFingerprint: strings.Repeat("a", 64), ExecutionProgramFingerprint: syntheticProgramFingerprint, + TransitionID: "test.advance", StateRevision: 1, InvocationFingerprint: strings.Repeat("d", 64), + } + resolution, err := kernel.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if resolution.Prescription.ID == "" || resolution.Prescription.InvocationFingerprint != request.InvocationEvidence.InvocationFingerprint { + t.Fatalf("distinct definition and executable identities were not prescribed: %#v", resolution) + } + request.InvocationEvidence.ExecutionProgramFingerprint = strings.Repeat("e", 64) + refused, err := kernel.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if refused.Decision.Kind != supervisor.DecisionRefused || !strings.Contains(refused.Decision.Reason, "executable program") { + t.Fatalf("wrong executable identity was not refused: %#v", refused.Decision) + } +} + func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T) { // control-law: a selected transition is only a candidate until deterministic admission inputs are complete now := time.Unix(30, 0).UTC() @@ -362,6 +441,43 @@ func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T } } +func TestResolutionRejectsParametersThatDifferFromInvocationEvidence(t *testing.T) { + // control-law: protocol parameters cannot diverge from the exact values + // materialized into the selected transition invocation. + now := time.Unix(30, 0).UTC() + transitions := testRegistry(t).All() + for index := range transitions { + if transitions[index].ID == "test.advance" { + transitions[index].Parameters = []catalog.ParameterSpec{{Name: "value", Required: true}} + } + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + effects := &fakeEffects{} + kernel, err := New(registry, syntheticObjectiveContracts(t), syntheticProgram, + &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source")}}, fixedClock{now}, + fakeLocker{&fakeLock{}}, &fakeJournal{}, effects, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + request := request(t, now).ResolveRequest + request.Parameters = protocol.Parameters{{Name: "value", Value: "substituted"}} + request.InvocationEvidence = &invocation.Evidence{ + ProgramFingerprint: strings.Repeat("c", 64), ExecutionProgramFingerprint: syntheticProgramFingerprint, + TransitionID: "test.advance", StateRevision: 1, InvocationFingerprint: strings.Repeat("d", 64), + Parameters: []invocation.ResolvedParameter{{Name: "value", Value: "materialized"}}, + } + resolution, err := kernel.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if resolution.Decision.Kind != supervisor.DecisionRefused || !strings.Contains(resolution.Decision.Reason, "INVOCATION_DRIFT") || effects.transition.ID != "" { + t.Fatalf("mismatched invocation parameters = decision %#v prepared=%q", resolution.Decision, effects.transition.ID) + } +} + func mustWorkContextFingerprint(t *testing.T, snapshot model.Snapshot) string { t.Helper() fingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) @@ -483,7 +599,26 @@ func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) if err != nil { t.Fatal(err) } - result, err := kernel.Apply(context.Background(), request(t, now)) + apply := request(t, now) + snapshot, err := model.CanonicalizeForProgram(observation(model.PhaseObserved, "source"), syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + transition, _ := testRegistry(t).Lookup("test.advance") + capabilities, err := protocol.ProjectCapabilities(snapshot, transition, apply.Authority, now) + if err != nil { + t.Fatal(err) + } + invocationFingerprint := strings.Repeat("a", 64) + apply.Prescription, err = protocol.NewPrescriptionWithInvocation(snapshot, transition, capabilities, nil, nil, invocationFingerprint) + if err != nil { + t.Fatal(err) + } + apply.InvocationEvidence = &invocation.Evidence{ + ProgramFingerprint: strings.Repeat("c", 64), ExecutionProgramFingerprint: syntheticProgramFingerprint, + TransitionID: "test.advance", StateRevision: snapshot.StateRevision, InvocationFingerprint: invocationFingerprint, + } + result, err := kernel.Apply(context.Background(), apply) if err != nil { t.Fatal(err) } @@ -491,7 +626,9 @@ func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) t.Fatalf("unexpected boundary evidence: effects=%+v journal=%+v receipts=%d receipt=%q released=%v", effects, journal, len(receipts.values), result.Receipt.ID, lock.released) } retry := request(t, now) + retry.Prescription = apply.Prescription retry.IdempotencyKey = result.Admission.IdempotencyKey + retry.InvocationEvidence = nil replayed, err := kernel.Apply(context.Background(), retry) if err != nil { t.Fatal(err) @@ -501,6 +638,26 @@ func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) } } +func TestApplyValidatesCommittedHistoryBeforeExecutingEffect(t *testing.T) { + // control-law: incompatible durable history cannot be discovered after a + // new effect has crossed its execution boundary + now := time.Unix(30, 0).UTC() + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}} + journal, effects := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}} + receipts := &memoryReceipts{sequenceErr: errors.New("unsupported committed history")} + kernel, err := New(testRegistry(t), syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, receipts) + if err != nil { + t.Fatal(err) + } + _, err = kernel.Apply(context.Background(), request(t, now)) + if err == nil || !strings.Contains(err.Error(), "preflight committed receipt history") { + t.Fatalf("history preflight error = %v", err) + } + if effects.executions != 0 || journal.begun != 0 || journal.recovery != 0 { + t.Fatalf("history failure crossed mutation boundary: effects=%d begun=%d recovery=%d", effects.executions, journal.begun, journal.recovery) + } +} + func TestCommitFailureCannotProjectSuccessfulTransitionFact(t *testing.T) { // control-law: canonical commit precedes every passive success projection now := time.Unix(30, 0).UTC() diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager.go b/boatstack/internal/softwaredelivery/foregroundwork/manager.go index e193274..c08cbd3 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager.go @@ -104,6 +104,15 @@ type Manager struct { store ports.RuntimeStore } +// LoadRecord reads one exact runtime-owned foreground-work record for generic +// invocation materialization. It does not mutate or advance the work state. +func LoadRecord(layout ports.ControllerLayout, runID, workID string) (Record, error) { + if !segment(runID) || !segment(workID) { + return Record{}, fmt.Errorf("foreground work requires semantic run and work identities") + } + return load(recordPath(layout, runID, workID)) +} + func NewManager(resolver ports.InvocationResolver, locker ports.Locker, clock ports.Clock, store ports.RuntimeStore) (Manager, error) { if resolver == nil || locker == nil || clock == nil || store == nil { return Manager{}, fmt.Errorf("foreground work manager requires resolver, locker, clock, and runtime store") diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 34bac4d..c577ab1 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -617,6 +617,9 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta if err != nil { return plan, verification, terminal, nil, nil, err } + if exists && fingerprint == gate.Fingerprint && gate.Revision == state.SourceRevision { + evidence.Revision = gate.Revision + } verificationEvidence = append(verificationEvidence, evidence) if !exists || fingerprint != gate.Fingerprint || gate.Revision == "" { verification, terminal = model.VerificationStale, model.TerminalStale @@ -629,7 +632,6 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta if err != nil { return plan, verification, terminal, nil, nil, err } - verificationEvidence = append(verificationEvidence, evidence) valid := known && exists if valid { raw, readErr := os.ReadFile(path) @@ -660,10 +662,17 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta } else { valid = false } + if valid && gate.Revision == state.SourceRevision { + evidence.Revision = gate.Revision + } + verificationEvidence = append(verificationEvidence, evidence) if !valid { verification, terminal = model.VerificationStale, model.TerminalStale } } + if state.Verification == model.VerificationCurrent && !state.RequiredGateEvidenceCurrent() { + verification, terminal = model.VerificationStale, model.TerminalStale + } if terminal == model.TerminalEstablished && state.Objective.TrustedObjectiveClass() == model.ObjectiveVerified && state.VisualEvidencePolicy == "required" && !hasVisual { verification, terminal = model.VerificationUnresolved, model.TerminalStale } diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index 3d86e14..3b5535b 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -311,6 +311,45 @@ func TestObserverMarksApprovalByteSubstitutionStale(t *testing.T) { } } +func TestObserverRejectsCurrentVerificationWithStaleMandatoryGate(t *testing.T) { + repository := t.TempDir() + deliveryID := "delivery" + root := filepath.Join(repository, ".boatstack", "evidence", deliveryID) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + completedAt := time.Unix(1, 0).UTC() + payload := observedGateEvidence{SchemaVersion: 1, Gate: "test", SourceRevision: "old", Outcome: "passed", Producer: "test", CompletedAt: completedAt} + payloadRaw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + payloadFingerprint := hashBytes(payloadRaw) + artifact := observedGate{SchemaVersion: 1, DeliveryID: deliveryID, TransitionID: "gate.test.record", Revision: "old", Fingerprint: payloadFingerprint, AdmissionID: "adm-test", RecordedAt: completedAt} + artifactRaw, err := json.Marshal(artifact) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "test.evidence.json"), payloadRaw, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "test.json"), artifactRaw, 0o600); err != nil { + t.Fatal(err) + } + state := durable.State{ + Verification: model.VerificationCurrent, Terminal: model.TerminalEstablished, SourceRevision: "current", + Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: deliveryID}, + Gates: []durable.GateEvidence{{Gate: "test", Revision: "old", Fingerprint: payloadFingerprint}}, + } + _, verification, terminal, _, _, err := observeRepositoryArtifacts(ports.ControllerLayout{RepositoryRoot: repository, EvidenceRoot: filepath.Join(repository, ".boatstack", "evidence")}, state, completedAt.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + if verification != model.VerificationStale || terminal != model.TerminalStale { + t.Fatalf("stale mandatory gate observed as verification=%s terminal=%s", verification, terminal) + } +} + func TestObserverDerivesHighRiskChangeFromCommittedAndWorkingTreePaths(t *testing.T) { repository := t.TempDir() runGit(t, repository, "init", "-q") @@ -453,6 +492,43 @@ func TestRecoveryWithoutStagedManifestCannotPrescribeResume(t *testing.T) { } } +func TestUnknownPublicationOutcomeRetainsMaterializableReconciliationIdentity(t *testing.T) { + // control-law: publication recovery depends only on journal evidence that + // survives an interrupted external effect. + root := t.TempDir() + transactionID := "adm-publication-unknown" + pending := map[string]any{ + "schema_version": protocol.JournalSchemaVersion, + "transition_id": "publication.execute", + "transition_class": "owned-external", + "status": "recovery-required", + "reason": "publication result was not parseable", + "admission": map[string]any{ + "id": transactionID, + "expected_program_fingerprint": strings.Repeat("a", 64), + "source_phase": "ACTIVE", + "invocation": map[string]any{"correlation_id": "prior-process"}, + }, + } + raw, err := json.Marshal(pending) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, transactionID+".pending"), raw, 0o600); err != nil { + t.Fatal(err) + } + observed, err := pendingJournalEvidence(root, "restart", time.Unix(550, 0).UTC()) + if err != nil { + t.Fatal(err) + } + if !observed.Found || observed.Recovery.TransactionID != transactionID { + t.Fatalf("publication recovery evidence = %#v", observed) + } + if len(observed.Recovery.Permitted) != 2 || observed.Recovery.Permitted[0] != "publication.reconcile" || observed.Recovery.Permitted[1] != "recovery.escalate" { + t.Fatalf("publication recovery contract = %v", observed.Recovery.Permitted) + } +} + func TestInterruptedRecoveryAttemptCollapsesToEscalatableTransactionGroup(t *testing.T) { // control-law: recovery-of-recovery-does-not-create-an-unselectable-conflict root := t.TempDir() diff --git a/boatstack/internal/softwaredelivery/plant/resolver.go b/boatstack/internal/softwaredelivery/plant/resolver.go index e5df26b..e1043de 100644 --- a/boatstack/internal/softwaredelivery/plant/resolver.go +++ b/boatstack/internal/softwaredelivery/plant/resolver.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "os/exec" @@ -144,6 +145,33 @@ func (r Resolver) git(ctx context.Context, path string, arguments ...string) (st return strings.TrimSpace(string(output)), nil } +// BranchExists performs the trusted read-only Git inspection used by domain +// parameter resolvers. Command execution remains owned by the plant boundary. +func (r Resolver) BranchExists(ctx context.Context, path, branch string) (bool, error) { + _, err := r.git(ctx, path, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) + if err == nil { + return true, nil + } + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 { + return false, nil + } + return false, err +} + +// ResolveSourceRevision returns the exact committed HEAD through the plant's +// classified read-only Git boundary. +func (r Resolver) ResolveSourceRevision(ctx context.Context, path string) (string, error) { + revision, err := r.git(ctx, path, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return "", fmt.Errorf("resolve current source revision: %w", err) + } + if (len(revision) != 40 && len(revision) != 64) || strings.Trim(revision, "0123456789abcdef") != "" { + return "", fmt.Errorf("resolve current source revision: invalid object identity") + } + return revision, nil +} + func (r Resolver) ResolveInvocation(ctx context.Context, path, host, correlation string) (model.InvocationContext, error) { if strings.TrimSpace(path) == "" || strings.TrimSpace(host) == "" || strings.TrimSpace(correlation) == "" { return model.InvocationContext{}, fmt.Errorf("repository path, host, and correlation are required") diff --git a/boatstack/internal/softwaredelivery/ports/ports.go b/boatstack/internal/softwaredelivery/ports/ports.go index b32fd68..5b7fd15 100644 --- a/boatstack/internal/softwaredelivery/ports/ports.go +++ b/boatstack/internal/softwaredelivery/ports/ports.go @@ -79,6 +79,7 @@ const ( type EffectResult struct { Settlement EffectSettlement Detail string + Outputs protocol.Parameters } type ResourceMutation struct { diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 1c862af..a9aea51 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -10,7 +10,12 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const AdmissionSchemaVersion = 8 +const AdmissionSchemaVersion = 9 + +// PreviousAdmissionSchemaVersion is the only historical admission encoding +// accepted by the committed-journal compatibility boundary. New admissions +// are always written at AdmissionSchemaVersion. +const PreviousAdmissionSchemaVersion = 8 type Admission struct { SchemaVersion int `json:"schema_version"` @@ -43,6 +48,7 @@ type Admission struct { ExpiresAt time.Time `json:"expires_at"` Work *WorkEvidence `json:"work,omitempty"` ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` + InvocationFingerprint string `json:"invocation_fingerprint,omitempty"` } func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, now time.Time, lifetime time.Duration) (Admission, error) { @@ -98,6 +104,7 @@ func NewAdmissionWithWorkAndBundle(snapshot model.Snapshot, objective model.Obje AuthorityFingerprint: capabilities.AuthorityFingerprint, RequiredCapabilities: capabilities.Required, GrantedCapabilities: capabilities.Granted, EffectiveCapabilities: capabilities.Effective, Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(), + InvocationFingerprint: prescription.InvocationFingerprint, } if work != nil { copy := *work @@ -445,11 +452,38 @@ func validateAuthorityEvidence(snapshot model.Snapshot, authority AuthorityBundl } func (a Admission) ValidateIdentity() error { - if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.PrescriptionID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.ExpectedStateRevision == 0 || len(a.ExpectedProgramFingerprint) != 64 || len(a.ExpectedSnapshotFingerprint) != 64 || len(a.ExpectedObjectiveBindingFingerprint) != 64 || a.AuthorityFingerprint == "" || len(a.RequiredCapabilities) == 0 || len(a.EffectiveCapabilities) == 0 || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { + return a.validateIdentity(AdmissionSchemaVersion) +} + +// ValidateCommittedHistoryIdentity validates an immutable admission using +// either the current encoding or the exact immediately preceding encoding. +// It is not valid for admitting new work or for pending journals. +func (a Admission) ValidateCommittedHistoryIdentity() error { + switch a.SchemaVersion { + case AdmissionSchemaVersion: + return a.ValidateIdentity() + case PreviousAdmissionSchemaVersion: + if a.InvocationFingerprint != "" { + return fmt.Errorf("legacy admission invents a current-schema invocation identity") + } + return a.validateIdentity(PreviousAdmissionSchemaVersion) + default: + return fmt.Errorf("admission: unsupported committed-history schema %d", a.SchemaVersion) + } +} + +func (a Admission) validateIdentity(schemaVersion int) error { + if a.SchemaVersion != schemaVersion || a.ID == "" || a.PrescriptionID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.ExpectedStateRevision == 0 || len(a.ExpectedProgramFingerprint) != 64 || len(a.ExpectedSnapshotFingerprint) != 64 || len(a.ExpectedObjectiveBindingFingerprint) != 64 || a.AuthorityFingerprint == "" || len(a.RequiredCapabilities) == 0 || len(a.EffectiveCapabilities) == 0 || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) || (a.InvocationFingerprint != "" && len(a.InvocationFingerprint) != 64) { return fmt.Errorf("admission: invalid schema, identity, source, or lifetime") } if a.ControlBundle != nil { - if err := a.ControlBundle.Validate(); err != nil { + var err error + if schemaVersion == PreviousAdmissionSchemaVersion { + err = a.ControlBundle.ValidateCommittedHistory() + } else { + err = a.ControlBundle.Validate() + } + if err != nil { return err } } diff --git a/boatstack/internal/softwaredelivery/protocol/prescription.go b/boatstack/internal/softwaredelivery/protocol/prescription.go index 8a6c840..6a09fe2 100644 --- a/boatstack/internal/softwaredelivery/protocol/prescription.go +++ b/boatstack/internal/softwaredelivery/protocol/prescription.go @@ -9,7 +9,7 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const PrescriptionSchemaVersion = 6 +const PrescriptionSchemaVersion = 7 // Prescription is the immutable compare-and-swap binding emitted by // resolution and required by apply. It carries no reusable authority or @@ -23,6 +23,7 @@ type Prescription struct { EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + InvocationFingerprint string `json:"invocation_fingerprint,omitempty"` } func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection) (Prescription, error) { @@ -34,6 +35,10 @@ func NewPrescriptionWithWork(snapshot model.Snapshot, transition catalog.Transit } func NewPrescriptionWithWorkAndBundle(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection, work *WorkEvidence, bundle *boatstackruntime.ControlBundleContract) (Prescription, error) { + return NewPrescriptionWithInvocation(snapshot, transition, capabilities, work, bundle, "") +} + +func NewPrescriptionWithInvocation(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection, work *WorkEvidence, bundle *boatstackruntime.ControlBundleContract, invocationFingerprint string) (Prescription, error) { objectiveBindingFingerprint, err := ObjectiveBindingFingerprint(snapshot) if err != nil { return Prescription{}, err @@ -49,6 +54,12 @@ func NewPrescriptionWithWorkAndBundle(snapshot model.Snapshot, transition catalo RequiredCapabilities: append([]catalog.Capability(nil), capabilities.Required...), EffectiveCapabilities: append([]catalog.Capability(nil), capabilities.Effective...), } + if invocationFingerprint != "" { + if len(invocationFingerprint) != 64 { + return Prescription{}, fmt.Errorf("invocation fingerprint is invalid") + } + prescription.InvocationFingerprint = invocationFingerprint + } if err := ValidateControlBundleForTransition(bundle, transition); err != nil { return Prescription{}, err } @@ -73,6 +84,13 @@ func NewPrescriptionWithWorkAndBundle(snapshot model.Snapshot, transition catalo return prescription, nil } +func (p Prescription) ValidateInvocation(fingerprint string) error { + if p.InvocationFingerprint != fingerprint { + return fmt.Errorf("INVOCATION_DRIFT: prescription is bound to a different transition invocation") + } + return nil +} + func (p Prescription) Validate() error { if err := p.validateFields(); err != nil { return err @@ -92,7 +110,7 @@ func (p Prescription) Validate() error { func (p Prescription) validateFields() error { if p.SchemaVersion != PrescriptionSchemaVersion || p.TransitionID == "" || p.Freshness.Validate() != nil || - len(p.RequiredCapabilities) == 0 || len(p.EffectiveCapabilities) == 0 || (p.ControlBundleFingerprint != "" && len(p.ControlBundleFingerprint) != 64) { + len(p.RequiredCapabilities) == 0 || len(p.EffectiveCapabilities) == 0 || (p.ControlBundleFingerprint != "" && len(p.ControlBundleFingerprint) != 64) || (p.InvocationFingerprint != "" && len(p.InvocationFingerprint) != 64) { return fmt.Errorf("prescription has invalid schema, transition, state revision, program, or snapshot identity") } return nil diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index d0b23cf..85b1794 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -12,7 +12,12 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ReceiptSchemaVersion = 12 +const ReceiptSchemaVersion = 13 + +// PreviousReceiptSchemaVersion is the only historical receipt encoding +// accepted by the committed-journal compatibility boundary. New receipts are +// always written at ReceiptSchemaVersion. +const PreviousReceiptSchemaVersion = 12 type TransitionFactKind string @@ -123,6 +128,7 @@ type TransitionReceipt struct { GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` CommittedEffects []EffectFact `json:"committed_effects"` + EffectOutputs Parameters `json:"effect_outputs,omitempty"` ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` Verification VerificationFact `json:"verification"` IdempotencyKey string `json:"idempotency_key"` @@ -137,6 +143,7 @@ type TransitionReceipt struct { WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` ControlBundleSourceFingerprint string `json:"control_bundle_source_fingerprint,omitempty"` ControlBundleTargetFingerprint string `json:"control_bundle_target_fingerprint,omitempty"` + InvocationFingerprint string `json:"invocation_fingerprint,omitempty"` } type AuthoritySource struct { @@ -146,7 +153,7 @@ type AuthoritySource struct { Fingerprint string `json:"fingerprint"` } -func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admission Admission, transition catalog.Transition, target model.Snapshot, changedStateFacets []model.StateFacet, effects []EffectFact, exercised []catalog.Capability, startedAt, committedAt time.Time) (TransitionReceipt, error) { +func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admission Admission, transition catalog.Transition, target model.Snapshot, changedStateFacets []model.StateFacet, effects []EffectFact, outputs Parameters, exercised []catalog.Capability, startedAt, committedAt time.Time) (TransitionReceipt, error) { if flowID == "" || sequence == 0 || admission.ID == "" || target.Fingerprint == "" { return TransitionReceipt{}, fmt.Errorf("receipt requires flow, sequence, admission, and target identity") } @@ -197,10 +204,12 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi GrantedCapabilities: append([]catalog.Capability(nil), admission.GrantedCapabilities...), ExercisedCapabilities: append([]catalog.Capability(nil), exercised...), CommittedEffects: canonicalEffects, + EffectOutputs: outputs.Canonical(), ChangedStateFacets: canonicalFacets, Verification: VerificationFact{Verifier: transition.Verifier, ExpectedPostcondition: transition.TargetPredicate, Result: VerificationSatisfied, EvidenceFingerprint: target.Fingerprint, VerifiedAt: committedAt.UTC()}, IdempotencyKey: admission.IdempotencyKey, Recovery: transition.Interruption.Recovery, Terminal: terminal, StartedAt: startedAt.UTC(), CommittedAt: committedAt.UTC(), DurationNanoseconds: committedAt.Sub(startedAt).Nanoseconds(), + InvocationFingerprint: admission.InvocationFingerprint, } if admission.Work != nil { receipt.WorkResultFingerprint = admission.Work.ResultFingerprint @@ -246,13 +255,40 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi } func (r TransitionReceipt) Validate() error { - if r.SchemaVersion != ReceiptSchemaVersion || r.Kind != TransitionCommitted || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision != r.PriorStateRevision+1 || !validSHA256(r.SourceFingerprint) || !validSHA256(r.TargetFingerprint) || !validSHA256(r.ObjectiveBindingFingerprint) || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || r.IdempotencyKey == "" || len(r.CommittedEffects) == 0 || len(r.ChangedStateFacets) == 0 { + return r.validate(ReceiptSchemaVersion) +} + +// ValidateCommittedHistory validates an immutable receipt using either the +// current encoding or the exact immediately preceding encoding. It is not a +// write-side compatibility path. +func (r TransitionReceipt) ValidateCommittedHistory() error { + switch r.SchemaVersion { + case ReceiptSchemaVersion: + return r.Validate() + case PreviousReceiptSchemaVersion: + if len(r.EffectOutputs) != 0 || r.InvocationFingerprint != "" { + return fmt.Errorf("legacy receipt invents current-schema output or invocation evidence") + } + return r.validate(PreviousReceiptSchemaVersion) + default: + return fmt.Errorf("receipt has unsupported committed-history schema %d", r.SchemaVersion) + } +} + +func (r TransitionReceipt) validate(schemaVersion int) error { + if r.SchemaVersion != schemaVersion || r.Kind != TransitionCommitted || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision != r.PriorStateRevision+1 || !validSHA256(r.SourceFingerprint) || !validSHA256(r.TargetFingerprint) || !validSHA256(r.ObjectiveBindingFingerprint) || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || r.IdempotencyKey == "" || len(r.CommittedEffects) == 0 || len(r.ChangedStateFacets) == 0 { return fmt.Errorf("receipt has incomplete committed-transition identity or evidence") } if (r.ControlBundleSourceFingerprint == "") != (r.ControlBundleTargetFingerprint == "") || (r.ControlBundleSourceFingerprint != "" && (len(r.ControlBundleSourceFingerprint) != 64 || len(r.ControlBundleTargetFingerprint) != 64)) { return fmt.Errorf("receipt has incomplete repository control-bundle identity") } + if r.InvocationFingerprint != "" && !validSHA256(r.InvocationFingerprint) { + return fmt.Errorf("receipt has invalid invocation identity") + } + if err := validateEffectOutputs(r.EffectOutputs); err != nil { + return err + } if r.ExecutionContext != "" { if r.ExecutionContext != "advance" || r.PriorInvocation == nil || r.ResultingInvocation == nil { return fmt.Errorf("receipt has invalid execution context lineage") @@ -384,6 +420,21 @@ func (r TransitionReceipt) Validate() error { return nil } +func validateEffectOutputs(outputs Parameters) error { + seen := map[string]bool{} + canonical := outputs.Canonical() + for index, output := range outputs { + if output.Name == "" || output.Value == "" || seen[output.Name] { + return fmt.Errorf("receipt effect outputs require unique non-empty names and values") + } + seen[output.Name] = true + if canonical[index] != output { + return fmt.Errorf("receipt effect outputs are not canonical") + } + } + return nil +} + func validSHA256(value string) bool { if len(value) != 64 || strings.ToLower(value) != value { return false diff --git a/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go b/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go index 4b4a6d1..c41b977 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go @@ -30,7 +30,7 @@ func TestReceiptRejectsRehashedAuthorityProvenanceTampering(t *testing.T) { } transition := catalog.Transition{ID: "program/write", Version: 1, Owner: "program", Effect: "program.write", TargetPredicate: "program.written", Verifier: "program.written", Policy: catalog.PolicyContract{ObjectiveScope: catalog.ObjectiveScopeBoundExact}} effects := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "program.state", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} - receipt, err := NewReceipt("flow", 1, ProgramIdentity{ID: "program", Version: "1.0.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, model.Snapshot{Observation: model.Observation{StateRevision: 2}, Fingerprint: strings.Repeat("c", 64)}, []model.StateFacet{model.StateFacetControl}, effects, nil, now, now.Add(time.Second)) + receipt, err := NewReceipt("flow", 1, ProgramIdentity{ID: "program", Version: "1.0.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, model.Snapshot{Observation: model.Observation{StateRevision: 2}, Fingerprint: strings.Repeat("c", 64)}, []model.StateFacet{model.StateFacetControl}, effects, nil, nil, now, now.Add(time.Second)) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go b/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go index 67cfb46..90f9a06 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go @@ -38,7 +38,7 @@ func committedReceiptFixture(t *testing.T) (TransitionReceipt, Admission, catalo {Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "product-delivery.state", Target: "/repo/.boatstack/state.json", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}, {Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "product-delivery.evidence", Target: "/repo/.boatstack/build.json", Operation: "create", PriorFingerprint: strings.Repeat("3", 64), ResultingFingerprint: strings.Repeat("4", 64)}, } - receipt, err := NewReceipt("flow", 7, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, effects, nil, now, now.Add(time.Second)) + receipt, err := NewReceipt("flow", 7, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, effects, nil, nil, now, now.Add(time.Second)) if err != nil { t.Fatal(err) } @@ -87,6 +87,23 @@ func TestCommittedTransitionFactBindsProgramTransitionStateAuthorityEffectsAndVe } } +func TestCommittedTransitionFactPreservesCanonicalEffectOutputs(t *testing.T) { + _, admission, transition, target, now := committedReceiptFixture(t) + effects := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "state", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} + receipt, err := NewReceipt("flow", 9, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl}, effects, Parameters{{Name: "publication_id", Value: "222"}}, nil, now, now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + if value, ok := receipt.EffectOutputs.Get("publication_id"); !ok || value != "222" { + t.Fatalf("effect outputs = %#v", receipt.EffectOutputs) + } + tampered := receipt + tampered.EffectOutputs = Parameters{{Name: "publication_id", Value: "223"}} + if err := tampered.Validate(); err == nil || !strings.Contains(err.Error(), "content identity") { + t.Fatalf("tampered effect output validation = %v", err) + } +} + func TestObjectiveBindReceiptRecordsResultingObjectiveBinding(t *testing.T) { _, admission, transition, target, now := committedReceiptFixture(t) transition.ID = "objective.bind" @@ -97,7 +114,7 @@ func TestObjectiveBindReceiptRecordsResultingObjectiveBinding(t *testing.T) { t.Fatal(err) } effects := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "objective", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} - receipt, err := NewReceipt("flow", 8, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, effects, nil, now, now.Add(time.Second)) + receipt, err := NewReceipt("flow", 8, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, effects, nil, nil, now, now.Add(time.Second)) if err != nil { t.Fatal(err) } @@ -118,7 +135,7 @@ func TestCommittedTransitionFactRejectsNonSuccessSemantics(t *testing.T) { func TestCommittedTransitionFactRejectsProgramMismatchAtConstruction(t *testing.T) { _, admission, transition, target, now := committedReceiptFixture(t) effect := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "state", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} - _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "other", Version: "1", Fingerprint: strings.Repeat("b", 64)}, admission, transition, target, []model.StateFacet{model.StateFacetControl}, effect, nil, now, now) + _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "other", Version: "1", Fingerprint: strings.Repeat("b", 64)}, admission, transition, target, []model.StateFacet{model.StateFacetControl}, effect, nil, nil, now, now) if err == nil || !strings.Contains(err.Error(), "differs from admitted program") { t.Fatalf("program mismatch error = %v", err) } @@ -128,7 +145,7 @@ func TestCommittedTransitionFactRejectsWrongRevision(t *testing.T) { _, admission, transition, target, now := committedReceiptFixture(t) target.StateRevision = 43 effect := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "state", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} - _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl}, effect, nil, now, now) + _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl}, effect, nil, nil, now, now) if err == nil || !strings.Contains(err.Error(), "advance exactly once") { t.Fatalf("revision mismatch error = %v", err) } diff --git a/boatstack/internal/softwaredelivery/supervisor/supervisor.go b/boatstack/internal/softwaredelivery/supervisor/supervisor.go index 1cae65e..dde02d2 100644 --- a/boatstack/internal/softwaredelivery/supervisor/supervisor.go +++ b/boatstack/internal/softwaredelivery/supervisor/supervisor.go @@ -298,11 +298,17 @@ func targetAlreadySatisfied(snapshot model.Snapshot, objective model.Objective, } func currentEvidenceRecorded(snapshot model.Snapshot, sourcePrefix string) bool { - if snapshot.Verification.Status != model.FactKnown || snapshot.Verification.Value != model.VerificationCurrent { + if snapshot.Verification.Status != model.FactKnown { return false } + currentRevisions := map[string]bool{} + for _, evidence := range snapshot.Delivery.Evidence { + if evidence.Revision != "" { + currentRevisions[evidence.Revision] = true + } + } for _, evidence := range snapshot.Verification.Evidence { - if strings.HasPrefix(evidence.Source, sourcePrefix) { + if strings.HasPrefix(evidence.Source, sourcePrefix) && evidence.Revision != "" && currentRevisions[evidence.Revision] { return true } } diff --git a/boatstack/internal/softwaredelivery/surfaces/locus_render.go b/boatstack/internal/softwaredelivery/surfaces/locus_render.go index dc262a5..1f70e62 100644 --- a/boatstack/internal/softwaredelivery/surfaces/locus_render.go +++ b/boatstack/internal/softwaredelivery/surfaces/locus_render.go @@ -87,7 +87,7 @@ func renderCatalogLocus(transitions []catalog.Transition, safety bool) (string, ID: "boatstack-executable-catalog-liveness-v1", Subject: "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", Evidence: []locusEvidence{ - {Path: "boatstack/delivery/delivery.go", Note: "Compiler combines exact CoreSystem, ProgramRuntime, extension, contract, and ownership declarations into one immutable runtime registry."}, + {Path: "boatstack/delivery/control.go", Note: "Compiler combines exact CoreSystem, ProgramRuntime, extension, contract, and ownership declarations into one immutable runtime registry."}, {Path: "docs/architecture/boatstack-transition-catalog.md", Note: "Generated readable projection from the same runtime registry."}, {Path: "boatstack/internal/softwaredelivery/protocol/admission.go", Note: "Exact admission, authority, parameter, source-revision, provider-request, expiry, and stale-snapshot checks."}, {Path: "boatstack/internal/softwaredelivery/engine/engine.go", Note: "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery."}, diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index de791fe..5abcf08 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -15,10 +15,11 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" + "github.com/operatorstack/boatstack/boatstack/invocation" general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 10 +const SchemaVersion = 12 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -80,6 +81,8 @@ type Request struct { WorkBlockReason string `json:"work_block_reason,omitempty"` ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + InvocationEvidence *invocation.Evidence `json:"invocation_evidence,omitempty"` + InputRequest *invocation.InputRequest `json:"input_request,omitempty"` } func (r Request) Validate(now time.Time) error { @@ -98,6 +101,19 @@ func (r Request) Validate(now time.Time) error { if r.ProgramID == "" && r.ProgramFingerprint != "" { return fmt.Errorf("surface request cannot carry a program fingerprint without a program") } + if r.InputRequest != nil && r.InvocationEvidence != nil { + return fmt.Errorf("surface request cannot carry both an input request and ready invocation evidence") + } + if r.InputRequest != nil { + if r.Operation != OperationResolve || r.InputRequest.Validate() != nil || r.InputRequest.RunID != r.FlowID || r.InputRequest.ProgramFingerprint != r.ProgramFingerprint || r.InputRequest.EntryID != r.EntryID || r.InputRequest.TransitionID != string(r.TransitionID) { + return fmt.Errorf("surface input request does not match the selected Flow transition") + } + } + if r.InvocationEvidence != nil { + if err := r.InvocationEvidence.Validate(); err != nil || r.InvocationEvidence.RunID != r.FlowID || r.InvocationEvidence.ProgramFingerprint != r.ProgramFingerprint || r.InvocationEvidence.EntryID != r.EntryID || r.InvocationEvidence.TransitionID != string(r.TransitionID) { + return fmt.Errorf("surface invocation evidence does not match the selected Flow transition") + } + } if r.ControlBundle != nil { if err := r.ControlBundle.Validate(); err != nil { return err @@ -237,6 +253,8 @@ type Response struct { 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"` } type DelegationRequired struct { diff --git a/boatstack/invocation/invocation.go b/boatstack/invocation/invocation.go new file mode 100644 index 0000000..abf4e0d --- /dev/null +++ b/boatstack/invocation/invocation.go @@ -0,0 +1,529 @@ +// Package invocation materializes canonical Control Program transition +// parameters without knowing any domain vocabulary or effect mechanism. +package invocation + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" +) + +const ( + EvidenceSchema = "transition-invocation" + EvidenceSchemaRevision = 2 + RequestSchema = "transition-input-request" + RequestSchemaRevision = 2 + ReceiptSchema = "transition-input-receipt" + ReceiptSchemaRevision = 2 +) + +type Context struct { + RunID string + ProgramFingerprint string + ExecutionProgramFingerprint string + EntryID string + TargetID string + TransitionID string + StateRevision uint64 + ContextFingerprint string + ControlBundleFingerprint string + ExecutionScopeFingerprint string + InputRequestGeneration uint64 + InputRequestSupersession *InputRequestSupersession + EntryInputs map[string]Value + State map[string]Value + Receipts map[string]Value + WorkOutputs map[string]Value + InputReceipts map[string]InputReceipt +} + +type Value struct { + Type controlprogram.ValueTypeDefinition + Canonical string + SecretReference string + Provenance string + ProducerFingerprint string + AuthorityReceipts []string +} + +type Resolver interface { + ResolveParameter(controlprogram.ParameterResolverBinding, Context) (Value, error) +} + +type ResolvedParameter struct { + Name string `json:"name"` + Type controlprogram.ValueTypeDefinition `json:"type"` + Value string `json:"value,omitempty"` + SecretReference string `json:"secret_reference,omitempty"` + ValueFingerprint string `json:"value_fingerprint"` + ProducerKind controlprogram.ParameterSourceKind `json:"producer_kind"` + ProducerFingerprint string `json:"producer_fingerprint"` + AuthorityReceipts []string `json:"authority_receipts"` +} + +type Evidence struct { + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + RunID string `json:"run_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ExecutionProgramFingerprint string `json:"execution_program_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + TransitionID string `json:"transition_id"` + StateRevision uint64 `json:"state_revision"` + ContextFingerprint string `json:"context_fingerprint"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + Parameters []ResolvedParameter `json:"parameters"` + InvocationFingerprint string `json:"invocation_fingerprint"` +} + +type RequestedParameter struct { + ID string `json:"id"` + Type controlprogram.ValueTypeDefinition `json:"type"` + Description string `json:"description"` + Secret bool `json:"secret"` + Authority controlprogram.AuthorityRequirement `json:"authority"` +} + +type InputRequest struct { + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + ID string `json:"id"` + Code string `json:"code"` + RunID string `json:"run_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ExecutionProgramFingerprint string `json:"execution_program_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + TransitionID string `json:"transition_id"` + Fingerprint string `json:"fingerprint"` + StateRevision uint64 `json:"state_revision"` + ContextFingerprint string `json:"context_fingerprint"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + ExecutionScopeFingerprint string `json:"execution_scope_fingerprint"` + Generation uint64 `json:"generation,omitempty"` + Supersession *InputRequestSupersession `json:"supersession,omitempty"` + Parameters []RequestedParameter `json:"parameters"` +} + +// InputRequestSupersession binds a new immutable request generation to the +// rejected answer generation it replaces. The prior request and its receipts +// remain unchanged. +type InputRequestSupersession struct { + PreviousRequestFingerprint string `json:"previous_request_fingerprint"` + Reason string `json:"reason"` + Actor string `json:"actor"` + Host string `json:"host"` + CreatedAt time.Time `json:"created_at"` +} + +type InputReceipt struct { + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + ID string `json:"id"` + RunID string `json:"run_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ExecutionProgramFingerprint string `json:"execution_program_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + TransitionID string `json:"transition_id"` + ParameterID string `json:"parameter_id"` + Type controlprogram.ValueTypeDefinition `json:"type"` + Value string `json:"value,omitempty"` + SecretReference string `json:"secret_reference,omitempty"` + ValueFingerprint string `json:"value_fingerprint"` + ProducerFingerprint string `json:"producer_fingerprint"` + RequestFingerprint string `json:"request_fingerprint"` + StateRevision uint64 `json:"state_revision"` + ContextFingerprint string `json:"context_fingerprint"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + ExecutionScopeFingerprint string `json:"execution_scope_fingerprint"` + Actor string `json:"actor"` + Host string `json:"host"` + AuthorityReceipts []string `json:"authority_receipts"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Scope string `json:"scope"` + Fingerprint string `json:"fingerprint"` +} + +type Result struct { + Ready *Evidence + Request *InputRequest + Blocker *Blocker +} + +type Blocker struct { + Code string `json:"code"` + Detail string `json:"detail"` +} + +func (r InputRequest) Validate() error { + if r.Schema != RequestSchema || r.SchemaRevision != RequestSchemaRevision || r.ID == "" || r.Code != "TRANSITION_INPUT_REQUIRED" || r.RunID == "" || len(r.ProgramFingerprint) != 64 || len(r.ExecutionProgramFingerprint) != 64 || r.EntryID == "" || r.TargetID == "" || r.TransitionID == "" || len(r.ContextFingerprint) != 64 || len(r.ExecutionScopeFingerprint) != 64 || r.Fingerprint == "" || len(r.Parameters) == 0 { + return fmt.Errorf("input request envelope is invalid") + } + generation := r.EffectiveGeneration() + if generation == 1 && r.Supersession != nil { + return fmt.Errorf("first input request generation cannot supersede another request") + } + if generation > 1 { + if r.Supersession == nil || len(r.Supersession.PreviousRequestFingerprint) != 64 || strings.TrimSpace(r.Supersession.Reason) == "" || strings.TrimSpace(r.Supersession.Actor) == "" || strings.TrimSpace(r.Supersession.Host) == "" || r.Supersession.CreatedAt.IsZero() { + return fmt.Errorf("superseding input request has invalid lineage") + } + } + identity := r + identity.Fingerprint = "" + if fingerprint(identity) != r.Fingerprint { + return fmt.Errorf("input request failed content identity verification") + } + return nil +} + +// EffectiveGeneration treats legacy requests without an explicit generation +// as the first immutable generation. +func (r InputRequest) EffectiveGeneration() uint64 { + if r.Generation == 0 { + return 1 + } + return r.Generation +} + +func (e Evidence) Validate() error { + if e.Schema != EvidenceSchema || e.SchemaRevision != EvidenceSchemaRevision || e.RunID == "" || len(e.ProgramFingerprint) != 64 || len(e.ExecutionProgramFingerprint) != 64 || e.EntryID == "" || e.TargetID == "" || e.TransitionID == "" || len(e.ContextFingerprint) != 64 || len(e.InvocationFingerprint) != 64 { + return fmt.Errorf("invocation evidence envelope is invalid") + } + previous := "" + for _, parameter := range e.Parameters { + if parameter.Name == "" || parameter.Name <= previous || parameter.Type.Kind == "" || len(parameter.ValueFingerprint) != 64 || parameter.ProducerKind == "" || len(parameter.ProducerFingerprint) != 64 || (parameter.Value == "" && parameter.SecretReference == "") { + return fmt.Errorf("invocation evidence parameter set is invalid") + } + if parameter.ValueFingerprint != digest(parameter.Value+"\x00"+parameter.SecretReference) { + return fmt.Errorf("invocation evidence parameter %q failed value identity verification", parameter.Name) + } + previous = parameter.Name + } + identity := e + identity.InvocationFingerprint = "" + if fingerprint(identity) != e.InvocationFingerprint { + return fmt.Errorf("invocation evidence failed content identity verification") + } + return nil +} + +func Materialize(contracts []controlprogram.OperatorParameter, bindings []controlprogram.TransitionParameterBinding, context Context, resolver Resolver) (Result, error) { + if context.RunID == "" || len(context.ProgramFingerprint) != 64 || len(context.ExecutionProgramFingerprint) != 64 || context.EntryID == "" || context.TargetID == "" || context.TransitionID == "" || len(context.ContextFingerprint) != 64 || len(context.ExecutionScopeFingerprint) != 64 { + return Result{}, fmt.Errorf("invocation materializer requires exact run, program, entry, target, transition, and context identity") + } + byBinding := map[string]controlprogram.ParameterProducer{} + for _, binding := range bindings { + byBinding[binding.Parameter] = binding.Producer + } + hostRequest := inputRequestForHostBindings(contracts, byBinding, context) + var parameters []ResolvedParameter + var requested []RequestedParameter + for _, contract := range contracts { + producer, bound := byBinding[contract.ID] + if !bound { + if contract.Required { + return Result{}, fmt.Errorf("required parameter %q has no compiled producer", contract.ID) + } + continue + } + requestFingerprint := "" + if hostRequest != nil { + requestFingerprint = hostRequest.Fingerprint + } + value, available, err := materializeOne(contract, producer, context, resolver, requestFingerprint) + if err != nil { + return Result{Blocker: &Blocker{Code: "TRANSITION_INPUT_BLOCKED", Detail: err.Error()}}, nil + } + if !available { + if producer.Kind != controlprogram.ParameterSourceHostInput || producer.Request == nil { + return Result{Blocker: &Blocker{Code: "TRANSITION_INPUT_UNAVAILABLE", Detail: fmt.Sprintf("parameter %q producer is not currently available", contract.ID)}}, nil + } + requested = append(requested, RequestedParameter{ID: contract.ID, Type: contract.Type, Description: producer.Request.Description, Secret: contract.Secret, Authority: controlprogram.AuthorityRequirement{AnyOf: append([]string(nil), producer.Request.Authorities...)}}) + continue + } + if err := validateValue(contract.Type, value.Canonical, value.SecretReference, contract.Secret); err != nil { + return Result{Blocker: &Blocker{Code: "TRANSITION_INPUT_INVALID", Detail: fmt.Sprintf("parameter %q: %v", contract.ID, err)}}, nil + } + valueFingerprint := digest(value.Canonical + "\x00" + value.SecretReference) + producerFingerprint := value.ProducerFingerprint + if producerFingerprint == "" { + producerFingerprint = fingerprintProducer(producer) + } + parameter := ResolvedParameter{Name: contract.ID, Type: contract.Type, ValueFingerprint: valueFingerprint, ProducerKind: producer.Kind, ProducerFingerprint: producerFingerprint, AuthorityReceipts: append([]string(nil), value.AuthorityReceipts...)} + if contract.Secret { + parameter.SecretReference = value.SecretReference + } else { + parameter.Value = value.Canonical + } + parameters = append(parameters, parameter) + } + if len(requested) != 0 { + return Result{Request: hostRequest}, nil + } + sort.Slice(parameters, func(i, j int) bool { return parameters[i].Name < parameters[j].Name }) + evidence := Evidence{Schema: EvidenceSchema, SchemaRevision: EvidenceSchemaRevision, RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, Parameters: parameters} + evidence.InvocationFingerprint = fingerprintWithoutField(evidence, "InvocationFingerprint") + return Result{Ready: &evidence}, nil +} + +func materializeOne(contract controlprogram.OperatorParameter, producer controlprogram.ParameterProducer, context Context, resolver Resolver, requestFingerprint string) (Value, bool, error) { + switch producer.Kind { + case controlprogram.ParameterSourceEntryInput: + value, ok := context.EntryInputs[producer.Input] + return value, ok, nil + case controlprogram.ParameterSourceState: + value, ok := context.State[producer.Facet] + return value, ok, nil + case controlprogram.ParameterSourceReceipt: + value, ok := context.Receipts[producer.Transition+"/"+producer.Field] + return value, ok, nil + case controlprogram.ParameterSourceStateOrReceipt: + if value, ok := context.State[producer.Facet]; ok { + return value, true, nil + } + value, ok := context.Receipts[producer.Transition+"/"+producer.Field] + return value, ok, nil + case controlprogram.ParameterSourceWorkOutput: + value, ok := context.WorkOutputs[producer.Work+"/"+producer.Output] + return value, ok, nil + case controlprogram.ParameterSourceTrustedResolver: + if resolver == nil || producer.Binding == nil { + return Value{}, false, fmt.Errorf("trusted resolver is unavailable") + } + value, err := resolver.ResolveParameter(*producer.Binding, context) + return value, err == nil, err + case controlprogram.ParameterSourceHostInput: + receipt, ok := context.InputReceipts[contract.ID+"@"+requestFingerprint] + if !ok { + return Value{}, false, nil + } + if err := receipt.ValidateCurrent(context, contract, producer, requestFingerprint); err != nil { + return Value{}, false, err + } + return Value{Type: receipt.Type, Canonical: receipt.Value, SecretReference: receipt.SecretReference, ProducerFingerprint: receipt.ProducerFingerprint, AuthorityReceipts: append([]string(nil), receipt.AuthorityReceipts...)}, true, nil + default: + return Value{}, false, fmt.Errorf("unknown producer kind %q", producer.Kind) + } +} + +func (r InputReceipt) ValidateCurrent(context Context, contract controlprogram.OperatorParameter, producer controlprogram.ParameterProducer, requestFingerprint string) error { + if r.Schema != ReceiptSchema || r.SchemaRevision != ReceiptSchemaRevision || r.ID == "" || r.Fingerprint == "" || r.Scope != "transition" || len(r.ExecutionProgramFingerprint) != 64 || len(r.ExecutionScopeFingerprint) != 64 || producer.Request == nil { + return fmt.Errorf("input receipt envelope is invalid") + } + identity := r + identity.Fingerprint = "" + if fingerprint(identity) != r.Fingerprint { + return fmt.Errorf("input receipt failed content identity verification") + } + identities := []struct { + field string + differs bool + }{ + {"run", r.RunID != context.RunID}, {"program", r.ProgramFingerprint != context.ProgramFingerprint}, + {"execution-program", r.ExecutionProgramFingerprint != context.ExecutionProgramFingerprint}, + {"entry", r.EntryID != context.EntryID}, {"target", r.TargetID != context.TargetID}, + {"transition", r.TransitionID != context.TransitionID}, {"parameter", r.ParameterID != contract.ID}, + {"state", r.StateRevision != context.StateRevision}, {"context", r.ContextFingerprint != context.ContextFingerprint}, + {"control-bundle", r.ControlBundleFingerprint != context.ControlBundleFingerprint}, + {"execution-scope", r.ExecutionScopeFingerprint != context.ExecutionScopeFingerprint}, + {"request", r.RequestFingerprint != requestFingerprint}, + } + for _, identity := range identities { + if identity.differs { + return fmt.Errorf("input receipt is stale because its %s identity changed", identity.field) + } + } + if !r.ExpiresAt.IsZero() && !time.Now().UTC().Before(r.ExpiresAt) { + return fmt.Errorf("input receipt is expired") + } + if !sameType(r.Type, contract.Type) || r.ValueFingerprint != digest(r.Value+"\x00"+r.SecretReference) || r.ProducerFingerprint != fingerprintProducer(producer) { + return fmt.Errorf("input receipt type, value, or producer binding changed") + } + if strings.TrimSpace(r.Actor) == "" || strings.TrimSpace(r.Host) == "" || !authorityReceiptsSatisfy(r.AuthorityReceipts, contract.Authority) || !authorityReceiptsSatisfy(r.AuthorityReceipts, authorityRequirementForRequest(producer.Request)) { + return fmt.Errorf("input receipt does not satisfy the compiled parameter authority") + } + return validateValue(contract.Type, r.Value, r.SecretReference, contract.Secret) +} + +func authorityRequirementForRequest(request *controlprogram.HostInputRequest) controlprogram.AuthorityRequirement { + if request == nil { + return controlprogram.AuthorityRequirement{} + } + return controlprogram.AuthorityRequirement{AnyOf: append([]string(nil), request.Authorities...)} +} + +func authorityReceiptsSatisfy(receipts []string, requirement controlprogram.AuthorityRequirement) bool { + provided := map[string]bool{} + for _, receipt := range receipts { + class, _, ok := strings.Cut(receipt, ":") + if ok && class != "" { + provided[class] = true + } + } + if len(requirement.AnyOf) != 0 { + found := false + for _, authority := range requirement.AnyOf { + found = found || provided[authority] + } + if !found { + return false + } + } + for _, authority := range requirement.AllOf { + if !provided[authority] { + return false + } + } + return true +} + +func inputRequestForHostBindings(contracts []controlprogram.OperatorParameter, producers map[string]controlprogram.ParameterProducer, context Context) *InputRequest { + var requested []RequestedParameter + for _, contract := range contracts { + producer, ok := producers[contract.ID] + if !ok || producer.Kind != controlprogram.ParameterSourceHostInput || producer.Request == nil { + continue + } + requested = append(requested, RequestedParameter{ + ID: contract.ID, Type: contract.Type, Description: producer.Request.Description, Secret: contract.Secret, + Authority: controlprogram.AuthorityRequirement{AnyOf: append([]string(nil), producer.Request.Authorities...)}, + }) + } + if len(requested) == 0 { + return nil + } + sort.Slice(requested, func(i, j int) bool { return requested[i].ID < requested[j].ID }) + request := InputRequest{Schema: RequestSchema, SchemaRevision: RequestSchemaRevision, Code: "TRANSITION_INPUT_REQUIRED", RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Generation: context.InputRequestGeneration, Supersession: context.InputRequestSupersession, Parameters: requested} + request.ID = "input-" + fingerprintWithoutField(request, "Fingerprint")[:24] + request.Fingerprint = fingerprintWithoutField(request, "Fingerprint") + return &request +} + +// SupersedeRequest creates the next immutable request generation after a +// semantic rejection. It never modifies the prior request or any answer +// receipt bound to it. +func SupersedeRequest(prior InputRequest, reason, actor, host string, now time.Time) (InputRequest, error) { + if err := prior.Validate(); err != nil { + return InputRequest{}, err + } + if strings.TrimSpace(reason) == "" || strings.TrimSpace(actor) == "" || strings.TrimSpace(host) == "" || now.IsZero() { + return InputRequest{}, fmt.Errorf("input request supersession requires reason, actor, host, and time") + } + next := prior + next.ID, next.Fingerprint = "", "" + next.Generation = prior.EffectiveGeneration() + 1 + next.Supersession = &InputRequestSupersession{ + PreviousRequestFingerprint: prior.Fingerprint, + Reason: strings.TrimSpace(reason), Actor: strings.TrimSpace(actor), Host: strings.TrimSpace(host), CreatedAt: now.UTC(), + } + next.ID = "input-" + fingerprintWithoutField(next, "Fingerprint")[:24] + next.Fingerprint = fingerprintWithoutField(next, "Fingerprint") + if err := next.Validate(); err != nil { + return InputRequest{}, err + } + return next, nil +} + +func SealReceipt(receipt InputReceipt) (InputReceipt, error) { + receipt.Schema, receipt.SchemaRevision = ReceiptSchema, ReceiptSchemaRevision + if receipt.ID == "" { + receipt.ID = "input-" + digest(strings.Join([]string{receipt.RunID, receipt.TransitionID, receipt.ParameterID, receipt.RequestFingerprint}, "\x00"))[:24] + } + if receipt.CreatedAt.IsZero() { + receipt.CreatedAt = time.Now().UTC() + } + receipt.ValueFingerprint = digest(receipt.Value + "\x00" + receipt.SecretReference) + identity := receipt + identity.Fingerprint = "" + receipt.Fingerprint = fingerprint(identity) + return receipt, nil +} + +// ProducerFingerprint returns the stable identity used to bind invocation +// receipts to one exact compiled producer declaration. +func ProducerFingerprint(producer controlprogram.ParameterProducer) string { + return fingerprintProducer(producer) +} + +// BindControlBundle seals already materialized parameter evidence to the exact +// active control bundle derived from those values. Producers are +// rematerialized before this step on every resolve and apply attempt. +func BindControlBundle(evidence Evidence, bundleFingerprint string) (Evidence, error) { + if len(bundleFingerprint) != 64 { + return Evidence{}, fmt.Errorf("invocation evidence requires an exact control-bundle fingerprint") + } + evidence.ControlBundleFingerprint = bundleFingerprint + evidence.InvocationFingerprint = "" + evidence.InvocationFingerprint = fingerprint(evidence) + if err := evidence.Validate(); err != nil { + return Evidence{}, err + } + return evidence, nil +} + +// ValidateAnswer checks one canonical host answer against its trusted +// parameter contract before a runtime receipt can be recorded. +func ValidateAnswer(contract controlprogram.OperatorParameter, value, secretReference string) error { + return validateValue(contract.Type, value, secretReference, contract.Secret) +} + +func validateValue(valueType controlprogram.ValueTypeDefinition, value, secretReference string, secret bool) error { + if secret { + if value != "" || secretReference == "" { + return fmt.Errorf("secret values require only an opaque runtime reference") + } + return nil + } + if secretReference != "" || value == "" { + return fmt.Errorf("non-secret values require canonical plaintext") + } + switch valueType.Kind { + case "string": + return nil + case "boolean": + if value != "true" && value != "false" { + return fmt.Errorf("expected boolean") + } + case "integer": + integer, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("expected integer") + } + if valueType.Minimum != nil && integer < *valueType.Minimum { + return fmt.Errorf("integer is below minimum") + } + if valueType.Maximum != nil && integer > *valueType.Maximum { + return fmt.Errorf("integer exceeds maximum") + } + case "json": + if !json.Valid([]byte(value)) { + return fmt.Errorf("expected canonical JSON") + } + default: + return fmt.Errorf("unknown value type %q", valueType.Kind) + } + return nil +} + +func sameType(left, right controlprogram.ValueTypeDefinition) bool { + leftRaw, _ := json.Marshal(left) + rightRaw, _ := json.Marshal(right) + return string(leftRaw) == string(rightRaw) +} +func fingerprintProducer(value controlprogram.ParameterProducer) string { return fingerprint(value) } +func fingerprint(value any) string { raw, _ := json.Marshal(value); return digest(string(raw)) } +func fingerprintWithoutField(value any, _ string) string { return fingerprint(value) } +func digest(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/boatstack/invocation/invocation_test.go b/boatstack/invocation/invocation_test.go new file mode 100644 index 0000000..964990f --- /dev/null +++ b/boatstack/invocation/invocation_test.go @@ -0,0 +1,234 @@ +package invocation + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" +) + +type testRuntimeStore struct{} + +func (testRuntimeStore) EnsureDirectory(path string, mode uint32) error { + return os.MkdirAll(path, os.FileMode(mode)) +} + +func (testRuntimeStore) WriteAtomic(path string, raw []byte, mode uint32) error { + return os.WriteFile(path, raw, os.FileMode(mode)) +} + +func testContext() Context { + return Context{RunID: "run-one", ProgramFingerprint: strings.Repeat("a", 64), ExecutionProgramFingerprint: strings.Repeat("e", 64), EntryID: "run", TargetID: "mitigated", TransitionID: "respond", StateRevision: 12, ContextFingerprint: strings.Repeat("b", 64), ControlBundleFingerprint: strings.Repeat("c", 64), ExecutionScopeFingerprint: strings.Repeat("d", 64), InputReceipts: map[string]InputReceipt{}} +} + +func testContract() controlprogram.OperatorParameter { + return controlprogram.OperatorParameter{ID: "channel", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceHostInput}, Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"human"}}} +} + +func testProducer() controlprogram.ParameterProducer { + return controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ID: "channel", Description: "Select the response channel.", Authorities: []string{"human"}, Scope: "transition"}} +} + +func TestHostInputSuspendsAndSameRunReceiptResumes(t *testing.T) { + // control-law: declared-missing-host-input-suspends-without-guessing-and-resumes-only-the-bound-run + context := testContext() + bindings := []controlprogram.TransitionParameterBinding{{Parameter: "channel", Producer: testProducer()}} + result, err := Materialize([]controlprogram.OperatorParameter{testContract()}, bindings, context, nil) + if err != nil || result.Request == nil || result.Request.Code != "TRANSITION_INPUT_REQUIRED" || result.Ready != nil { + t.Fatalf("suspension = %#v, %v", result, err) + } + receipt, err := SealReceipt(InputReceipt{RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, ParameterID: "channel", Type: testContract().Type, Value: "pager", ProducerFingerprint: fingerprintProducer(testProducer()), RequestFingerprint: result.Request.Fingerprint, StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Actor: "operator", Host: "codex", AuthorityReceipts: []string{"human:operator"}, CreatedAt: time.Now().UTC(), Scope: "transition"}) + if err != nil { + t.Fatal(err) + } + context.InputReceipts["channel@"+result.Request.Fingerprint] = receipt + resumed, err := Materialize([]controlprogram.OperatorParameter{testContract()}, bindings, context, nil) + if err != nil || resumed.Ready == nil || resumed.Ready.Parameters[0].Value != "pager" || resumed.Request != nil { + t.Fatalf("resumed = %#v, %v", resumed, err) + } + changed := context + changed.RunID = "run-other" + replayed, err := Materialize([]controlprogram.OperatorParameter{testContract()}, bindings, changed, nil) + if err != nil || replayed.Request == nil || replayed.Ready != nil { + t.Fatalf("cross-run replay = %#v, %v", replayed, err) + } +} + +func TestInvocationFingerprintChangesWithProducerOrContext(t *testing.T) { + contract := testContract() + contract.Authority = controlprogram.AuthorityRequirement{} + contract.AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceEntryInput} + binding := controlprogram.TransitionParameterBinding{Parameter: "channel", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "channel"}} + context := testContext() + context.EntryInputs = map[string]Value{"channel": {Type: contract.Type, Canonical: "pager", Provenance: "entry"}} + one, _ := Materialize([]controlprogram.OperatorParameter{contract}, []controlprogram.TransitionParameterBinding{binding}, context, nil) + context.ContextFingerprint = strings.Repeat("d", 64) + two, _ := Materialize([]controlprogram.OperatorParameter{contract}, []controlprogram.TransitionParameterBinding{binding}, context, nil) + if one.Ready == nil || two.Ready == nil || one.Ready.InvocationFingerprint == two.Ready.InvocationFingerprint { + t.Fatal("context drift preserved invocation fingerprint") + } + context.ContextFingerprint = strings.Repeat("b", 64) + context.ExecutionProgramFingerprint = strings.Repeat("f", 64) + three, _ := Materialize([]controlprogram.OperatorParameter{contract}, []controlprogram.TransitionParameterBinding{binding}, context, nil) + if three.Ready == nil || one.Ready.InvocationFingerprint == three.Ready.InvocationFingerprint { + t.Fatal("executable program drift preserved invocation fingerprint") + } +} + +func TestZeroParameterTransitionStillProducesInvocationEvidence(t *testing.T) { + // control-law: zero-runtime-parameters-do-not-mean-zero-transition-invocation + result, err := Materialize(nil, nil, testContext(), nil) + if err != nil { + t.Fatal(err) + } + if result.Ready == nil || result.Ready.InvocationFingerprint == "" || result.Request != nil || result.Blocker != nil { + t.Fatalf("zero-parameter invocation = %#v", result) + } + if result.Ready.Parameters != nil { + t.Fatalf("zero-parameter invocation exposed parameters: %#v", result.Ready.Parameters) + } +} + +func TestStateOrReceiptMaterializesEitherExactAlternative(t *testing.T) { + // control-law: one canonical producer can explicitly cover normal receipt + // output and recovery-established durable state without inferring either. + contract := testContract() + contract.Authority = controlprogram.AuthorityRequirement{} + contract.AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceStateOrReceipt} + producer := controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceStateOrReceipt, Facet: "channel", + AvailableWhen: &controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: "channel", Statuses: []string{"known"}}}, + Transition: "observe-channel", Field: "channel", + } + binding := []controlprogram.TransitionParameterBinding{{Parameter: "channel", Producer: producer}} + + receiptContext := testContext() + receiptContext.Receipts = map[string]Value{"observe-channel/channel": {Type: contract.Type, Canonical: "pager", Provenance: "transition-receipt:receipt-channel"}} + receiptResult, err := Materialize([]controlprogram.OperatorParameter{contract}, binding, receiptContext, nil) + if err != nil || receiptResult.Ready == nil || receiptResult.Ready.Parameters[0].Value != "pager" { + t.Fatalf("receipt alternative = %#v, %v", receiptResult, err) + } + + stateContext := testContext() + stateContext.State = map[string]Value{"channel": {Type: contract.Type, Canonical: "chat", Provenance: "durable-state:channel"}} + stateResult, err := Materialize([]controlprogram.OperatorParameter{contract}, binding, stateContext, nil) + if err != nil || stateResult.Ready == nil || stateResult.Ready.Parameters[0].Value != "chat" { + t.Fatalf("state alternative = %#v, %v", stateResult, err) + } + + missing, err := Materialize([]controlprogram.OperatorParameter{contract}, binding, testContext(), nil) + if err != nil || missing.Blocker == nil || missing.Blocker.Code != "TRANSITION_INPUT_UNAVAILABLE" { + t.Fatalf("missing alternatives = %#v, %v", missing, err) + } +} + +func TestInputReceiptRejectsCrossScopeExpiryAndControlDrift(t *testing.T) { + context := testContext() + binding := []controlprogram.TransitionParameterBinding{{Parameter: "channel", Producer: testProducer()}} + suspended, err := Materialize([]controlprogram.OperatorParameter{testContract()}, binding, context, nil) + if err != nil || suspended.Request == nil { + t.Fatalf("suspension = %#v, %v", suspended, err) + } + base, err := SealReceipt(InputReceipt{ + RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, EntryID: context.EntryID, TargetID: context.TargetID, + TransitionID: context.TransitionID, ParameterID: "channel", Type: testContract().Type, Value: "pager", + ProducerFingerprint: fingerprintProducer(testProducer()), RequestFingerprint: suspended.Request.Fingerprint, + StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, + ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, + Actor: "operator", Host: "codex", AuthorityReceipts: []string{"human:operator"}, Scope: "transition", + }) + if err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(*Context){ + "entry": func(value *Context) { value.EntryID = "alternate" }, + "execution-program": func(value *Context) { value.ExecutionProgramFingerprint = strings.Repeat("f", 64) }, + "target": func(value *Context) { value.TargetID = "alternate" }, + "transition": func(value *Context) { value.TransitionID = "alternate" }, + "state": func(value *Context) { value.StateRevision++ }, + "context": func(value *Context) { value.ContextFingerprint = strings.Repeat("d", 64) }, + "control-bundle": func(value *Context) { value.ControlBundleFingerprint = strings.Repeat("e", 64) }, + "execution-scope": func(value *Context) { value.ExecutionScopeFingerprint = strings.Repeat("f", 64) }, + } { + t.Run(name, func(t *testing.T) { + changed := context + changed.InputReceipts = map[string]InputReceipt{"channel@" + suspended.Request.Fingerprint: base} + mutate(&changed) + result, materializeErr := Materialize([]controlprogram.OperatorParameter{testContract()}, binding, changed, nil) + if materializeErr != nil || result.Request == nil || result.Ready != nil { + t.Fatalf("cross-scope result = %#v, %v", result, materializeErr) + } + }) + } + expired := base + expired.ExpiresAt = time.Now().UTC().Add(-time.Minute) + expired.Fingerprint = "" + expired, _ = SealReceipt(expired) + context.InputReceipts = map[string]InputReceipt{"channel@" + suspended.Request.Fingerprint: expired} + result, err := Materialize([]controlprogram.OperatorParameter{testContract()}, binding, context, nil) + if err != nil || result.Blocker == nil || !strings.Contains(result.Blocker.Detail, "expired") { + t.Fatalf("expired result = %#v, %v", result, err) + } +} + +func TestInputReceiptCannotClaimUnrecordedParameterAuthority(t *testing.T) { + context := testContext() + binding := []controlprogram.TransitionParameterBinding{{Parameter: "channel", Producer: testProducer()}} + suspended, err := Materialize([]controlprogram.OperatorParameter{testContract()}, binding, context, nil) + if err != nil || suspended.Request == nil { + t.Fatalf("suspension = %#v, %v", suspended, err) + } + receipt, err := SealReceipt(InputReceipt{ + RunID: context.RunID, ProgramFingerprint: context.ProgramFingerprint, ExecutionProgramFingerprint: context.ExecutionProgramFingerprint, + EntryID: context.EntryID, TargetID: context.TargetID, TransitionID: context.TransitionID, ParameterID: "channel", + Type: testContract().Type, Value: "pager", ProducerFingerprint: fingerprintProducer(testProducer()), RequestFingerprint: suspended.Request.Fingerprint, + StateRevision: context.StateRevision, ContextFingerprint: context.ContextFingerprint, ControlBundleFingerprint: context.ControlBundleFingerprint, + ExecutionScopeFingerprint: context.ExecutionScopeFingerprint, Actor: "automation", Host: "codex", + AuthorityReceipts: []string{"autonomy:automation"}, Scope: "transition", + }) + if err != nil { + t.Fatal(err) + } + context.InputReceipts["channel@"+suspended.Request.Fingerprint] = receipt + result, err := Materialize([]controlprogram.OperatorParameter{testContract()}, binding, context, nil) + if err != nil || result.Blocker == nil || !strings.Contains(result.Blocker.Detail, "parameter authority") { + t.Fatalf("wrong-authority input receipt = %#v, %v", result, err) + } +} + +func TestEvidenceValidationRejectsNonCanonicalParameters(t *testing.T) { + context := testContext() + contract := testContract() + contract.Authority = controlprogram.AuthorityRequirement{} + contract.AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceEntryInput} + context.EntryInputs = map[string]Value{"channel": {Type: contract.Type, Canonical: "pager", Provenance: "entry"}} + result, err := Materialize([]controlprogram.OperatorParameter{contract}, []controlprogram.TransitionParameterBinding{{Parameter: "channel", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "channel"}}}, context, nil) + if err != nil || result.Ready == nil || result.Ready.Validate() != nil { + t.Fatalf("valid evidence = %#v, %v", result, err) + } + changed := *result.Ready + changed.Parameters[0].ValueFingerprint = strings.Repeat("f", 64) + changed.InvocationFingerprint = fingerprintWithoutField(changed, "InvocationFingerprint") + if err := changed.Validate(); err == nil || !strings.Contains(err.Error(), "value identity") { + t.Fatalf("noncanonical parameter result = %v", err) + } +} + +func TestStoreIsIdempotentAndRejectsConflictingAnswers(t *testing.T) { + store := Store{Root: t.TempDir(), Writer: testRuntimeStore{}} + receipt, _ := SealReceipt(InputReceipt{RunID: "run", ProgramFingerprint: strings.Repeat("a", 64), ExecutionProgramFingerprint: strings.Repeat("f", 64), EntryID: "run", TargetID: "target", TransitionID: "respond", ParameterID: "channel", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Value: "pager", ValueFingerprint: digest("pager\x00"), ProducerFingerprint: strings.Repeat("b", 64), RequestFingerprint: strings.Repeat("c", 64), StateRevision: 1, ContextFingerprint: strings.Repeat("d", 64), ExecutionScopeFingerprint: strings.Repeat("e", 64), Actor: "operator", Host: "codex", Scope: "transition"}) + if err := store.SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + if err := store.SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + conflict := receipt + conflict.Value = "chat" + conflict, _ = SealReceipt(conflict) + if err := store.SaveReceipt(conflict); err == nil || !strings.Contains(err.Error(), "conflicting") { + t.Fatalf("conflicting answer result = %v", err) + } +} diff --git a/boatstack/invocation/store.go b/boatstack/invocation/store.go new file mode 100644 index 0000000..a7f827c --- /dev/null +++ b/boatstack/invocation/store.go @@ -0,0 +1,303 @@ +package invocation + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +type RuntimeStore interface { + EnsureDirectory(string, uint32) error + WriteAtomic(string, []byte, uint32) error +} + +type Store struct { + Root string + Writer RuntimeStore +} + +func (s Store) RequestPath(runID, transitionID, requestFingerprint string) (string, error) { + if err := validSegment(runID); err != nil { + return "", err + } + if err := validSegment(transitionID); err != nil { + return "", err + } + if len(requestFingerprint) != 64 { + return "", fmt.Errorf("input request fingerprint is invalid") + } + return filepath.Join(s.Root, "inputs", runID, transitionID, requestFingerprint+".request.json"), nil +} + +func (s Store) ReceiptPath(runID, transitionID, requestFingerprint, parameterID string) (string, error) { + if err := validSegment(runID); err != nil { + return "", err + } + if err := validSegment(transitionID); err != nil { + return "", err + } + if err := validSegment(parameterID); err != nil { + return "", err + } + if len(requestFingerprint) != 64 { + return "", fmt.Errorf("input request fingerprint is invalid") + } + return filepath.Join(s.Root, "inputs", runID, transitionID, requestFingerprint, parameterID+".receipt.json"), nil +} + +func (s Store) SaveRequest(request InputRequest) error { + path, err := s.RequestPath(request.RunID, request.TransitionID, request.Fingerprint) + if err != nil { + return err + } + raw, err := json.MarshalIndent(request, "", " ") + if err != nil { + return err + } + return s.writeIdempotent(path, append(raw, '\n')) +} + +func (s Store) LoadRequest(runID, transitionID, requestFingerprint string) (InputRequest, error) { + path, err := s.RequestPath(runID, transitionID, requestFingerprint) + if err != nil { + return InputRequest{}, err + } + var request InputRequest + if err := decodeStrict(path, &request); err != nil { + return InputRequest{}, err + } + if err := request.Validate(); err != nil { + return InputRequest{}, err + } + return request, nil +} + +func (s Store) FindRequest(runID, requestFingerprint string) (InputRequest, error) { + if err := validSegment(runID); err != nil { + return InputRequest{}, err + } + if len(requestFingerprint) != 64 { + return InputRequest{}, fmt.Errorf("input request fingerprint is invalid") + } + root := filepath.Join(s.Root, "inputs", runID) + var found *InputRequest + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".request.json") { + return nil + } + var request InputRequest + if err := decodeStrict(path, &request); err != nil { + return err + } + if request.Fingerprint == requestFingerprint { + if found != nil { + return fmt.Errorf("input request fingerprint is ambiguous") + } + copy := request + found = © + } + return nil + }) + if err != nil { + return InputRequest{}, err + } + if found == nil { + return InputRequest{}, fmt.Errorf("input request %s was not found for run %s", requestFingerprint, runID) + } + if err := found.Validate(); err != nil { + return InputRequest{}, err + } + return *found, nil +} + +// LatestRequest returns the current immutable request generation for one exact +// invocation context and verifies the complete supersession chain. +func (s Store) LatestRequest(context Context) (InputRequest, bool, error) { + if err := validSegment(context.RunID); err != nil { + return InputRequest{}, false, err + } + if err := validSegment(context.TransitionID); err != nil { + return InputRequest{}, false, err + } + root := filepath.Join(s.Root, "inputs", context.RunID, context.TransitionID) + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return InputRequest{}, false, nil + } + if err != nil { + return InputRequest{}, false, err + } + byGeneration := map[uint64]InputRequest{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".request.json") { + continue + } + var request InputRequest + if err := decodeStrict(filepath.Join(root, entry.Name()), &request); err != nil { + return InputRequest{}, false, err + } + if err := request.Validate(); err != nil { + return InputRequest{}, false, err + } + if !requestMatchesContext(request, context) { + continue + } + generation := request.EffectiveGeneration() + if prior, exists := byGeneration[generation]; exists && prior.Fingerprint != request.Fingerprint { + return InputRequest{}, false, fmt.Errorf("input request generation %d is ambiguous", generation) + } + byGeneration[generation] = request + } + if len(byGeneration) == 0 { + return InputRequest{}, false, nil + } + var latest InputRequest + for generation := uint64(1); generation <= uint64(len(byGeneration)); generation++ { + request, exists := byGeneration[generation] + if !exists { + return InputRequest{}, false, fmt.Errorf("input request supersession chain skips generation %d", generation) + } + if generation > 1 && (request.Supersession == nil || request.Supersession.PreviousRequestFingerprint != latest.Fingerprint) { + return InputRequest{}, false, fmt.Errorf("input request supersession chain is invalid at generation %d", generation) + } + latest = request + } + return latest, true, nil +} + +func requestMatchesContext(request InputRequest, context Context) bool { + return request.RunID == context.RunID && request.ProgramFingerprint == context.ProgramFingerprint && + request.ExecutionProgramFingerprint == context.ExecutionProgramFingerprint && request.EntryID == context.EntryID && + request.TargetID == context.TargetID && request.TransitionID == context.TransitionID && + request.StateRevision == context.StateRevision && request.ContextFingerprint == context.ContextFingerprint && + request.ControlBundleFingerprint == context.ControlBundleFingerprint && request.ExecutionScopeFingerprint == context.ExecutionScopeFingerprint +} + +func (s Store) SaveReceipt(receipt InputReceipt) error { + path, err := s.ReceiptPath(receipt.RunID, receipt.TransitionID, receipt.RequestFingerprint, receipt.ParameterID) + if err != nil { + return err + } + raw, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + return err + } + return s.writeIdempotent(path, append(raw, '\n')) +} + +func (s Store) LoadReceipts(runID, transitionID string) (map[string]InputReceipt, error) { + if err := validSegment(runID); err != nil { + return nil, err + } + if err := validSegment(transitionID); err != nil { + return nil, err + } + base := filepath.Join(s.Root, "inputs", runID, transitionID) + entries, err := os.ReadDir(base) + if os.IsNotExist(err) { + return map[string]InputReceipt{}, nil + } + if err != nil { + return nil, err + } + result := map[string]InputReceipt{} + for _, requestDirectory := range entries { + if !requestDirectory.IsDir() || len(requestDirectory.Name()) != 64 { + continue + } + receipts, readErr := os.ReadDir(filepath.Join(base, requestDirectory.Name())) + if readErr != nil { + return nil, readErr + } + for _, entry := range receipts { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".receipt.json") { + continue + } + var receipt InputReceipt + if err := decodeStrict(filepath.Join(base, requestDirectory.Name(), entry.Name()), &receipt); err != nil { + return nil, err + } + result[receipt.ParameterID+"@"+receipt.RequestFingerprint] = receipt + } + } + return result, nil +} + +func (s Store) List(runID string) ([]InputReceipt, error) { + if err := validSegment(runID); err != nil { + return nil, err + } + root := filepath.Join(s.Root, "inputs", runID) + var result []InputReceipt + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if os.IsNotExist(walkErr) { + return nil + } + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".receipt.json") { + return nil + } + var receipt InputReceipt + if err := decodeStrict(path, &receipt); err != nil { + return err + } + result = append(result, receipt) + return nil + }) + if os.IsNotExist(err) { + return nil, nil + } + sort.Slice(result, func(i, j int) bool { return result[i].ParameterID < result[j].ParameterID }) + return result, err +} + +func decodeStrict(path string, target any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("record contains trailing JSON") + } + return nil +} + +func (s Store) writeIdempotent(path string, raw []byte) error { + if prior, err := os.ReadFile(path); err == nil { + if bytes.Equal(prior, raw) { + return nil + } + return fmt.Errorf("conflicting input record already exists at %s", path) + } else if !os.IsNotExist(err) { + return err + } + if s.Writer == nil { + return fmt.Errorf("input store requires an effects-owned runtime writer") + } + if err := s.Writer.EnsureDirectory(filepath.Dir(path), 0o700); err != nil { + return err + } + return s.Writer.WriteAtomic(path, raw, 0o600) +} + +func validSegment(value string) error { + if value == "" || filepath.Base(value) != value || value == "." || value == ".." || strings.ContainsAny(value, `/\\`) { + return fmt.Errorf("invalid input record identity %q", value) + } + return nil +} diff --git a/boatstack/testdata/control-programs/incident-response-invocation-missing.flow.ts b/boatstack/testdata/control-programs/incident-response-invocation-missing.flow.ts new file mode 100644 index 0000000..c4cf60c --- /dev/null +++ b/boatstack/testdata/control-programs/incident-response-invocation-missing.flow.ts @@ -0,0 +1,33 @@ +import { + defineFlow, + entry, + evidence, + fact, + facet, + fromEntryInput, + marked, + operator, + transition, +} from "@operatorstack/boatstack"; + +export default defineFlow({ + id: "incident-response-invocation-missing", + version: "1", + declarations: { capabilities: ["service.restart"], authorities: ["human", "incident-commander"], effects: ["service.restart"], verifiers: ["healthcheck"], input_resolvers: ["incident-input"] }, + facets: [facet("incident", "enum", ["open", "mitigated"]), facet("service", "enum", ["degraded", "healthy"])], + evidence: [evidence("healthcheck", "service", "observation")], + operators: [operator("restart", { + capabilities: ["service.restart"], authority: { any_of: ["incident-commander"] }, effects: ["service.restart"], verifier: "healthcheck", recovery: "restart", execution_context: "preserve", + parameters: [ + { id: "incident", type: { kind: "string" }, required: true, secret: false, allowed_sources: ["entry-input"], authority: {} }, + { id: "channel", type: { kind: "string" }, required: true, secret: false, allowed_sources: ["host-input"], authority: { any_of: ["human"] } }, + ], + state_effect: { kind: "assignments", assignments: [{ facet: "incident", value: "mitigated" }] }, + })], + transitions: [transition("restart", "restart", { + guard: fact("incident", ["open"]), target: fact("incident", ["mitigated"]), priority: 10, + parameters: [{ parameter: "incident", producer: fromEntryInput("incident") }], + })], + targets: [marked("mitigated", fact("incident", ["mitigated"]))], + entries: [entry({ id: "respond", target: "mitigated", inputs: [{ id: "incident", type: "text", required: true, resolver: "incident-input" }] })], +}); diff --git a/boatstack/testdata/control-programs/incident-response-invocation.flow.ts b/boatstack/testdata/control-programs/incident-response-invocation.flow.ts new file mode 100644 index 0000000..1e49803 --- /dev/null +++ b/boatstack/testdata/control-programs/incident-response-invocation.flow.ts @@ -0,0 +1,51 @@ +import { + defineFlow, + entry, + evidence, + fact, + facet, + fromEntryInput, + hostParameter, + always, + marked, + operator, + transition, +} from "@operatorstack/boatstack"; + +export default defineFlow({ + id: "incident-response-invocation", + version: "1", + declarations: { + authorities: ["human"], + verifiers: ["state-effect"], + input_resolvers: ["incident-input"], + }, + facets: [ + facet("incident", "enum", ["open", "mitigated"]), + facet("service", "enum", ["degraded", "healthy"]), + ], + evidence: [evidence("state-effect", "incident", "state-observation")], + operators: [operator("restart", { + capabilities: [], + authority: { any_of: ["human"] }, + effects: [], + verifier: "state-effect", + execution_context: "preserve", + parameters: [ + { id: "incident", type: { kind: "string" }, required: true, secret: false, allowed_sources: ["entry-input"], authority: {} }, + { id: "channel", type: { kind: "string" }, required: true, secret: false, allowed_sources: ["host-input"], authority: { any_of: ["human"] } }, + ], + state_effect: { kind: "assignments", assignments: [{ facet: "incident", value: "mitigated" }] }, + })], + transitions: [transition("restart", "restart", { + guard: always, + target: fact("incident", ["mitigated"]), + priority: 10, + parameters: [ + { parameter: "incident", producer: fromEntryInput("incident") }, + { parameter: "channel", producer: hostParameter({ id: "channel", description: "Select the response channel.", authorities: ["human"], scope: "transition" }) }, + ], + })], + targets: [marked("mitigated", fact("incident", ["mitigated"]))], + entries: [entry({ id: "respond", target: "mitigated", inputs: [{ id: "incident", type: "text", required: true, resolver: "incident-input" }] })], +}); diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 0827b3a..0ddf31f 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,6 +1,6 @@ { "schema": "control-program", - "schema_revision": 3, + "schema_revision": 4, "program": { "id": "incident-response", "version": "1" diff --git a/boatstack/testdata/control-programs/product-delivery-a.flow.ts b/boatstack/testdata/control-programs/product-delivery-a.flow.ts index 5c4978b..d3e986b 100644 --- a/boatstack/testdata/control-programs/product-delivery-a.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-a.flow.ts @@ -1,4 +1,4 @@ -import { all, defineFlow, entry, fact, marked } from "@operatorstack/boatstack"; +import { all, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, @@ -6,7 +6,7 @@ import { softwareDeliveryFacets, trustedOperators, trustedDelegation, - trustedTransitions, + trustedTransition, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -21,7 +21,9 @@ export default defineFlow({ facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, operators: trustedOperators(lifecycle), - transitions: trustedTransitions(lifecycle), + transitions: [trustedTransition({ id: "publication.observe", priority: 77 }, { + parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: fact("publication_id") }) }, + })], targets: [marked("published-pr", all( fact("verification", ["current"]), fact("configuration", ["verified"]), diff --git a/boatstack/testdata/control-programs/product-delivery-b.flow.ts b/boatstack/testdata/control-programs/product-delivery-b.flow.ts index 91cd646..edffab4 100644 --- a/boatstack/testdata/control-programs/product-delivery-b.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-b.flow.ts @@ -1,11 +1,11 @@ -import { all, defineFlow, entry, fact, marked } from "@operatorstack/boatstack"; +import { all, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, softwareDeliveryEvidence, softwareDeliveryFacets, trustedOperators, - trustedTransitions, + trustedTransition, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -21,7 +21,12 @@ export default defineFlow({ facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, operators: trustedOperators(lifecycle), - transitions: trustedTransitions(lifecycle), + transitions: [ + trustedTransition({ id: "publication.observe", priority: 77 }, { + parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: fact("publication_id") }) }, + }), + trustedTransition({ id: "plan.abandon", priority: 31 }), + ], targets: [ marked("published-pr", all( fact("verification", ["current"]), diff --git a/boatstack/testdata/control-programs/product-delivery-c.flow.ts b/boatstack/testdata/control-programs/product-delivery-c.flow.ts index 438db2f..e96fe19 100644 --- a/boatstack/testdata/control-programs/product-delivery-c.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-c.flow.ts @@ -5,7 +5,7 @@ import { softwareDeliveryEvidence, softwareDeliveryFacets, trustedOperators, - trustedTransitions, + trustedSoftwareDeliveryTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -25,7 +25,7 @@ export default defineFlow({ facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, operators: trustedOperators(lifecycle), - transitions: trustedTransitions(lifecycle), + transitions: trustedSoftwareDeliveryTransitions(lifecycle), targets: [marked("published-pr", all( fact("verification", ["current"]), fact("configuration", ["verified"]), diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts index 5864148..74f982e 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -20,7 +20,7 @@ import { softwareDeliveryFacets, trustedDelegation, trustedOperators, - trustedTransition, + trustedSoftwareDeliveryTransitions, } from "@operatorstack/boatstack-software-delivery"; const planning = foregroundWork({ @@ -45,6 +45,7 @@ const lifecycle = [ planningPackageAdmit, planningPackageApprove, planningPackagePromote, + { id: "plan.abandon", priority: 31 }, { id: "plan.activate", priority: 50 }, { id: "workspace.cut", priority: 52 }, { id: "workspace.activate", priority: 53 }, @@ -66,46 +67,37 @@ const lifecycle = [ ]; export default defineFlow({ - id: "product-delivery-planning-package", + id: "product-delivery", version: "1", declarations: { input_resolvers: [planInboxResolver] }, facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, work: [planning], operators: trustedOperators(lifecycle), - transitions: [ - trustedTransition(planningPackageAdmit, { work: planning }), - trustedTransition(planningPackageApprove), - trustedTransition(planningPackagePromote), - trustedTransition({ id: "plan.activate", priority: 50 }), - trustedTransition({ id: "workspace.cut", priority: 52 }), - trustedTransition({ id: "workspace.activate", priority: 53 }), - trustedTransition({ id: "workspace.sync", priority: 58 }), - trustedTransition({ id: "gate.build.record", priority: 61 }), - trustedTransition({ id: "gate.test.record", priority: 62 }), - trustedTransition({ id: "gate.review.record", priority: 63 }), - trustedTransition({ id: "gate.change.record", priority: 64 }), - trustedTransition({ id: "gate.journey.record", priority: 64 }), - trustedTransition({ id: "evidence.visual.attach", priority: 66 }), - trustedTransition({ id: "delivery.slice.advance", priority: 68 }), - trustedTransition({ id: "publication.preview", priority: 72 }), - trustedTransition({ id: "workspace.publish", priority: 75 }), - trustedTransition({ id: "publication.execute", priority: 76 }), - trustedTransition({ id: "publication.observe", priority: 77 }), - trustedTransition({ id: "publication.correct", priority: 80 }), - trustedTransition({ id: "workspace.reconcile", priority: 2 }), - trustedTransition({ id: "publication.reconcile", priority: 1 }), + transitions: trustedSoftwareDeliveryTransitions(lifecycle, { planningPackageWork: planning }), + targets: [ + marked("published-pr", all( + fact("verification", ["current"]), + fact("configuration", ["verified"]), + fact("runtime", ["verified"]), + fact("publication", ["open"]), + )), + marked("safely-abandoned", all( + fact("delivery", ["discarded"]), + fact("workspace", ["abandoned", "absent"]), + )), + ], + entries: [ + entry({ + id: "run", + target: "published-pr", + inputs: [inbox(".boatstack/plans/inbox")], + delegation: trustedDelegation("autonomy"), + }), + entry({ + id: "abandon", + target: "safely-abandoned", + inputs: [inbox(".boatstack/plans/inbox")], + }), ], - targets: [marked("published-pr", all( - fact("verification", ["current"]), - fact("configuration", ["verified"]), - fact("runtime", ["verified"]), - fact("publication", ["open"]), - ))], - entries: [entry({ - id: "run", - target: "published-pr", - inputs: [inbox(".boatstack/plans/inbox")], - delegation: trustedDelegation("autonomy"), - })], }); diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json new file mode 100644 index 0000000..fae7581 --- /dev/null +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -0,0 +1 @@ +{"schema":"control-program","schema_revision":4,"program":{"id":"product-delivery","version":"1"},"declarations":{"input_resolvers":["software-delivery.plan-inbox"]},"facets":[{"id":"phase","kind":"string"},{"id":"program","kind":"string"},{"id":"engagement","kind":"string"},{"id":"objective","kind":"string"},{"id":"delivery","kind":"string"},{"id":"workspace","kind":"string"},{"id":"plan","kind":"string"},{"id":"configuration","kind":"string"},{"id":"configuration-policy","kind":"string"},{"id":"runtime","kind":"string"},{"id":"publication","kind":"string"},{"id":"verification","kind":"string"},{"id":"recovery","kind":"string"},{"id":"recovery-info","kind":"string"},{"id":"transaction","kind":"string"},{"id":"terminal","kind":"string"},{"id":"recovery_budget","kind":"string"},{"id":"recovery_cause","kind":"string"},{"id":"recovery_resumption","kind":"string"},{"id":"recovery_source_phase","kind":"string"},{"id":"source_revision","kind":"string"},{"id":"preview_fingerprint","kind":"string"},{"id":"publication_id","kind":"string"},{"id":"recovery_transaction_id","kind":"string"},{"id":"transaction_id","kind":"string"},{"id":"transaction_transition","kind":"string"},{"id":"workspace_base_ref","kind":"string"},{"id":"workspace_branch","kind":"string"},{"id":"workspace_path","kind":"string"},{"id":"workspace_source_id","kind":"string"},{"id":"workspace_source_path","kind":"string"},{"id":"workspace_source_ref","kind":"string"},{"id":"worktree_fingerprint","kind":"string"}],"evidence":[{"id":"plan-evidence","subject":"plan","kind":"artifact"},{"id":"publication-evidence","subject":"publication","kind":"provider-observation"}],"work":[{"id":"planning-package","instructions":{"path":"boatstack/testdata/control-programs/assets/planning-package.md"},"inputs":[{"id":"plan","entry_input":"plan"}],"outputs":[{"id":"plan","path":"plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"feature-spec","path":"feature-spec.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"questions","path":"questions.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"test-plan","path":"test-plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"gaps","path":"gaps.md","media_type":"text/markdown","required":false,"max_bytes":131072},{"id":"autonomy","path":"autonomy.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"tasks","path":"compiled/tasks.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"test-matrix","path":"compiled/test-matrix.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"journey-oracles","path":"compiled/journey-oracles.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"evidence","path":"compiled/evidence.md","media_type":"text/markdown","required":true,"max_bytes":131072}]}],"operators":[{"id":"planning.package.admit","binding":{"reference":"software-delivery/planning.package.admit","version":"1"}},{"id":"planning.package.approve","binding":{"reference":"software-delivery/planning.package.approve","version":"1"}},{"id":"planning.package.promote","binding":{"reference":"software-delivery/planning.package.promote","version":"1"}},{"id":"plan.abandon","binding":{"reference":"software-delivery/plan.abandon","version":"1"}},{"id":"plan.activate","binding":{"reference":"software-delivery/plan.activate","version":"1"}},{"id":"workspace.cut","binding":{"reference":"software-delivery/workspace.cut","version":"1"}},{"id":"workspace.activate","binding":{"reference":"software-delivery/workspace.activate","version":"1"}},{"id":"workspace.sync","binding":{"reference":"software-delivery/workspace.sync","version":"1"}},{"id":"gate.build.record","binding":{"reference":"software-delivery/gate.build.record","version":"1"}},{"id":"gate.test.record","binding":{"reference":"software-delivery/gate.test.record","version":"1"}},{"id":"gate.review.record","binding":{"reference":"software-delivery/gate.review.record","version":"1"}},{"id":"gate.change.record","binding":{"reference":"software-delivery/gate.change.record","version":"1"}},{"id":"gate.journey.record","binding":{"reference":"software-delivery/gate.journey.record","version":"1"}},{"id":"evidence.visual.attach","binding":{"reference":"software-delivery/evidence.visual.attach","version":"1"}},{"id":"delivery.slice.advance","binding":{"reference":"software-delivery/delivery.slice.advance","version":"1"}},{"id":"publication.preview","binding":{"reference":"software-delivery/publication.preview","version":"1"}},{"id":"workspace.publish","binding":{"reference":"software-delivery/workspace.publish","version":"1"}},{"id":"publication.execute","binding":{"reference":"software-delivery/publication.execute","version":"1"}},{"id":"publication.observe","binding":{"reference":"software-delivery/publication.observe","version":"1"}},{"id":"publication.correct","binding":{"reference":"software-delivery/publication.correct","version":"1"}},{"id":"workspace.reconcile","binding":{"reference":"software-delivery/workspace.reconcile","version":"1"}},{"id":"publication.reconcile","binding":{"reference":"software-delivery/publication.reconcile","version":"1"}}],"transitions":[{"id":"planning.package.admit","operator":"planning.package.admit","guard":{"true":true},"target":{"true":true},"priority":43,"work":"planning-package"},{"id":"planning.package.approve","operator":"planning.package.approve","guard":{"true":true},"target":{"true":true},"priority":44,"parameters":[{"parameter":"package_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/admitted-planning-package-fingerprint","version":"1"}}}]},{"id":"planning.package.promote","operator":"planning.package.promote","guard":{"true":true},"target":{"true":true},"priority":45},{"id":"plan.abandon","operator":"plan.abandon","guard":{"true":true},"target":{"true":true},"priority":31},{"id":"plan.activate","operator":"plan.activate","guard":{"true":true},"target":{"true":true},"priority":50},{"id":"workspace.cut","operator":"workspace.cut","guard":{"true":true},"target":{"true":true},"priority":52,"parameters":[{"parameter":"branch","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/delivery-branch","version":"1"}}},{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"destination","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/managed-worktree-destination","version":"1"}}}]},{"id":"workspace.activate","operator":"workspace.activate","guard":{"true":true},"target":{"true":true},"priority":53,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"workspace.sync","operator":"workspace.sync","guard":{"true":true},"target":{"true":true},"priority":58,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"gate.build.record","operator":"gate.build.record","guard":{"true":true},"target":{"true":true},"priority":61,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/build","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/build","version":"1"}}}]},{"id":"gate.test.record","operator":"gate.test.record","guard":{"true":true},"target":{"true":true},"priority":62,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/test","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/test","version":"1"}}}]},{"id":"gate.review.record","operator":"gate.review.record","guard":{"true":true},"target":{"true":true},"priority":63,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/review","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/review","version":"1"}}}]},{"id":"gate.change.record","operator":"gate.change.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/change","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/change","version":"1"}}}]},{"id":"gate.journey.record","operator":"gate.journey.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/journey","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/journey","version":"1"}}}]},{"id":"evidence.visual.attach","operator":"evidence.visual.attach","guard":{"true":true},"target":{"true":true},"priority":66,"parameters":[{"parameter":"manifest_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-manifest-path","version":"1"}}},{"parameter":"privacy_receipt","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-privacy-receipt","version":"1"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"delivery.slice.advance","operator":"delivery.slice.advance","guard":{"true":true},"target":{"true":true},"priority":68,"parameters":[{"parameter":"slice_id","producer":{"kind":"host-input","request":{"id":"delivery-slice","description":"Select the next bounded delivery slice.","authorities":["human","autonomy"],"scope":"transition"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"publication.preview","operator":"publication.preview","guard":{"true":true},"target":{"true":true},"priority":72,"parameters":[{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"head_ref","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}}]},{"id":"workspace.publish","operator":"workspace.publish","guard":{"true":true},"target":{"true":true},"priority":75,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"publication.execute","operator":"publication.execute","guard":{"true":true},"target":{"true":true},"priority":76,"parameters":[{"parameter":"preview_fingerprint","producer":{"kind":"state","facet":"preview_fingerprint","available_when":{"fact":{"facet":"preview_fingerprint","statuses":["known"],"values":[]}}}}]},{"id":"publication.observe","operator":"publication.observe","guard":{"true":true},"target":{"true":true},"priority":77,"parameters":[{"parameter":"publication_id","producer":{"kind":"state-or-receipt","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}},"transition":"publication.execute","field":"publication_id"}}]},{"id":"publication.correct","operator":"publication.correct","guard":{"true":true},"target":{"true":true},"priority":80,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}},{"parameter":"body_sha256","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-sha256","version":"1"}}}]},{"id":"workspace.reconcile","operator":"workspace.reconcile","guard":{"true":true},"target":{"true":true},"priority":2,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]}],"targets":[{"id":"published-pr","predicate":{"all":[{"fact":{"facet":"verification","statuses":["known"],"values":["current"]}},{"fact":{"facet":"configuration","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"runtime","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"publication","statuses":["known"],"values":["open"]}}]}},{"id":"safely-abandoned","predicate":{"all":[{"fact":{"facet":"delivery","statuses":["known"],"values":["discarded"]}},{"fact":{"facet":"workspace","statuses":["known"],"values":["abandoned","absent"]}}]}}],"entries":[{"id":"run","target":"published-pr","inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}],"delegation":{"reference":"software-delivery/delegation/autonomy","version":"1"}},{"id":"abandon","target":"safely-abandoned","inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}]}]} diff --git a/docs/architecture/boatstack-locus-liveness.json b/docs/architecture/boatstack-locus-liveness.json index 3b365b1..ca5378c 100644 --- a/docs/architecture/boatstack-locus-liveness.json +++ b/docs/architecture/boatstack-locus-liveness.json @@ -4,7 +4,7 @@ "subject": "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", "evidence": [ { - "path": "boatstack/delivery/delivery.go", + "path": "boatstack/delivery/control.go", "note": "Compiler combines exact CoreSystem, ProgramRuntime, extension, contract, and ownership declarations into one immutable runtime registry." }, { diff --git a/docs/architecture/boatstack-locus-safety.json b/docs/architecture/boatstack-locus-safety.json index 8750c47..02e1297 100644 --- a/docs/architecture/boatstack-locus-safety.json +++ b/docs/architecture/boatstack-locus-safety.json @@ -4,7 +4,7 @@ "subject": "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", "evidence": [ { - "path": "boatstack/delivery/delivery.go", + "path": "boatstack/delivery/control.go", "note": "Compiler combines exact CoreSystem, ProgramRuntime, extension, contract, and ownership declarations into one immutable runtime registry." }, { diff --git a/docs/architecture/boatstack-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index 7b61b75..e34a572 100644 --- a/docs/architecture/boatstack-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -11,12 +11,12 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | | `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | | `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | -| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | +| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | | `engagement.begin` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | | `engagement.release` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | | `engagement.renew` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | -| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | -| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | +| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | +| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | | `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | | `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | | `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | @@ -30,30 +30,30 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | | `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | | `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | -| `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | -| `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | -| `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | -| `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | -| `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | +| `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | +| `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | +| `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | +| `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | +| `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | | `installation.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | | `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | | `installation.update` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | | `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | | `objective.bind` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | -| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | -| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | -| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | -| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | -| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | -| `plan.create` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | -| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | -| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | -| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | -| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | -| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | -| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | -| `publication.preview` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | -| `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | +| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | +| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | +| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | +| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | +| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | +| `plan.create` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | +| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | +| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | +| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | +| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | +| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | +| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | +| `publication.preview` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | +| `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | | `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | | `recovery.resume` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | | `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | @@ -62,13 +62,13 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | | `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | | `runtime.replace` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | -| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | -| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | -| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | -| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | -| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | -| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | -| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | -| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | +| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | +| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | +| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | +| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | +| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | +| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | +| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | +| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | `*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`. diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 0dc3f55..d8f2aaf 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -25,8 +25,7 @@ import { softwareDeliveryFacets, trustedDelegation, trustedOperators, - trustedTransition, - trustedTransitions, + trustedSoftwareDeliveryTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -34,6 +33,7 @@ const lifecycle = [ planningPackageAdmit, planningPackageApprove, planningPackagePromote, + { id: "workspace.cut", priority: 52 }, // Add the repository's trusted execution, gate, and publication steps here. ] satisfies TrustedStep[]; @@ -60,12 +60,9 @@ export default defineFlow({ evidence: softwareDeliveryEvidence, work: [planning], operators: trustedOperators(lifecycle), - transitions: [ - trustedTransition(planningPackageAdmit, { work: planning }), - trustedTransition(planningPackageApprove), - trustedTransition(planningPackagePromote), - // Add trustedTransitions(...) for the remaining lifecycle here. - ], + transitions: trustedSoftwareDeliveryTransitions(lifecycle, { + planningPackageWork: planning, + }), targets: [ marked( "published-pr", @@ -99,6 +96,38 @@ resolves trusted bindings, canonicalizes executable semantics, and fingerprints the program. Runtime commands load the committed IR artifact; they do not execute this source file. +Every required trusted operator parameter has exactly one producer in the +repository source. Compilation rejects missing, duplicate, incompatible, or +authority-weakening producers before it projects an artifact or generated +skill. Trusted resolvers are read-only. + +The standard producer ownership is: + +| Value | Canonical producer | +| --- | --- | +| admitted planning-package fingerprint | trusted manifest resolver | +| workspace branch, preview fingerprint, recovery IDs | durable state | +| publication ID | `publication.execute` effect receipt | +| gate evidence path and fingerprint | trusted canonical-artifact resolver | +| source revision | trusted committed-HEAD resolver | +| publication body path and fingerprint | trusted canonical-artifact resolver | +| next slice ID | genuinely actor-owned host input | + +Human or delegated approval remains an authority decision. The approving actor +does not type deterministic values such as the admitted package fingerprint. +Only `delivery.slice.advance.slice_id` is free-form in the standard lifecycle. +A missing free-form input returns a typed `TRANSITION_INPUT_REQUIRED` +suspension; record its answer with `boatstack flow input answer`, then resume +the same run. If the value is semantically rejected, use +`boatstack flow input supersede` to issue a linked request generation. Do not +edit or delete the old request or receipt. + +Prepare gate evidence at +`.boatstack/evidence//.input.json`, visual evidence at +`.boatstack/evidence//visual-manifest.input.json`, and the pull +request body at `.boatstack/publication/.body.md`. Boatstack binds +the exact path and content fingerprint when the transition is materialized. + The planning-package operations are optional trusted mechanisms. A repository that includes them owns the planning instruction, output contract, transition order, approval policy, target, and entry. `planning.package.admit` atomically diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index a9760bf..41cb29a 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -12,12 +12,18 @@ import { always, facet, + fact, + fromState, + fromStateOrReceipt, + hostParameter, operator, + trustedParameterResolver, transition, type EntryInputDefinition, type EvidenceDefinition, type FacetDefinition, type OperatorDefinition, + type ParameterProducer, type TransitionDefinition, type DelegationBindingDefinition, type WorkContract, @@ -50,6 +56,9 @@ export const softwareDeliveryFacets: FacetDefinition[] = [ "recovery_resumption", "recovery_source_phase", "source_revision", + "preview_fingerprint", + "publication_id", + "recovery_transaction_id", "transaction_id", "transaction_transition", "workspace_base_ref", @@ -104,6 +113,170 @@ export interface TrustedTransitionOptions { requires?: { authorities?: string[] }; /** Foreground work that must complete before this transition is admitted. */ work?: WorkContract; + /** One repository-selected producer for each trusted operator parameter. */ + parameters?: Record; +} + +/** Reads the exact verified repository default branch. */ +export function repositoryDefaultBranch(): ParameterProducer { + return trustedParameterResolver( + "software-delivery/repository-default-branch", + "1", + ); +} + +/** Derives a non-conflicting managed branch from the exact delivery context. */ +export function deliveryBranch(): ParameterProducer { + return trustedParameterResolver("software-delivery/delivery-branch", "1"); +} + +/** Derives the managed destination within Boatstack's trusted worktree root. */ +export function managedWorktreeDestination(): ParameterProducer { + return trustedParameterResolver( + "software-delivery/managed-worktree-destination", + "1", + ); +} + +/** Reads the exact fingerprint of the planning-package manifest admitted for this delivery. */ +export function admittedPlanningPackageFingerprint(): ParameterProducer { + return trustedParameterResolver( + "software-delivery/admitted-planning-package-fingerprint", + "1", + ); +} + +/** Reads the exact committed revision of the invoking worktree. */ +export function currentSourceRevision(): ParameterProducer { + return trustedParameterResolver("software-delivery/current-source-revision", "1"); +} + +/** Reads one canonical gate-evidence input path prepared for this delivery. */ +export function gateEvidencePath(gate: "build" | "test" | "review" | "change" | "journey"): ParameterProducer { + return trustedParameterResolver(`software-delivery/gate-evidence-path/${gate}`, "1"); +} + +/** Hashes the exact canonical gate-evidence input prepared for this delivery. */ +export function gateEvidenceFingerprint(gate: "build" | "test" | "review" | "change" | "journey"): ParameterProducer { + return trustedParameterResolver(`software-delivery/gate-evidence-fingerprint/${gate}`, "1"); +} + +/** Reads the canonical visual-evidence manifest path for this delivery. */ +export function visualEvidenceManifestPath(): ParameterProducer { + return trustedParameterResolver("software-delivery/visual-evidence-manifest-path", "1"); +} + +/** Hashes the canonical visual-evidence manifest for its privacy receipt. */ +export function visualEvidencePrivacyReceipt(): ParameterProducer { + return trustedParameterResolver("software-delivery/visual-evidence-privacy-receipt", "1"); +} + +/** Reads the canonical pull-request body path for this delivery. */ +export function publicationBodyPath(): ParameterProducer { + return trustedParameterResolver("software-delivery/publication-body-path", "1"); +} + +/** Hashes the canonical pull-request body for a correction. */ +export function publicationBodyFingerprint(): ParameterProducer { + return trustedParameterResolver("software-delivery/publication-body-sha256", "1"); +} + +function durableValue(facet: string): ParameterProducer { + return fromState({ facet, availableWhen: fact(facet) }); +} + +/** Resolves the exact transaction identity from current recovery observation. */ +export function observedRecoveryTransaction(): ParameterProducer { + return fromState({ + facet: "recovery_transaction_id", + availableWhen: fact("recovery_transaction_id"), + }); +} + +function canonicalGateParameters(gate: "build" | "test" | "review" | "change" | "journey"): Record { + return { + source_revision: currentSourceRevision(), + evidence_path: gateEvidencePath(gate), + evidence_fingerprint: gateEvidenceFingerprint(gate), + }; +} + +/** + * Returns the standard explicit producer bindings for one trusted lifecycle step. + * This helper declares data ownership only; it does not infer values at runtime. + */ +export function standardSoftwareDeliveryParameters(step: TrustedStep): Record { + switch (step.id) { + case "planning.package.approve": + return { package_fingerprint: admittedPlanningPackageFingerprint() }; + case "workspace.cut": + return { + branch: deliveryBranch(), + base_ref: repositoryDefaultBranch(), + destination: managedWorktreeDestination(), + }; + case "workspace.activate": + case "workspace.sync": + case "workspace.publish": + return { branch: durableValue("workspace_branch") }; + case "gate.build.record": + return canonicalGateParameters("build"); + case "gate.test.record": + return canonicalGateParameters("test"); + case "gate.review.record": + return canonicalGateParameters("review"); + case "gate.change.record": + return canonicalGateParameters("change"); + case "gate.journey.record": + return canonicalGateParameters("journey"); + case "evidence.visual.attach": + return { + manifest_path: visualEvidenceManifestPath(), + privacy_receipt: visualEvidencePrivacyReceipt(), + source_revision: currentSourceRevision(), + }; + case "delivery.slice.advance": + return { + slice_id: hostParameter({ + id: "delivery-slice", + description: "Select the next bounded delivery slice.", + authorities: ["human", "autonomy"], + scope: "transition", + }), + source_revision: currentSourceRevision(), + }; + case "publication.preview": + return { + base_ref: repositoryDefaultBranch(), + head_ref: durableValue("workspace_branch"), + body_path: publicationBodyPath(), + }; + case "publication.execute": + return { preview_fingerprint: durableValue("preview_fingerprint") }; + case "publication.observe": + return { + publication_id: fromStateOrReceipt({ + facet: "publication_id", + availableWhen: fact("publication_id"), + transition: "publication.execute", + field: "publication_id", + }), + }; + case "publication.correct": + return { + publication_id: durableValue("publication_id"), + body_path: publicationBodyPath(), + body_sha256: publicationBodyFingerprint(), + }; + case "workspace.reconcile": + return { transaction_id: observedRecoveryTransaction() }; + case "publication.reconcile": + return { + transaction_id: observedRecoveryTransaction(), + }; + default: + return {}; + } } /** @@ -168,15 +341,52 @@ export function trustedTransition( step: TrustedStep, options: TrustedTransitionOptions = {}, ): TransitionDefinition { + const parameters = options.parameters ?? {}; return transition(step.id, step.id, { guard: always, target: always, priority: step.priority, ...(options.requires ? { requires: options.requires } : {}), ...(options.work ? { work: options.work.id } : {}), + ...(Object.keys(parameters).length !== 0 + ? { + parameters: Object.entries(parameters).map( + ([parameter, producer]) => ({ parameter, producer }), + ), + } + : {}), }); } +/** + * Declares one standard software-delivery transition with its canonical, + * explicit producer bindings. Authority remains owned by the trusted operator. + */ +export function trustedSoftwareDeliveryTransition( + step: TrustedStep, + options: Omit = {}, +): TransitionDefinition { + const parameters = standardSoftwareDeliveryParameters(step); + return trustedTransition(step, { + ...options, + ...(Object.keys(parameters).length !== 0 ? { parameters } : {}), + }); +} + +/** Expands a standard lifecycle into transitions with explicit canonical producers. */ +export function trustedSoftwareDeliveryTransitions( + steps: TrustedStep[], + options: { planningPackageWork?: WorkContract } = {}, +): TransitionDefinition[] { + return steps.map((step) => + trustedSoftwareDeliveryTransition(step, { + ...(step.id === planningPackageAdmit.id && options.planningPackageWork + ? { work: options.planningPackageWork } + : {}), + }), + ); +} + /** * Declares trusted transitions for a lifecycle list. * diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index 24c94f1..febfd1d 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -11,7 +11,7 @@ /** Canonical schema name emitted by {@link defineFlow}. */ export const CONTROL_PROGRAM_SCHEMA = "control-program" as const; /** Current revision of the canonical Control Program schema. */ -export const CONTROL_PROGRAM_SCHEMA_REVISION = 3 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 4 as const; /** * A declarative condition over runtime state facts. @@ -97,9 +97,79 @@ export interface OperatorDefinition { recovery?: string; state_effect?: StateEffectDefinition; execution_context?: "preserve" | "advance"; + parameters?: OperatorParameterDefinition[]; + /** Trusted binding-owned committed transition-receipt fields. */ + outputs?: { id: string; type: ValueTypeDefinition }[]; description?: string; } +/** Closed producer vocabulary accepted by invocation-completeness analysis. */ +export type ParameterSourceKind = + | "entry-input" + | "state" + | "receipt" + | "state-or-receipt" + | "work-output" + | "trusted-resolver" + | "host-input"; + +/** Immutable trusted validator reference resolved by the compiler. */ +export interface TrustedValidatorBinding { + reference: string; + version: string; + fingerprint: string; +} + +/** Canonical value contract for one operator parameter. */ +export type ValueTypeDefinition = + | { kind: "string"; validator?: TrustedValidatorBinding } + | { kind: "boolean" } + | { kind: "integer"; minimum?: number; maximum?: number } + | { kind: "json"; schema?: TrustedValidatorBinding }; + +/** Trusted operator-owned requirements for one invocation parameter. */ +export interface OperatorParameterDefinition { + id: string; + type: ValueTypeDefinition; + required: boolean; + secret: boolean; + allowed_sources: ParameterSourceKind[]; + authority: { any_of?: string[]; all_of?: string[] }; +} + +/** Exactly one repository-selected source for a transition parameter. */ +export type ParameterProducer = + | { kind: "entry-input"; input: string } + | { kind: "state"; facet: string; available_when: Predicate } + | { kind: "receipt"; transition: string; field: string } + | { + kind: "state-or-receipt"; + facet: string; + available_when: Predicate; + transition: string; + field: string; + } + | { kind: "work-output"; work: string; output: string } + | { + kind: "trusted-resolver"; + binding: { reference: string; version: string; fingerprint?: string }; + } + | { + kind: "host-input"; + request: { + id: string; + description: string; + authorities: string[]; + scope: "transition"; + }; + }; + +/** Binds one trusted operator parameter to its declared producer. */ +export interface TransitionParameterBinding { + parameter: string; + producer: ParameterProducer; +} + /** * A repository asset resolved and fingerprinted by the trusted compiler. * @@ -156,6 +226,7 @@ export interface TransitionDefinition { priority: number; requires?: { authorities?: string[] }; work?: string; + parameters?: TransitionParameterBinding[]; description?: string; } @@ -309,6 +380,73 @@ export function foregroundWork(definition: WorkContract): WorkContract { return { ...definition }; } +/** Resolves a transition parameter from one declared entry input. */ +export function fromEntryInput(input: string): ParameterProducer { + return { kind: "entry-input", input }; +} + +/** Resolves a transition parameter from a state facet under an exact availability condition. */ +export function fromState(definition: { + facet: string; + availableWhen: Predicate; +}): ParameterProducer { + return { + kind: "state", + facet: definition.facet, + available_when: definition.availableWhen, + }; +} + +/** Resolves a transition parameter from an earlier transition receipt. */ +export function fromReceipt(definition: { + transition: string; + field: string; +}): ParameterProducer { + return { kind: "receipt", ...definition }; +} + +/** Resolves from current durable state, falling back to one exact committed receipt. */ +export function fromStateOrReceipt(definition: { + facet: string; + availableWhen: Predicate; + transition: string; + field: string; +}): ParameterProducer { + return { + kind: "state-or-receipt", + facet: definition.facet, + available_when: definition.availableWhen, + transition: definition.transition, + field: definition.field, + }; +} + +/** Resolves a transition parameter from exact run-scoped foreground-work output. */ +export function fromWorkOutput(definition: { + work: string; + output: string; +}): ParameterProducer { + return { kind: "work-output", ...definition }; +} + +/** Requests one trusted runtime-owned parameter resolver binding. */ +export function trustedParameterResolver( + reference: string, + version: string, +): ParameterProducer { + return { kind: "trusted-resolver", binding: { reference, version } }; +} + +/** Declares a typed, resumable host-input request for a transition parameter. */ +export function hostParameter(definition: { + id: string; + description: string; + authorities: string[]; + scope: "transition"; +}): ParameterProducer { + return { kind: "host-input", request: { ...definition } }; +} + /** * Declares a typed state facet. * diff --git a/release-notes/2026-08-15-invocation-completeness.md b/release-notes/2026-08-15-invocation-completeness.md new file mode 100644 index 0000000..48694c2 --- /dev/null +++ b/release-notes/2026-08-15-invocation-completeness.md @@ -0,0 +1,3 @@ +### Require complete Control Program invocations + +Control Program compilation now requires one declared producer for every required operator parameter. Repository Flows materialize those producers through a domain-neutral invocation boundary, suspend with a typed input request when a host value is missing, and bind current invocation evidence through prescription, admission, effect execution, and transition receipts. Software-delivery Flows derive deterministic lifecycle values from trusted runtime evidence, can supersede a rejected free-form input without rewriting its receipt, and reproject after an accepted program change before requesting fresh product authority. Generated drivers preserve the run across that boundary and continue to the marked delivery state.