From a55db8e4d29cb075f6eca36ca2fff33a5cd9eddc Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sat, 15 Aug 2026 22:35:08 +0100 Subject: [PATCH 01/15] feat: require complete Control Program invocations --- .github/tests/test_repository_contract.py | 19 + .../cmd/boatstack-helper/declarative_flow.go | 480 +++++++++++++++++ .../boatstack-helper/declarative_flow_test.go | 118 +++++ .../boatstack-helper/delegation_command.go | 87 +++- .../boatstack-helper/delegation_runtime.go | 49 ++ .../cmd/boatstack-helper/flow_command.go | 67 ++- .../cmd/boatstack-helper/flow_runtime.go | 485 +++++++++++++----- .../cmd/boatstack-helper/flow_runtime_test.go | 445 ++++++++-------- .../cmd/boatstack-helper/input_command.go | 281 ++++++++++ .../boatstack-helper/input_command_test.go | 180 +++++++ boatstack/cmd/boatstack-helper/main.go | 102 +++- .../work_output_invocation_test.go | 61 +++ boatstack/controlprogram/artifact.go | 2 +- boatstack/controlprogram/canonical.go | 17 +- boatstack/controlprogram/canonical_test.go | 177 +++++++ .../frontend_conformance_test.go | 38 ++ .../controlprogram/invocation_compile.go | 375 ++++++++++++++ boatstack/controlprogram/ir.go | 107 +++- boatstack/delivery_controller.go | 10 +- boatstack/flow/softwaredelivery/bindings.go | 63 +++ boatstack/flow/softwaredelivery/definition.go | 6 +- .../flow/softwaredelivery/definition_test.go | 15 +- .../softwaredelivery/parameter_runtime.go | 141 +++++ .../flow/softwaredelivery/planning_package.go | 3 + boatstack/flow/softwaredelivery/skills.go | 91 +++- .../flow/softwaredelivery/skills_test.go | 36 ++ boatstack/flow/standard/completeness_test.go | 1 + .../softwaredelivery/effects/journal.go | 1 + .../softwaredelivery/engine/engine.go | 36 +- .../softwaredelivery/engine/engine_test.go | 75 +++ .../foregroundwork/manager.go | 9 + .../softwaredelivery/plant/resolver.go | 15 + .../softwaredelivery/protocol/admission.go | 6 +- .../softwaredelivery/protocol/prescription.go | 22 +- .../softwaredelivery/protocol/receipt.go | 7 +- .../softwaredelivery/surfaces/protocol.go | 20 +- boatstack/invocation/invocation.go | 420 +++++++++++++++ boatstack/invocation/invocation_test.go | 158 ++++++ boatstack/invocation/store.go | 240 +++++++++ ...cident-response-invocation-missing.flow.ts | 33 ++ .../incident-response-invocation.flow.ts | 51 ++ .../incident-response.raw.json | 2 +- .../product-delivery-a.flow.ts | 8 +- .../product-delivery-b.flow.ts | 11 +- .../product-delivery-c.flow.ts | 34 +- .../product-delivery-planning-package.flow.ts | 76 ++- docs/product-delivery/writing-a-flow.md | 34 +- .../boatstack-software-delivery/src/index.ts | 32 ++ packages/boatstack/src/index.ts | 114 +++- .../2026-08-15-invocation-completeness.md | 3 + 50 files changed, 4438 insertions(+), 425 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/declarative_flow.go create mode 100644 boatstack/cmd/boatstack-helper/declarative_flow_test.go create mode 100644 boatstack/cmd/boatstack-helper/input_command.go create mode 100644 boatstack/cmd/boatstack-helper/input_command_test.go create mode 100644 boatstack/cmd/boatstack-helper/work_output_invocation_test.go create mode 100644 boatstack/controlprogram/invocation_compile.go create mode 100644 boatstack/flow/softwaredelivery/parameter_runtime.go create mode 100644 boatstack/invocation/invocation.go create mode 100644 boatstack/invocation/invocation_test.go create mode 100644 boatstack/invocation/store.go create mode 100644 boatstack/testdata/control-programs/incident-response-invocation-missing.flow.ts create mode 100644 boatstack/testdata/control-programs/incident-response-invocation.flow.ts create mode 100644 release-notes/2026-08-15-invocation-completeness.md 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/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go new file mode 100644 index 0000000..e7d219e --- /dev/null +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -0,0 +1,480 @@ +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 = 1 + +type declarativeRunState struct { + SchemaRevision int `json:"schema_revision"` + RunID string `json:"run_id"` + ProgramFingerprint string `json:"program_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"` + LastTransition string `json:"last_transition,omitempty"` + LastInvocation string `json:"last_invocation,omitempty"` + 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, + }, options.format) + } + transition, operator, ok := selectDeclarativeTransition(compiled.Document, runtimeContext.state.Facts) + if !ok { + 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) + } + 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") + } + + // 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") + } + if err := applyDeclarativeAssignments(&runtimeContext.state, *operator.StateEffect, fresh.Ready.Parameters); err != nil { + return err + } + runtimeContext.state.StateRevision++ + runtimeContext.state.LastTransition = transition.ID + runtimeContext.state.LastInvocation = fresh.Ready.InvocationFingerprint + if err := saveDeclarativeRun(runtimeContext.statePath, runtimeContext.state); err != nil { + return err + } + 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": map[string]any{"transition_id": transition.ID, "invocation_fingerprint": fresh.Ready.InvocationFingerprint}, + }, options.format) +} + +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, EntryID: entry.ID, TargetID: entry.Target, StateRevision: 1, EntryInputs: provided, Facts: map[string]string{}} + 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.EntryID != entry.ID || state.TargetID != entry.Target || state.StateRevision == 0 || state.EntryInputs == nil || state.Facts == nil { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative run identity or schema changed") + } + 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 selectDeclarativeTransition(document controlprogram.Document, facts map[string]string) (controlprogram.Transition, controlprogram.Operator, bool) { + operators := map[string]controlprogram.Operator{} + for _, operator := range document.Operators { + operators[operator.ID] = operator + } + transitions := append([]controlprogram.Transition(nil), document.Transitions...) + sort.Slice(transitions, func(i, j int) bool { + if transitions[i].Priority != transitions[j].Priority { + return transitions[i].Priority < transitions[j].Priority + } + return transitions[i].ID < transitions[j].ID + }) + for _, transition := range transitions { + if predicateSatisfied(transition.Guard, facts) && !predicateSatisfied(transition.Target, facts) { + return transition, operators[transition.Operator], true + } + } + return controlprogram.Transition{}, controlprogram.Operator{}, false +} + +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..cdda173 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "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{"healthcheck"}}, + Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "mitigated"}}}, + Evidence: []controlprogram.Evidence{{ID: "healthcheck", Subject: "incident", Kind: "observation"}}, + Operators: []controlprogram.Operator{{ + ID: "restart", Authority: controlprogram.AuthorityRequirement{}, Verifier: "healthcheck", 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 { + 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") + 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, declarativeInvocationDocument(), sourcePath, source, lockPath, lock) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-m", "fixture") + return repository +} + +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) + } + + completedRaw, 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) + } + 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) + } +} diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index f90f244..902cbaa 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -226,6 +226,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 +266,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 +297,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 +321,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 +363,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 +386,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 +414,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 +500,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 +533,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..cb0181c 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -121,6 +121,55 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo return lock, nil, nil } +// 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..efa7049 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,68 @@ 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 { + 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.Effects) != 0 || 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) + } + } + for _, transition := range compiled.Document.Transitions { + for _, binding := range transition.Parameters { + switch binding.Producer.Kind { + case controlprogram.ParameterSourceEntryInput, controlprogram.ParameterSourceState, controlprogram.ParameterSourceHostInput: + default: + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative transition %q requires an adapter for producer %q", transition.ID, binding.Producer.Kind) + } + } + } + return nil +} + 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..cd4cf96 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -13,15 +13,20 @@ 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/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 +38,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 +49,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 +131,16 @@ 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 { + // Program reconciliation is an installation-authority transition. It must + // not consume or create product delegation because its accepted effect + // changes the exact bundle to which product delegation is bound. + if entry.Delegation != nil && options.transitionID != "installation.reconcile-update" { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { return commandOptions{}, resolverErr @@ -199,94 +194,308 @@ 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} + } + stateValues := softwareflow.StateParameterValues(state) + receiptValues := map[string]invocation.Value{} + if value, ok := stateValues["preview_fingerprint"]; ok { + receiptValues["publication.preview/preview_fingerprint"] = value + } + if value, ok := stateValues["publication_id"]; ok { + receiptValues["publication.observe/publication_id"] = value + } + if value, ok := stateValues["transaction_id"]; ok { + receiptValues["publication.execute/transaction_id"] = value + } + 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) + } } - invocation, err := resolver.ResolveInvocation(ctx, repository, host, "flow-publication-preview") + 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 + } + 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, + } + bindingResolver, err := softwareflow.NewResolver(ctx) + 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, 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 } - if !strings.HasPrefix(invocation.Ref, "refs/heads/") { - return fmt.Errorf("FLOW_INPUT_REQUIRED: publication requires an attached branch") + 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) } - bodyPath, err := resolveRegularRepositoryFile(repository, filepath.Join(repository, ".boatstack", "evidence", deliveryID+"-pr-body.md"), "publication body") + 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_INPUT_REQUIRED: bind publication body: %w", err) + 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 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 + 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 { @@ -381,7 +590,7 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma if bindErr != nil { return commandOptions{}, bindErr } - return bindStateOwnedTransitionParameters(bound, state) + return bound, nil } if entryObjective.TrustedClass == model.ObjectiveAbandoned { repositoryIdentity, identityErr := flowRepositoryIdentity(repository) @@ -399,36 +608,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 +618,27 @@ 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 } + 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,9 +661,71 @@ 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 } +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 && entryObjective.TrustedClass == model.ObjectiveAbandoned { return "", options.deliveryID, nil diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index a553d99..59d22dc 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -45,6 +45,32 @@ func flowRepository(t *testing.T) string { return repository } +func TestFlowEntryCanonicalizesRepositoryRoot(t *testing.T) { + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(workingDirectory, repository) + if err != nil { + t.Fatal(err) + } + bound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: relative, 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 +89,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) }) @@ -246,8 +300,13 @@ func productDeliveryDocument(programID string) controlprogram.Document { {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, }, - Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, - Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77}}, + 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.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ID: "publication-id", Description: "publication id", Authorities: []string{"human"}, Scope: "transition"}, + }}}, + }}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ flowFact("verification", "current"), flowFact("configuration", "verified"), flowFact("runtime", "verified"), flowFact("publication", "open"), }}}}, @@ -325,6 +384,55 @@ func TestRPCFlowEntryRejectsUnknownEntryAndInvalidInboxBeforeManagedState(t *tes } } +func TestPrescribedRepositoryTransitionRebindsBeforeExposure(t *testing.T) { + // control-law: a-selected-repository-transition-cannot-return-or-apply-an-unbound-prescription + repository := flowRepository(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: "publication.observe", + }} + rebound, changed, err := bindPrescribedRepositoryInvocation(context.Background(), request, response) + if err != nil { + t.Fatal(err) + } + if !changed || rebound.TransitionID != "publication.observe" || 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 +473,6 @@ 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", }) if err != nil { t.Fatal(err) @@ -373,12 +480,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{ @@ -553,7 +656,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 +666,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 +683,107 @@ 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 TestFlowCompileRejectsSourceChangedDuringFrontend(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell fixture is Unix-only") @@ -878,7 +1077,12 @@ func TestFlowValidationRejectsMissingProductionRecoveryClosure(t *testing.T) { // control-law: published-flows-close-recovery-in-the-production-composition document := productDeliveryDocument("product-delivery") document.Operators[0] = controlprogram.Operator{ID: "publication.execute", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.execute", Version: "1"}} - document.Transitions[0] = controlprogram.Transition{ID: "publication.execute", Operator: "publication.execute", Guard: document.Transitions[0].Guard, Target: document.Transitions[0].Target, Priority: 77} + 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.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ID: "preview-fingerprint", Description: "preview fingerprint", Authorities: []string{"human"}, Scope: "transition"}, + }}}, + } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { t.Fatal(err) @@ -1093,30 +1297,11 @@ 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")) 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 +1309,8 @@ 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 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 source, ok := resumed.workInputs["plan"]; !ok || source.Value != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { + t.Fatalf("resumed entry input = %#v, present=%t", source, ok) } } @@ -1271,174 +1452,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 @@ -1495,7 +1510,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 +1530,14 @@ 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) - 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) + 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 +1559,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 +1577,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) diff --git a/boatstack/cmd/boatstack-helper/input_command.go b/boatstack/cmd/boatstack-helper/input_command.go new file mode 100644 index 0000000..508351d --- /dev/null +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -0,0 +1,281 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "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" +) + +type flowInputOptions struct { + repository string + programID string + entryID string + runID string + requestFingerprint string + answerPath string + human string + host string + format 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") + 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" { + 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 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..8674478 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/input_command_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "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" +) + +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 := 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") + writeAdmittedFlowProgramState(t, repository, strings.Repeat("f", 64)) + + base := commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "publication.observe"} + 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(`{"publication_id":"123"}`), 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: "publication.observe", + 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.Join(resumed.parameters, "") != "publication_id=123" { + t.Fatalf("resumed invocation = evidence %#v request %#v parameters %#v", resumed.invocationEvidence, resumed.inputRequest, resumed.parameters) + } + + if err := os.WriteFile(answerPath, []byte(`{"publication_id":"456"}`), 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 TestCLIAndRPCBindingsCreateTheSameInputSuspension(t *testing.T) { + // control-law: transport selection cannot change the typed invocation + // request for one exact Flow context. + 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") + 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: "publication.observe", + }) + 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: "publication.observe", + }) + 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 := 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") + 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: "publication.observe", + }) + if err != nil { + t.Fatal(err) + } + answerPath := filepath.Join(t.TempDir(), "answer.json") + if err := os.WriteFile(answerPath, []byte(`{"publication_id":"123"}`), 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: "publication.observe", 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..c40d652 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 @@ -87,6 +90,8 @@ type commandOptions struct { workResultFingerprint string controlBundle *boatstackruntime.ControlBundleContract controlBundleFingerprint string + invocationEvidence *invocation.Evidence + inputRequest *invocation.InputRequest } func main() { @@ -148,6 +153,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 +175,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 +211,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 +255,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 +281,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 +295,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 +387,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 +418,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 +495,48 @@ 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 + } + 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 +708,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 +720,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 +732,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 +900,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/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..c223f37 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} 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 = resolved.Verifier, resolved.Recovery, &resolved.StateEffect, resolved.ExecutionContext, resolved.Parameters } } var err error @@ -326,6 +329,9 @@ 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 op.ExecutionContext != "preserve" && op.ExecutionContext != "advance" { return nil, invalid("operators."+op.ID+".execution_context", "must be preserve or advance") } @@ -341,6 +347,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 +381,7 @@ 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) _ = normalizeStateEffect(expectedBinding.StateEffect, facets) if !sameOperatorSemantics(*op, *expectedBinding) { return nil, invalid("operators."+op.ID, "compiled binding semantics drift") @@ -699,7 +710,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 } 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..ab9c249 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -19,6 +19,34 @@ type delegationResolver struct { delegable bool } +type invocationResolver struct { + delegationResolver + parameterResolvers map[string]controlprogram.ResolvedParameterResolver + validators map[string]controlprogram.ResolvedValueValidator +} + +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 +58,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 +96,147 @@ 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.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 TestInvocationCompletenessRejectsUnguaranteedReceiptAndUnknownResolver(t *testing.T) { + receiptBound := parameterProgram() + 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 is not guaranteed") { + t.Fatalf("unguaranteed receipt result = %v", err) + } + + unknown := parameterProgram() + 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 TestInvocationCompletenessChecksEntryAndStateAvailabilityPerEntry(t *testing.T) { + entryBound := parameterProgram() + 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].AllowedSources = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState} + 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 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].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..b7f8c54 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -148,6 +148,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..f449cda --- /dev/null +++ b/boatstack/controlprogram/invocation_compile.go @@ -0,0 +1,375 @@ +package controlprogram + +import ( + "encoding/json" + "fmt" + "sort" +) + +var parameterSourceKinds = map[ParameterSourceKind]bool{ + ParameterSourceEntryInput: true, ParameterSourceState: true, + ParameterSourceReceipt: 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 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, 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, 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 + } + 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 !predicateImplies(transition.Guard, *producer.AvailableWhen) { + return nil, invocationIncomplete(transition.ID, contract.ID, "state availability is not implied by the transition guard") + } + case ParameterSourceReceipt: + prior, ok := transitions[producer.Transition] + if !ok || !validID(producer.Field) || prior.Priority >= transition.Priority || !predicateImplies(transition.Guard, prior.Target) { + return nil, invocationIncomplete(transition.ID, contract.ID, "receipt is not guaranteed before the consuming transition") + } + 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") + } + 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") + } + if !authoritySatisfies(resolved.Authority, contract.Authority) { + return nil, invocationIncomplete(transition.ID, contract.ID, "trusted resolver weakens parameter authority") + } + 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 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 { + 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 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 authoritySatisfies(actual, required AuthorityRequirement) bool { + combined := append(append([]string(nil), actual.AnyOf...), actual.AllOf...) + return authorityListSatisfies(combined, required) +} + +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..211f853 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,6 +112,75 @@ type AuthorityRequirement struct { AllOf []string `json:"all_of,omitempty"` } +type ParameterSourceKind string + +const ( + ParameterSourceEntryInput ParameterSourceKind = "entry-input" + ParameterSourceState ParameterSourceKind = "state" + ParameterSourceReceipt ParameterSourceKind = "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"` @@ -122,6 +191,7 @@ type Operator struct { Recovery string `json:"recovery,omitempty"` StateEffect *StateEffect `json:"state_effect,omitempty"` ExecutionContext string `json:"execution_context"` + Parameters []OperatorParameter `json:"parameters,omitempty"` Description string `json:"description,omitempty"` } @@ -150,14 +220,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 +278,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 +291,22 @@ type ResolvedOperator struct { Recovery string StateEffect StateEffect ExecutionContext string + Parameters []OperatorParameter +} + +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..92cfd39 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 @@ -85,9 +86,47 @@ 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), }, 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 reference { + case ParameterResolverPrefix + "repository-default-branch": + value.Dependencies = []string{"repository", "verified-configuration"} + case ParameterResolverPrefix + "delivery-branch": + value.Dependencies = []string{"repository", "delivery_id", "repository-policy"} + case ParameterResolverPrefix + "managed-worktree-destination": + value.Dependencies = []string{"repository", "git-common", "run_id", "delivery_id", "source-worktree"} + 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,6 +194,30 @@ func projectStateEffect(value delivery.StateEffect) controlprogram.StateEffect { return result } +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 == "workspace.cut": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case parameter.Name == "branch": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState, controlprogram.ParameterSourceHostInput} + case transition.ID == PlanningPackageApprove && parameter.Name == "package_fingerprint": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceWorkOutput, controlprogram.ParameterSourceHostInput} + case parameter.Name == "preview_fingerprint" || parameter.Name == "publication_id" || parameter.Name == "transaction_id": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState, controlprogram.ParameterSourceReceipt, controlprogram.ParameterSourceHostInput} + case transition.ID == "publication.preview" && parameter.Name == "base_ref": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + } + result = append(result, controlprogram.OperatorParameter{ + ID: parameter.Name, Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: parameter.Required, Secret: parameter.Secret, + AllowedSources: allowed, + }) + } + return result +} + func transitionFingerprint(value delivery.Transition) (string, error) { encoded, err := json.Marshal(value) if err != nil { 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..2c54072 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -29,7 +29,7 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, }, Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, - Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: guard, Target: controlprogram.Predicate{True: &truth}, Priority: 77}}, + Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: guard, Target: controlprogram.Predicate{True: &truth}, Priority: 77, Parameters: hostParameters("publication_id")}}, 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,17 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. return compiled, resolver } +func hostParameters(ids ...string) []controlprogram.TransitionParameterBinding { + result := make([]controlprogram.TransitionParameterBinding, 0, len(ids)) + for _, id := range ids { + result = append(result, controlprogram.TransitionParameterBinding{Parameter: id, Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceHostInput, + Request: &controlprogram.HostInputRequest{ID: id, Description: "Provide " + id + ".", Scope: "transition"}, + }}) + } + return result +} + func fact(facet, value string) controlprogram.Predicate { return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}, Values: []string{value}}} } @@ -308,7 +319,7 @@ func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) {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: hostParameters("publication_id")}, {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..f2ab2bf --- /dev/null +++ b/boatstack/flow/softwaredelivery/parameter_runtime.go @@ -0,0 +1,141 @@ +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/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._-]+`) + +// 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 + 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 +} + +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 binding.Reference { + case 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 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 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[:]) + default: + return invocation.Value{}, fmt.Errorf("unknown runtime parameter resolver %q", binding.Reference) + } + return value, nil +} 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..6f72d5d 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -57,7 +57,59 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s delegation := "" diagnostics := "" workProtocol := "" + inputProtocol := "" + 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 @@ -78,6 +130,23 @@ An answer is evidence, never authority. If work succeeds, run 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. +`, compiled.Document.Program.ID, entry.ID, host, compiled.Document.Program.ID, entry.ID, host) } if entry.Diagnostics != nil && entry.Diagnostics.ExplainOnSuspend { diagnostics = fmt.Sprintf(` @@ -140,7 +209,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 +223,29 @@ 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 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..ff3eb04 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{ 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/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go index ef11793..414e3ee 100644 --- a/boatstack/internal/softwaredelivery/effects/journal.go +++ b/boatstack/internal/softwaredelivery/effects/journal.go @@ -138,6 +138,7 @@ func readJournal(path string) (journalRecord, error) { 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/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index f183312..67e1031 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,16 @@ 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 + } + 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() @@ -327,6 +338,13 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if request.Requested != request.Prescription.TransitionID { return result, fmt.Errorf("apply transition does not match prescription") } + invocationFingerprint := "" + if request.InvocationEvidence != nil { + invocationFingerprint = request.InvocationEvidence.InvocationFingerprint + } + if err := request.Prescription.ValidateInvocation(invocationFingerprint); err != nil { + return result, err + } if request.IdempotencyKey != "" { prior, ok, err := e.receipts.FindByIdempotency(ctx, request.Invocation, request.IdempotencyKey) if err != nil { diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index 89dab5b..3318a16 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" ) @@ -324,6 +325,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() 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/resolver.go b/boatstack/internal/softwaredelivery/plant/resolver.go index e5df26b..10802b3 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,20 @@ 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 +} + 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/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 1c862af..defd556 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -10,7 +10,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const AdmissionSchemaVersion = 8 +const AdmissionSchemaVersion = 9 type Admission struct { SchemaVersion int `json:"schema_version"` @@ -43,6 +43,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 +99,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,7 +447,7 @@ 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) { + 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) || (a.InvocationFingerprint != "" && len(a.InvocationFingerprint) != 64) { return fmt.Errorf("admission: invalid schema, identity, source, or lifetime") } if a.ControlBundle != nil { 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..559018a 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -12,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ReceiptSchemaVersion = 12 +const ReceiptSchemaVersion = 13 type TransitionFactKind string @@ -137,6 +137,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 { @@ -201,6 +202,7 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi 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 @@ -253,6 +255,9 @@ func (r TransitionReceipt) Validate() error { (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 r.ExecutionContext != "" { if r.ExecutionContext != "advance" || r.PriorInvocation == nil || r.ResultingInvocation == nil { return fmt.Errorf("receipt has invalid execution context lineage") diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 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..5c077c4 --- /dev/null +++ b/boatstack/invocation/invocation.go @@ -0,0 +1,420 @@ +// 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 + 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"` + Parameters []RequestedParameter `json:"parameters"` +} + +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") + } + identity := r + identity.Fingerprint = "" + if fingerprint(identity) != r.Fingerprint { + return fmt.Errorf("input request failed content identity verification") + } + return nil +} + +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") + } + 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.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") + } + return validateValue(contract.Type, r.Value, r.SecretReference, contract.Secret) +} + +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, Parameters: requested} + request.ID = "input-" + fingerprintWithoutField(request, "Fingerprint")[:24] + request.Fingerprint = fingerprintWithoutField(request, "Fingerprint") + return &request +} + +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..8d0789c --- /dev/null +++ b/boatstack/invocation/invocation_test.go @@ -0,0 +1,158 @@ +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 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 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..e4404df --- /dev/null +++ b/boatstack/invocation/store.go @@ -0,0 +1,240 @@ +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 +} + +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..e53bb28 --- /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: ["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: [], + authority: {}, + effects: [], + verifier: "healthcheck", + 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..2b7a5f8 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, hostParameter, 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: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }) }, + })], 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..1c59899 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, hostParameter, 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: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }) }, + }), + 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..c4044f7 100644 --- a/boatstack/testdata/control-programs/product-delivery-c.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-c.flow.ts @@ -1,11 +1,12 @@ -import { all, defineFlow, entry, fact, marked } from "@operatorstack/boatstack"; +import { all, defineFlow, entry, fact, hostParameter, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, softwareDeliveryEvidence, softwareDeliveryFacets, trustedOperators, - trustedTransitions, + repositoryDefaultBranch, + trustedTransition, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -25,7 +26,34 @@ export default defineFlow({ facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, operators: trustedOperators(lifecycle), - transitions: trustedTransitions(lifecycle), + transitions: [ + trustedTransition({ id: "gate.test.record", priority: 62 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), + evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), + evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "gate.review.record", priority: 63 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), + evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), + evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "gate.change.record", priority: 64 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), + evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), + evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "publication.preview", priority: 72 }, { parameters: { + base_ref: repositoryDefaultBranch(), + head_ref: hostParameter({ id: "head-ref", description: "head-ref", authorities: ["human"], scope: "transition" }), + body_path: hostParameter({ id: "body-path", description: "body-path", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "publication.execute", priority: 76 }, { parameters: { + preview_fingerprint: hostParameter({ id: "preview-fingerprint", description: "preview-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "publication.observe", priority: 77 }, { parameters: { + publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }), + }}), + ], 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..d5d4cca 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -5,6 +5,7 @@ import { entryInput, fact, foregroundWork, + hostParameter, instructionAsset, marked, schemaAsset, @@ -16,6 +17,9 @@ import { planningPackageAdmit, planningPackageApprove, planningPackagePromote, + repositoryDefaultBranch, + deliveryBranch, + managedWorktreeDestination, softwareDeliveryEvidence, softwareDeliveryFacets, trustedDelegation, @@ -66,7 +70,7 @@ const lifecycle = [ ]; export default defineFlow({ - id: "product-delivery-planning-package", + id: "product-delivery", version: "1", declarations: { input_resolvers: [planInboxResolver] }, facets: softwareDeliveryFacets, @@ -75,26 +79,60 @@ export default defineFlow({ operators: trustedOperators(lifecycle), transitions: [ trustedTransition(planningPackageAdmit, { work: planning }), - trustedTransition(planningPackageApprove), + trustedTransition(planningPackageApprove, { parameters: { + package_fingerprint: hostParameter({ id: "package-fingerprint", description: "package-fingerprint", authorities: ["human"], scope: "transition" }), + }}), 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 }), + trustedTransition({ id: "workspace.cut", priority: 52 }, { parameters: { + branch: deliveryBranch(), base_ref: repositoryDefaultBranch(), destination: managedWorktreeDestination(), + }}), + trustedTransition({ id: "workspace.activate", priority: 53 }, { parameters: { branch: hostParameter({ id: "branch", description: "branch", authorities: ["human"], scope: "transition" }) }}), + trustedTransition({ id: "workspace.sync", priority: 58 }, { parameters: { branch: hostParameter({ id: "branch", description: "branch", authorities: ["human"], scope: "transition" }) }}), + trustedTransition({ id: "gate.build.record", priority: 61 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), + evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), + evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "gate.test.record", priority: 62 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "gate.review.record", priority: 63 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "gate.change.record", priority: 64 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "gate.journey.record", priority: 64 }, { parameters: { + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "evidence.visual.attach", priority: 66 }, { parameters: { + manifest_path: hostParameter({ id: "manifest-path", description: "manifest-path", authorities: ["human"], scope: "transition" }), + privacy_receipt: hostParameter({ id: "privacy-receipt", description: "privacy-receipt", authorities: ["human"], scope: "transition" }), + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "delivery.slice.advance", priority: 68 }, { parameters: { + slice_id: hostParameter({ id: "slice-id", description: "slice-id", authorities: ["human"], scope: "transition" }), + source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "publication.preview", priority: 72 }, { parameters: { + base_ref: repositoryDefaultBranch(), + head_ref: hostParameter({ id: "head-ref", description: "head-ref", authorities: ["human"], scope: "transition" }), + body_path: hostParameter({ id: "body-path", description: "body-path", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "workspace.publish", priority: 75 }, { parameters: { branch: hostParameter({ id: "branch", description: "branch", authorities: ["human"], scope: "transition" }) }}), + trustedTransition({ id: "publication.execute", priority: 76 }, { parameters: { preview_fingerprint: hostParameter({ id: "preview-fingerprint", description: "preview-fingerprint", authorities: ["human"], scope: "transition" }) }}), + trustedTransition({ id: "publication.observe", priority: 77 }, { parameters: { publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }) }}), + trustedTransition({ id: "publication.correct", priority: 80 }, { parameters: { + publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }), + body_path: hostParameter({ id: "body-path", description: "body-path", authorities: ["human"], scope: "transition" }), + body_sha256: hostParameter({ id: "body-sha256", description: "body-sha256", authorities: ["human"], scope: "transition" }), + }}), + trustedTransition({ id: "workspace.reconcile", priority: 2 }, { parameters: { transaction_id: hostParameter({ id: "transaction-id", description: "transaction-id", authorities: ["human"], scope: "transition" }) }}), + trustedTransition({ id: "publication.reconcile", priority: 1 }, { parameters: { + publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }), + transaction_id: hostParameter({ id: "transaction-id", description: "transaction-id", authorities: ["human"], scope: "transition" }), + }}), ], targets: [marked("published-pr", all( fact("verification", ["current"]), diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 0dc3f55..c85e9f2 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -11,22 +11,25 @@ import { entryInput, fact, foregroundWork, + hostParameter, instructionAsset, marked, workArtifact, } from "@operatorstack/boatstack"; import { + deliveryBranch, inbox, + managedWorktreeDestination, planInboxResolver, planningPackageAdmit, planningPackageApprove, planningPackagePromote, + repositoryDefaultBranch, softwareDeliveryEvidence, softwareDeliveryFacets, trustedDelegation, trustedOperators, trustedTransition, - trustedTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -34,6 +37,7 @@ const lifecycle = [ planningPackageAdmit, planningPackageApprove, planningPackagePromote, + { id: "workspace.cut", priority: 52 }, // Add the repository's trusted execution, gate, and publication steps here. ] satisfies TrustedStep[]; @@ -62,8 +66,27 @@ export default defineFlow({ operators: trustedOperators(lifecycle), transitions: [ trustedTransition(planningPackageAdmit, { work: planning }), - trustedTransition(planningPackageApprove), + trustedTransition(planningPackageApprove, { + parameters: { + package_fingerprint: hostParameter({ + id: "package-fingerprint", + description: "Enter the exact admitted planning-package fingerprint.", + authorities: ["human"], + scope: "transition", + }), + }, + }), trustedTransition(planningPackagePromote), + trustedTransition( + { id: "workspace.cut", priority: 52 }, + { + parameters: { + base_ref: repositoryDefaultBranch(), + branch: deliveryBranch(), + destination: managedWorktreeDestination(), + }, + }, + ), // Add trustedTransitions(...) for the remaining lifecycle here. ], targets: [ @@ -99,6 +122,13 @@ 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. A missing `hostParameter` returns a +typed `TRANSITION_INPUT_REQUIRED` suspension; record its answer with +`boatstack flow input answer`, then resume the same run. + 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..588c29b 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -13,11 +13,13 @@ import { always, facet, operator, + trustedParameterResolver, transition, type EntryInputDefinition, type EvidenceDefinition, type FacetDefinition, type OperatorDefinition, + type ParameterProducer, type TransitionDefinition, type DelegationBindingDefinition, type WorkContract, @@ -104,6 +106,29 @@ 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", + ); } /** @@ -174,6 +199,13 @@ export function trustedTransition( priority: step.priority, ...(options.requires ? { requires: options.requires } : {}), ...(options.work ? { work: options.work.id } : {}), + ...(options.parameters + ? { + parameters: Object.entries(options.parameters).map( + ([parameter, producer]) => ({ parameter, producer }), + ), + } + : {}), }); } diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index 24c94f1..e822944 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,69 @@ export interface OperatorDefinition { recovery?: string; state_effect?: StateEffectDefinition; execution_context?: "preserve" | "advance"; + parameters?: OperatorParameterDefinition[]; description?: string; } +/** Closed producer vocabulary accepted by invocation-completeness analysis. */ +export type ParameterSourceKind = + | "entry-input" + | "state" + | "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: "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 +216,7 @@ export interface TransitionDefinition { priority: number; requires?: { authorities?: string[] }; work?: string; + parameters?: TransitionParameterBinding[]; description?: string; } @@ -309,6 +370,57 @@ 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 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..94096dc --- /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. Generated software-delivery drivers also preserve an authorized run across initial program drift, obtain separate human acceptance for the exact program delta, reconcile it atomically, and resume the same run instead of terminating at the maintenance boundary. From 2343e7623b3c471dbcd32b378bf1e5f0ba32f1e6 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 00:12:06 +0100 Subject: [PATCH 02/15] Complete product delivery invocation producers --- .../boatstack-helper/delegation_command.go | 32 +- .../boatstack-helper/delegation_runtime.go | 39 +- .../cmd/boatstack-helper/flow_runtime.go | 57 ++- .../cmd/boatstack-helper/flow_runtime_test.go | 197 +++++++- .../cmd/boatstack-helper/input_command.go | 46 +- .../boatstack-helper/input_command_test.go | 118 ++++- boatstack/cmd/boatstack-helper/main.go | 1 + .../product_delivery_flow_e2e_test.go | 445 ++++++++++++++++++ .../frontend_conformance_test.go | 20 + boatstack/flow/softwaredelivery/bindings.go | 62 ++- .../flow/softwaredelivery/definition_test.go | 18 +- .../softwaredelivery/parameter_runtime.go | 117 ++++- .../parameter_runtime_test.go | 58 +++ boatstack/flow/softwaredelivery/skills.go | 11 +- boatstack/flow/standard/transitions.json | 9 + .../softwaredelivery/delegation/record.go | 10 + .../delegation/record_test.go | 15 + .../effects/command_boundary.go | 27 +- .../effects/command_boundary_test.go | 8 +- .../effects/delegation_record.go | 49 ++ .../effects/delegation_record_test.go | 47 ++ .../softwaredelivery/effects/receipts.go | 84 ++++ .../softwaredelivery/effects/receipts_test.go | 55 +++ .../softwaredelivery/engine/engine.go | 2 +- .../softwaredelivery/plant/resolver.go | 13 + .../internal/softwaredelivery/ports/ports.go | 1 + .../softwaredelivery/protocol/receipt.go | 22 +- .../protocol/receipt_capability_test.go | 2 +- .../protocol/receipt_fact_test.go | 25 +- .../softwaredelivery/surfaces/locus_render.go | 2 +- boatstack/invocation/invocation.go | 92 +++- boatstack/invocation/store.go | 63 +++ .../product-delivery-a.flow.ts | 4 +- .../product-delivery-b.flow.ts | 4 +- .../product-delivery-c.flow.ts | 34 +- .../product-delivery-planning-package.flow.ts | 64 +-- .../boatstack-locus-liveness.json | 2 +- docs/architecture/boatstack-locus-safety.json | 2 +- .../boatstack-transition-catalog.md | 60 +-- docs/product-delivery/writing-a-flow.md | 65 ++- .../boatstack-software-delivery/src/index.ts | 161 +++++++ .../2026-08-15-invocation-completeness.md | 2 +- 42 files changed, 1878 insertions(+), 267 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go create mode 100644 boatstack/flow/softwaredelivery/parameter_runtime_test.go create mode 100644 boatstack/internal/softwaredelivery/effects/delegation_record_test.go diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 902cbaa..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") } diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index cb0181c..60cd35f 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, objective model.Objective, 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, objective, 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, request.Objective, 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,13 @@ 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 diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index cd4cf96..cfa3115 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -137,10 +137,11 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } options.controlBundle = bundle options.controlBundleFingerprint = bundleFingerprint - // Program reconciliation is an installation-authority transition. It must - // not consume or create product delegation because its accepted effect - // changes the exact bundle to which product delegation is bound. - if entry.Delegation != nil && options.transitionID != "installation.reconcile-update" { + // 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 @@ -178,9 +179,18 @@ 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) + runtimeObjective := model.Objective{ID: options.objectiveID, TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: deliveryID} + reprojected, reprojectErr := canReprojectDelegation(layout, invocation, runtimeObjective, 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 } @@ -351,14 +361,19 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp } stateValues := softwareflow.StateParameterValues(state) receiptValues := map[string]invocation.Value{} - if value, ok := stateValues["preview_fingerprint"]; ok { - receiptValues["publication.preview/preview_fingerprint"] = value - } - if value, ok := stateValues["publication_id"]; ok { - receiptValues["publication.observe/publication_id"] = value - } - if value, ok := stateValues["transaction_id"]; ok { - receiptValues["publication.execute/transaction_id"] = value + for _, binding := range transition.Parameters { + if binding.Producer.Kind != controlprogram.ParameterSourceReceipt { + 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{} @@ -418,11 +433,21 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp ExecutionScopeFingerprint: executionScopeFingerprint, EntryInputs: entryInputs, State: stateValues, Receipts: receiptValues, WorkOutputs: workOutputs, InputReceipts: inputReceipts, } + 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 commandOptions{}, err } - result, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, softwareflow.RuntimeParameterResolver{Context: ctx, Repository: options.repository, DeliveryID: options.deliveryID, Binding: bindingResolver}) + 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 } @@ -530,7 +555,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 diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 59d22dc..96d5393 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,6 +24,7 @@ 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" @@ -45,6 +47,55 @@ 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")) @@ -299,12 +350,13 @@ 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"}, }, 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.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ID: "publication-id", Description: "publication id", Authorities: []string{"human"}, Scope: "transition"}, + Kind: controlprogram.ParameterSourceState, Facet: "publication", AvailableWhen: &controlprogram.Predicate{True: &truth}, }}}, }}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ @@ -386,7 +438,7 @@ func TestRPCFlowEntryRejectsUnknownEntryAndInvalidInboxBeforeManagedState(t *tes func TestPrescribedRepositoryTransitionRebindsBeforeExposure(t *testing.T) { // control-law: a-selected-repository-transition-cannot-return-or-apply-an-unbound-prescription - repository := flowRepository(t) + 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) @@ -409,13 +461,13 @@ func TestPrescribedRepositoryTransitionRebindsBeforeExposure(t *testing.T) { } response := surfaces.Response{Prescription: &protocol.Prescription{ SchemaVersion: protocol.PrescriptionSchemaVersion, - TransitionID: "publication.observe", + TransitionID: "delivery.slice.advance", }} rebound, changed, err := bindPrescribedRepositoryInvocation(context.Background(), request, response) if err != nil { t.Fatal(err) } - if !changed || rebound.TransitionID != "publication.observe" || rebound.Prescription.ID != "" { + 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 { @@ -784,6 +836,85 @@ func TestProgramChangePreflightRequiresExactTypedRecoverySurface(t *testing.T) { } } +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") @@ -1076,11 +1207,12 @@ func TestFlowExecutionLeaseSerializesProjectionPublicationThroughEffect(t *testi func TestFlowValidationRejectsMissingProductionRecoveryClosure(t *testing.T) { // control-law: published-flows-close-recovery-in-the-production-composition document := productDeliveryDocument("product-delivery") + available := document.Transitions[0].Guard 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, Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "preview_fingerprint", Producer: controlprogram.ParameterProducer{ - Kind: controlprogram.ParameterSourceHostInput, Request: &controlprogram.HostInputRequest{ID: "preview-fingerprint", Description: "preview fingerprint", Authorities: []string{"human"}, Scope: "transition"}, + Kind: controlprogram.ParameterSourceState, Facet: "preview_fingerprint", AvailableWhen: &available, }}}, } resolver, err := softwareflow.NewResolver(context.Background()) @@ -1850,14 +1982,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 { @@ -1938,3 +2070,52 @@ 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{}, model.Objective{}, request, changedInput) + if err != nil || admitted { + t.Fatalf("unchanged-bundle 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 index 508351d..232a5ad 100644 --- a/boatstack/cmd/boatstack-helper/input_command.go +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" @@ -28,11 +29,12 @@ type flowInputOptions struct { 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]") + return fmt.Errorf("usage: boatstack flow input [flags]") } action := arguments[0] flags := flag.NewFlagSet("flow input "+action, flag.ContinueOnError) @@ -47,6 +49,7 @@ func runFlowInput(arguments []string) error { 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 } @@ -56,7 +59,7 @@ func runFlowInput(arguments []string) error { if action == "block" { return fmt.Errorf("TRANSITION_INPUT_BLOCKED: no input receipt was recorded") } - if action != "show" && action != "answer" { + 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 == "" { @@ -95,6 +98,45 @@ func runFlowInput(arguments []string) error { } 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") } diff --git a/boatstack/cmd/boatstack-helper/input_command_test.go b/boatstack/cmd/boatstack-helper/input_command_test.go index 8674478..94f0ede 100644 --- a/boatstack/cmd/boatstack-helper/input_command_test.go +++ b/boatstack/cmd/boatstack-helper/input_command_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -10,19 +11,20 @@ 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" ) 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 := flowRepository(t) + 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: "publication.observe"} + 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) @@ -31,7 +33,7 @@ func TestFlowInputAnswerResumesSameRunAndConflictsFailClosed(t *testing.T) { t.Fatalf("suspension = %#v", suspended.inputRequest) } answerPath := filepath.Join(t.TempDir(), "answer.json") - if err := os.WriteFile(answerPath, []byte(`{"publication_id":"123"}`), 0o600); err != nil { + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"slice-one"}`), 0o600); err != nil { t.Fatal(err) } arguments := []string{ @@ -43,17 +45,17 @@ func TestFlowInputAnswerResumesSameRunAndConflictsFailClosed(t *testing.T) { } resumed, err := bindFlowEntry(context.Background(), commandOptions{ - repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "publication.observe", + 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.Join(resumed.parameters, "") != "publication_id=123" { + 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(`{"publication_id":"456"}`), 0o600); err != nil { + 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") { @@ -61,10 +63,100 @@ func TestFlowInputAnswerResumesSameRunAndConflictsFailClosed(t *testing.T) { } } +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 := flowRepository(t) + repository := flowRepositoryWithHumanSlice(t) runFlowGit(t, repository, "init", "-q") writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) runFlowGit(t, repository, "add", ".") @@ -73,7 +165,7 @@ func TestCLIAndRPCBindingsCreateTheSameInputSuspension(t *testing.T) { cli, err := bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", host: "codex", - correlationID: "transport-parity", transitionID: "publication.observe", + correlationID: "transport-parity", transitionID: "delivery.slice.advance", }) if err != nil { t.Fatal(err) @@ -89,7 +181,7 @@ func TestCLIAndRPCBindingsCreateTheSameInputSuspension(t *testing.T) { ID: cli.objectiveID, TargetID: model.TargetID(cli.targetID), TrustedClass: model.TargetID(cli.trustedObjectiveClass), DeliveryID: cli.deliveryID, }, - TransitionID: "publication.observe", + TransitionID: "delivery.slice.advance", }) if err != nil { t.Fatal(err) @@ -107,7 +199,7 @@ func TestApplyAndRecoveryDiscardRevokedInvocationEvidence(t *testing.T) { // neither can reuse evidence after its receipt is revoked. stateRoot := t.TempDir() t.Setenv("BOATSTACK_STATE_ROOT", stateRoot) - repository := flowRepository(t) + repository := flowRepositoryWithHumanSlice(t) runFlowGit(t, repository, "init", "-q") writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) runFlowGit(t, repository, "add", ".") @@ -116,13 +208,13 @@ func TestApplyAndRecoveryDiscardRevokedInvocationEvidence(t *testing.T) { suspended, err := bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", host: "codex", - correlationID: "revocation", transitionID: "publication.observe", + 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(`{"publication_id":"123"}`), 0o600); err != nil { + if err := os.WriteFile(answerPath, []byte(`{"slice_id":"slice-one"}`), 0o600); err != nil { t.Fatal(err) } if _, err := captureStdout(t, func() error { @@ -135,7 +227,7 @@ func TestApplyAndRecoveryDiscardRevokedInvocationEvidence(t *testing.T) { } resumed, err := bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", host: "codex", correlationID: "revocation", - transitionID: "publication.observe", runID: suspended.runID, deliveryID: suspended.deliveryID, + transitionID: "delivery.slice.advance", runID: suspended.runID, deliveryID: suspended.deliveryID, targetID: suspended.targetID, objectiveID: suspended.objectiveID, }) if err != nil || resumed.invocationEvidence == nil { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index c40d652..a61ddb9 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -80,6 +80,7 @@ type commandOptions struct { delegationAuthorities stringList delegationDescription string delegationRequest delegation.Request + delegationReprojection bool workInputs map[string]protocol.WorkInputValue workID string workQuestionPrompt string 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..c7cb699 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -0,0 +1,445 @@ +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. + 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) + } + 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 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) + frontend := filepath.Join(repositoryRoot, "node_modules", ".bin", "boatstack-flow-frontend") + source := filepath.Join(moduleRoot, "testdata", "control-programs", "product-delivery-planning-package.flow.ts") + raw, err := exec.Command(frontend, source).CombinedOutput() + if err != nil { + t.Fatalf("compile exact product-delivery fixture: %v\n%s", err, raw) + } + 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/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index b7f8c54..60de2e6 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -111,6 +111,26 @@ 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" { + var packageProducer, publicationProducer controlprogram.ParameterProducer + 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.ParameterSourceReceipt || publicationProducer.Transition != "publication.execute" || publicationProducer.Field != "publication_id" { + t.Fatalf("publication identity producer = %#v", publicationProducer) + } 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) } diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 92cfd39..4e862d7 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -99,13 +99,35 @@ func (r Resolver) ResolveParameterResolver(reference, version string) (controlpr SourceKind: controlprogram.ParameterSourceTrustedResolver, StabilityScope: "invocation", } - switch reference { - case ParameterResolverPrefix + "repository-default-branch": + 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 ParameterResolverPrefix + "delivery-branch": + case reference == ParameterResolverPrefix+"delivery-branch": value.Dependencies = []string{"repository", "delivery_id", "repository-policy"} - case ParameterResolverPrefix + "managed-worktree-destination": + 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) } @@ -199,16 +221,34 @@ func projectOperatorParameters(transition delivery.Transition) []controlprogram. 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 parameter.Name == "branch": - allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState, controlprogram.ParameterSourceHostInput} - case transition.ID == PlanningPackageApprove && parameter.Name == "package_fingerprint": - allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceWorkOutput, controlprogram.ParameterSourceHostInput} - case parameter.Name == "preview_fingerprint" || parameter.Name == "publication_id" || parameter.Name == "transaction_id": - allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceState, controlprogram.ParameterSourceReceipt, controlprogram.ParameterSourceHostInput} - case transition.ID == "publication.preview" && parameter.Name == "base_ref": + 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.ParameterSourceReceipt, controlprogram.ParameterSourceState} + 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, diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 2c54072..99b3fce 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -29,7 +29,7 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, }, Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, - Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: guard, Target: controlprogram.Predicate{True: &truth}, Priority: 77, Parameters: hostParameters("publication_id")}}, + 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,15 +42,10 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. return compiled, resolver } -func hostParameters(ids ...string) []controlprogram.TransitionParameterBinding { - result := make([]controlprogram.TransitionParameterBinding, 0, len(ids)) - for _, id := range ids { - result = append(result, controlprogram.TransitionParameterBinding{Parameter: id, Producer: controlprogram.ParameterProducer{ - Kind: controlprogram.ParameterSourceHostInput, - Request: &controlprogram.HostInputRequest{ID: id, Description: "Provide " + id + ".", Scope: "transition"}, - }}) - } - return result +func publicationIDStateParameter(available controlprogram.Predicate) []controlprogram.TransitionParameterBinding { + return []controlprogram.TransitionParameterBinding{{Parameter: "publication_id", Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceState, Facet: "publication", AvailableWhen: &available, + }}} } func fact(facet, value string) controlprogram.Predicate { @@ -106,6 +101,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) @@ -319,7 +315,7 @@ func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) {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, Parameters: hostParameters("publication_id")}, + {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 index f2ab2bf..8e78c5a 100644 --- a/boatstack/flow/softwaredelivery/parameter_runtime.go +++ b/boatstack/flow/softwaredelivery/parameter_runtime.go @@ -25,10 +25,11 @@ var managedBranchSegment = regexp.MustCompile(`[^a-z0-9._-]+`) // 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 - Binding Resolver + Context context.Context + Repository string + DeliveryID string + SourceRevision string + Binding Resolver } // StateParameterValues projects software-delivery durable fields into the @@ -58,8 +59,14 @@ func (r RuntimeParameterResolver) ResolveParameter(binding controlprogram.Parame return invocation.Value{}, fmt.Errorf("trusted parameter resolver fingerprint drift") } value := invocation.Value{Type: resolved.OutputType, ProducerFingerprint: resolved.Fingerprint} - switch binding.Reference { - case ParameterResolverPrefix + "repository-default-branch": + 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 { @@ -74,7 +81,7 @@ func (r RuntimeParameterResolver) ResolveParameter(binding controlprogram.Parame return invocation.Value{}, fmt.Errorf("repository default branch configuration is missing or unverified") } value.Canonical, value.Provenance = config.Project.DefaultBranch, "project-config:"+configFingerprint - case ParameterResolverPrefix + "delivery-branch": + 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") @@ -92,7 +99,7 @@ func (r RuntimeParameterResolver) ResolveParameter(binding controlprogram.Parame 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 ParameterResolverPrefix + "managed-worktree-destination": + case binding.Reference == ParameterResolverPrefix+"managed-worktree-destination": repository, canonicalErr := filepath.Abs(r.Repository) if canonicalErr != nil { return invocation.Value{}, canonicalErr @@ -134,8 +141,102 @@ func (r RuntimeParameterResolver) ResolveParameter(binding controlprogram.Parame } 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..71a7d05 --- /dev/null +++ b/boatstack/flow/softwaredelivery/parameter_runtime_test.go @@ -0,0 +1,58 @@ +package softwaredelivery + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "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) + } +} diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 6f72d5d..7b10a2e 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -146,7 +146,16 @@ JSON answer object outside repository-tracked paths and submit it only with: 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. -`, compiled.Document.Program.ID, entry.ID, host, compiled.Document.Program.ID, entry.ID, host) + +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.Diagnostics != nil && entry.Diagnostics.ExplainOnSuspend { diagnostics = fmt.Sprintf(` diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json index 8b4fe5b..c2f69fc 100644 --- a/boatstack/flow/standard/transitions.json +++ b/boatstack/flow/standard/transitions.json @@ -5518,6 +5518,15 @@ }, "source_predicate": "predicate:source-phase:publication.preview", "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, { "facet": "plan", "statuses": [ 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/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index 3f01277..f5e9637 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" @@ -328,9 +329,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 +364,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..cc8d6da 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -277,7 +277,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 +318,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/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index cbdafa9..b63851e 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -136,6 +136,88 @@ 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. It never infers an output +// from durable state or provider state. +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 := receipt.EffectOutputs.Get(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 +} + +// 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. This permits +// a fresh delegation request; it never carries prior delegation authority. +func InstallationReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, objective model.Objective, 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, objective, controlBundleFingerprint) +} + +func installationReprojectionAdmits(records []journalRecord, flowID string, invocation model.InvocationContext, objective model.Objective, 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 || + receipt.ObjectiveID != objective.ID || receipt.TargetID != objective.TargetID || receipt.TrustedClass != objective.TrustedObjectiveClass() || receipt.DeliveryID != objective.DeliveryID { + 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. @@ -248,6 +330,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 +383,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..8a17767 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"} + objective := model.Objective{ID: "objective-product-delivery-run-one", TargetID: "published-pr", TrustedClass: model.ObjectiveOpenPR, DeliveryID: "one"} + 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.ID, TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: objective.DeliveryID, + } + 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, objective, "bundle-c") + if err != nil || !admitted { + t.Fatalf("installation reprojection admitted=%t err=%v", admitted, err) + } + wrongObjective := objective + wrongObjective.DeliveryID = "other" + if admitted, err := installationReprojectionAdmits(records, "run-one", invoking, wrongObjective, "bundle-c"); err != nil || admitted { + t.Fatalf("foreign objective 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 diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index 67e1031..f5253c0 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -570,7 +570,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe 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) } diff --git a/boatstack/internal/softwaredelivery/plant/resolver.go b/boatstack/internal/softwaredelivery/plant/resolver.go index 10802b3..e1043de 100644 --- a/boatstack/internal/softwaredelivery/plant/resolver.go +++ b/boatstack/internal/softwaredelivery/plant/resolver.go @@ -159,6 +159,19 @@ func (r Resolver) BranchExists(ctx context.Context, path, branch string) (bool, 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/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index 559018a..ec30905 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -123,6 +123,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"` @@ -147,7 +148,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") } @@ -198,6 +199,7 @@ 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, @@ -258,6 +260,9 @@ func (r TransitionReceipt) Validate() error { 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") @@ -389,6 +394,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/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/invocation/invocation.go b/boatstack/invocation/invocation.go index 5c077c4..46bcc88 100644 --- a/boatstack/invocation/invocation.go +++ b/boatstack/invocation/invocation.go @@ -35,6 +35,8 @@ type Context struct { ContextFingerprint string ControlBundleFingerprint string ExecutionScopeFingerprint string + InputRequestGeneration uint64 + InputRequestSupersession *InputRequestSupersession EntryInputs map[string]Value State map[string]Value Receipts map[string]Value @@ -91,22 +93,35 @@ type RequestedParameter struct { } 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"` - Parameters []RequestedParameter `json:"parameters"` + 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 { @@ -154,6 +169,15 @@ 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 { @@ -162,6 +186,15 @@ func (r InputRequest) Validate() error { 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") @@ -319,12 +352,37 @@ func inputRequestForHostBindings(contracts []controlprogram.OperatorParameter, p 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, Parameters: requested} + 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 == "" { diff --git a/boatstack/invocation/store.go b/boatstack/invocation/store.go index e4404df..a7f827c 100644 --- a/boatstack/invocation/store.go +++ b/boatstack/invocation/store.go @@ -118,6 +118,69 @@ func (s Store) FindRequest(runID, requestFingerprint string) (InputRequest, erro 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 { diff --git a/boatstack/testdata/control-programs/product-delivery-a.flow.ts b/boatstack/testdata/control-programs/product-delivery-a.flow.ts index 2b7a5f8..d937cb1 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, hostParameter, marked } from "@operatorstack/boatstack"; +import { all, always, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, @@ -22,7 +22,7 @@ export default defineFlow({ evidence: softwareDeliveryEvidence, operators: trustedOperators(lifecycle), transitions: [trustedTransition({ id: "publication.observe", priority: 77 }, { - parameters: { publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }) }, + parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: always }) }, })], targets: [marked("published-pr", all( fact("verification", ["current"]), diff --git a/boatstack/testdata/control-programs/product-delivery-b.flow.ts b/boatstack/testdata/control-programs/product-delivery-b.flow.ts index 1c59899..297f259 100644 --- a/boatstack/testdata/control-programs/product-delivery-b.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-b.flow.ts @@ -1,4 +1,4 @@ -import { all, defineFlow, entry, fact, hostParameter, marked } from "@operatorstack/boatstack"; +import { all, always, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, @@ -23,7 +23,7 @@ export default defineFlow({ operators: trustedOperators(lifecycle), transitions: [ trustedTransition({ id: "publication.observe", priority: 77 }, { - parameters: { publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }) }, + parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: always }) }, }), trustedTransition({ id: "plan.abandon", priority: 31 }), ], diff --git a/boatstack/testdata/control-programs/product-delivery-c.flow.ts b/boatstack/testdata/control-programs/product-delivery-c.flow.ts index c4044f7..e96fe19 100644 --- a/boatstack/testdata/control-programs/product-delivery-c.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-c.flow.ts @@ -1,12 +1,11 @@ -import { all, defineFlow, entry, fact, hostParameter, marked } from "@operatorstack/boatstack"; +import { all, defineFlow, entry, fact, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, softwareDeliveryEvidence, softwareDeliveryFacets, trustedOperators, - repositoryDefaultBranch, - trustedTransition, + trustedSoftwareDeliveryTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -26,34 +25,7 @@ export default defineFlow({ facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, operators: trustedOperators(lifecycle), - transitions: [ - trustedTransition({ id: "gate.test.record", priority: 62 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), - evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), - evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "gate.review.record", priority: 63 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), - evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), - evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "gate.change.record", priority: 64 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), - evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), - evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "publication.preview", priority: 72 }, { parameters: { - base_ref: repositoryDefaultBranch(), - head_ref: hostParameter({ id: "head-ref", description: "head-ref", authorities: ["human"], scope: "transition" }), - body_path: hostParameter({ id: "body-path", description: "body-path", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "publication.execute", priority: 76 }, { parameters: { - preview_fingerprint: hostParameter({ id: "preview-fingerprint", description: "preview-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "publication.observe", priority: 77 }, { parameters: { - publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }), - }}), - ], + 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 d5d4cca..cc7f385 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -5,7 +5,6 @@ import { entryInput, fact, foregroundWork, - hostParameter, instructionAsset, marked, schemaAsset, @@ -17,14 +16,11 @@ import { planningPackageAdmit, planningPackageApprove, planningPackagePromote, - repositoryDefaultBranch, - deliveryBranch, - managedWorktreeDestination, softwareDeliveryEvidence, softwareDeliveryFacets, trustedDelegation, trustedOperators, - trustedTransition, + trustedSoftwareDeliveryTransitions, } from "@operatorstack/boatstack-software-delivery"; const planning = foregroundWork({ @@ -77,63 +73,7 @@ export default defineFlow({ evidence: softwareDeliveryEvidence, work: [planning], operators: trustedOperators(lifecycle), - transitions: [ - trustedTransition(planningPackageAdmit, { work: planning }), - trustedTransition(planningPackageApprove, { parameters: { - package_fingerprint: hostParameter({ id: "package-fingerprint", description: "package-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition(planningPackagePromote), - trustedTransition({ id: "plan.activate", priority: 50 }), - trustedTransition({ id: "workspace.cut", priority: 52 }, { parameters: { - branch: deliveryBranch(), base_ref: repositoryDefaultBranch(), destination: managedWorktreeDestination(), - }}), - trustedTransition({ id: "workspace.activate", priority: 53 }, { parameters: { branch: hostParameter({ id: "branch", description: "branch", authorities: ["human"], scope: "transition" }) }}), - trustedTransition({ id: "workspace.sync", priority: 58 }, { parameters: { branch: hostParameter({ id: "branch", description: "branch", authorities: ["human"], scope: "transition" }) }}), - trustedTransition({ id: "gate.build.record", priority: 61 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), - evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), - evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "gate.test.record", priority: 62 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "gate.review.record", priority: 63 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "gate.change.record", priority: 64 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "gate.journey.record", priority: 64 }, { parameters: { - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), evidence_path: hostParameter({ id: "evidence-path", description: "evidence-path", authorities: ["human"], scope: "transition" }), evidence_fingerprint: hostParameter({ id: "evidence-fingerprint", description: "evidence-fingerprint", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "evidence.visual.attach", priority: 66 }, { parameters: { - manifest_path: hostParameter({ id: "manifest-path", description: "manifest-path", authorities: ["human"], scope: "transition" }), - privacy_receipt: hostParameter({ id: "privacy-receipt", description: "privacy-receipt", authorities: ["human"], scope: "transition" }), - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "delivery.slice.advance", priority: 68 }, { parameters: { - slice_id: hostParameter({ id: "slice-id", description: "slice-id", authorities: ["human"], scope: "transition" }), - source_revision: hostParameter({ id: "source-revision", description: "source-revision", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "publication.preview", priority: 72 }, { parameters: { - base_ref: repositoryDefaultBranch(), - head_ref: hostParameter({ id: "head-ref", description: "head-ref", authorities: ["human"], scope: "transition" }), - body_path: hostParameter({ id: "body-path", description: "body-path", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "workspace.publish", priority: 75 }, { parameters: { branch: hostParameter({ id: "branch", description: "branch", authorities: ["human"], scope: "transition" }) }}), - trustedTransition({ id: "publication.execute", priority: 76 }, { parameters: { preview_fingerprint: hostParameter({ id: "preview-fingerprint", description: "preview-fingerprint", authorities: ["human"], scope: "transition" }) }}), - trustedTransition({ id: "publication.observe", priority: 77 }, { parameters: { publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }) }}), - trustedTransition({ id: "publication.correct", priority: 80 }, { parameters: { - publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }), - body_path: hostParameter({ id: "body-path", description: "body-path", authorities: ["human"], scope: "transition" }), - body_sha256: hostParameter({ id: "body-sha256", description: "body-sha256", authorities: ["human"], scope: "transition" }), - }}), - trustedTransition({ id: "workspace.reconcile", priority: 2 }, { parameters: { transaction_id: hostParameter({ id: "transaction-id", description: "transaction-id", authorities: ["human"], scope: "transition" }) }}), - trustedTransition({ id: "publication.reconcile", priority: 1 }, { parameters: { - publication_id: hostParameter({ id: "publication-id", description: "publication-id", authorities: ["human"], scope: "transition" }), - transaction_id: hostParameter({ id: "transaction-id", description: "transaction-id", authorities: ["human"], scope: "transition" }), - }}), - ], + transitions: trustedSoftwareDeliveryTransitions(lifecycle, { planningPackageWork: planning }), targets: [marked("published-pr", all( fact("verification", ["current"]), fact("configuration", ["verified"]), 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..8d73827 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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | | `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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 c85e9f2..d8f2aaf 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -11,25 +11,21 @@ import { entryInput, fact, foregroundWork, - hostParameter, instructionAsset, marked, workArtifact, } from "@operatorstack/boatstack"; import { - deliveryBranch, inbox, - managedWorktreeDestination, planInboxResolver, planningPackageAdmit, planningPackageApprove, planningPackagePromote, - repositoryDefaultBranch, softwareDeliveryEvidence, softwareDeliveryFacets, trustedDelegation, trustedOperators, - trustedTransition, + trustedSoftwareDeliveryTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -64,31 +60,9 @@ export default defineFlow({ evidence: softwareDeliveryEvidence, work: [planning], operators: trustedOperators(lifecycle), - transitions: [ - trustedTransition(planningPackageAdmit, { work: planning }), - trustedTransition(planningPackageApprove, { - parameters: { - package_fingerprint: hostParameter({ - id: "package-fingerprint", - description: "Enter the exact admitted planning-package fingerprint.", - authorities: ["human"], - scope: "transition", - }), - }, - }), - trustedTransition(planningPackagePromote), - trustedTransition( - { id: "workspace.cut", priority: 52 }, - { - parameters: { - base_ref: repositoryDefaultBranch(), - branch: deliveryBranch(), - destination: managedWorktreeDestination(), - }, - }, - ), - // Add trustedTransitions(...) for the remaining lifecycle here. - ], + transitions: trustedSoftwareDeliveryTransitions(lifecycle, { + planningPackageWork: planning, + }), targets: [ marked( "published-pr", @@ -125,9 +99,34 @@ 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. A missing `hostParameter` returns a -typed `TRANSITION_INPUT_REQUIRED` suspension; record its answer with -`boatstack flow input answer`, then resume the same run. +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 diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 588c29b..eef1435 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -12,6 +12,9 @@ import { always, facet, + fromReceipt, + fromState, + hostParameter, operator, trustedParameterResolver, transition, @@ -52,6 +55,8 @@ export const softwareDeliveryFacets: FacetDefinition[] = [ "recovery_resumption", "recovery_source_phase", "source_revision", + "preview_fingerprint", + "publication_id", "transaction_id", "transaction_transition", "workspace_base_ref", @@ -131,6 +136,133 @@ export function managedWorktreeDestination(): ParameterProducer { ); } +/** 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: always }); +} + +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: fromReceipt({ 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: durableValue("transaction_id") }; + case "publication.reconcile": + return { + publication_id: durableValue("publication_id"), + transaction_id: durableValue("transaction_id"), + }; + default: + return {}; + } +} + /** * Requests trusted run-scoped autonomy delegation for an entry. * @@ -209,6 +341,35 @@ export function trustedTransition( }); } +/** + * 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/release-notes/2026-08-15-invocation-completeness.md b/release-notes/2026-08-15-invocation-completeness.md index 94096dc..48694c2 100644 --- a/release-notes/2026-08-15-invocation-completeness.md +++ b/release-notes/2026-08-15-invocation-completeness.md @@ -1,3 +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. Generated software-delivery drivers also preserve an authorized run across initial program drift, obtain separate human acceptance for the exact program delta, reconcile it atomically, and resume the same run instead of terminating at the maintenance boundary. +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. From 95c8de9141fb3fe2ebb6389280148ba9b275b864 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 00:31:54 +0100 Subject: [PATCH 03/15] Make product delivery E2E portable --- .github/workflows/ci.yml | 2 +- boatstack/cmd/boatstack-helper/flow_runtime_test.go | 11 ++--------- .../product_delivery_flow_e2e_test.go | 8 +++++--- boatstack/controlprogram/frontend_conformance_test.go | 11 +++++++++++ .../product-delivery-planning-package.raw.json | 1 + 5 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 boatstack/testdata/control-programs/product-delivery-planning-package.raw.json 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/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 96d5393..f9784de 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -99,16 +99,9 @@ func flowRepositoryWithHumanSlice(t *testing.T) string { func TestFlowEntryCanonicalizesRepositoryRoot(t *testing.T) { repository := flowRepository(t) writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) - workingDirectory, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - relative, err := filepath.Rel(workingDirectory, repository) - if err != nil { - t.Fatal(err) - } + noncanonical := repository + string(os.PathSeparator) + "." bound, err := bindFlowEntry(context.Background(), commandOptions{ - repository: relative, programID: "product-delivery", entryID: "run", host: "codex", + repository: noncanonical, programID: "product-delivery", entryID: "run", host: "codex", }) if err != nil { t.Fatal(err) diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index c7cb699..25c64c7 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -33,6 +33,9 @@ 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) @@ -334,11 +337,10 @@ func exactProductDeliveryFixture(t *testing.T) (controlprogram.Document, []byte, } moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) repositoryRoot := filepath.Dir(moduleRoot) - frontend := filepath.Join(repositoryRoot, "node_modules", ".bin", "boatstack-flow-frontend") source := filepath.Join(moduleRoot, "testdata", "control-programs", "product-delivery-planning-package.flow.ts") - raw, err := exec.Command(frontend, source).CombinedOutput() + raw, err := os.ReadFile(filepath.Join(moduleRoot, "testdata", "control-programs", "product-delivery-planning-package.raw.json")) if err != nil { - t.Fatalf("compile exact product-delivery fixture: %v\n%s", err, raw) + t.Fatalf("read checked exact product-delivery fixture: %v", err) } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 60de2e6..1c7797d 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -111,6 +111,17 @@ 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 for _, transition := range compiled.Document.Transitions { for _, parameter := range transition.Parameters { 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..cb15cee --- /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":"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.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.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":{"true":true}}}]},{"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":{"true":true}}}]},{"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":{"true":true}}},{"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":{"true":true}}}]},{"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":{"true":true}}}]},{"id":"publication.observe","operator":"publication.observe","guard":{"true":true},"target":{"true":true},"priority":77,"parameters":[{"parameter":"publication_id","producer":{"kind":"receipt","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":{"true":true}}},{"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":"transaction_id","available_when":{"true":true}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"true":true}}},{"parameter":"transaction_id","producer":{"kind":"state","facet":"transaction_id","available_when":{"true":true}}}]}],"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"]}}]}}],"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"}}]} From 935b15940ea10de0cd2415b613441cb8174d2b46 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 01:07:49 +0100 Subject: [PATCH 04/15] Close invocation acceptance and replay gaps --- .../cmd/boatstack-helper/declarative_flow.go | 92 +++++++++--- .../boatstack-helper/declarative_flow_test.go | 72 +++++++++- .../cmd/boatstack-helper/flow_command.go | 131 +++++++++++++++++- .../cmd/boatstack-helper/flow_runtime.go | 49 +++++++ .../cmd/boatstack-helper/flow_runtime_test.go | 11 +- .../product_delivery_flow_e2e_test.go | 43 ++++++ boatstack/controlprogram/canonical.go | 14 +- boatstack/controlprogram/canonical_test.go | 6 + .../frontend_conformance_test.go | 9 ++ .../controlprogram/invocation_compile.go | 109 ++++++++++++++- boatstack/controlprogram/ir.go | 19 +++ boatstack/flow/softwaredelivery/bindings.go | 46 +++++- .../flow/softwaredelivery/definition_test.go | 9 +- .../softwaredelivery/engine/engine.go | 51 +++++-- .../softwaredelivery/engine/engine_test.go | 60 +++++++- boatstack/invocation/invocation.go | 45 ++++++ boatstack/invocation/invocation_test.go | 43 ++++++ .../incident-response-invocation.flow.ts | 8 +- .../product-delivery-a.flow.ts | 4 +- .../product-delivery-b.flow.ts | 4 +- ...product-delivery-planning-package.raw.json | 2 +- .../boatstack-software-delivery/src/index.ts | 8 +- packages/boatstack/src/index.ts | 2 + 23 files changed, 772 insertions(+), 65 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index e7d219e..b9918fd 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -21,20 +21,32 @@ import ( "github.com/operatorstack/boatstack/boatstack/invocation" ) -const declarativeRunSchemaRevision = 1 +const declarativeRunSchemaRevision = 2 + +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"` + 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"` - 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"` - LastTransition string `json:"last_transition,omitempty"` - LastInvocation string `json:"last_invocation,omitempty"` - Fingerprint string `json:"fingerprint"` + SchemaRevision int `json:"schema_revision"` + RunID string `json:"run_id"` + ProgramFingerprint string `json:"program_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"` + LastTransition string `json:"last_transition,omitempty"` + LastInvocation string `json:"last_invocation,omitempty"` + LastReceipt *declarativeTransitionReceipt `json:"last_receipt,omitempty"` + Fingerprint string `json:"fingerprint"` } type declarativeRuntimeContext struct { @@ -114,6 +126,7 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o 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": runtimeContext.state.LastReceipt, }, options.format) } transition, operator, ok := selectDeclarativeTransition(compiled.Document, runtimeContext.state.Facts) @@ -146,6 +159,13 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o 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 @@ -161,15 +181,29 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o if fresh.Ready == nil || fresh.Ready.InvocationFingerprint != result.Ready.InvocationFingerprint { return fmt.Errorf("INVOCATION_DRIFT: invocation changed before declarative state effect") } - if err := applyDeclarativeAssignments(&runtimeContext.state, *operator.StateEffect, fresh.Ready.Parameters); err != nil { + priorRevision := runtimeContext.state.StateRevision + candidate := runtimeContext.state + if err := applyDeclarativeAssignments(&candidate, *operator.StateEffect, fresh.Ready.Parameters); err != nil { return err } - runtimeContext.state.StateRevision++ - runtimeContext.state.LastTransition = transition.ID - runtimeContext.state.LastInvocation = fresh.Ready.InvocationFingerprint - if err := saveDeclarativeRun(runtimeContext.statePath, runtimeContext.state); err != nil { + if !predicateSatisfied(transition.Target, candidate.Facts) { + return fmt.Errorf("DECLARATIVE_VERIFICATION_FAILED: transition %q did not establish its compiled target", transition.ID) + } + candidate.StateRevision++ + candidate.LastTransition = transition.ID + candidate.LastInvocation = fresh.Ready.InvocationFingerprint + 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), + } + receipt.ID = "receipt-" + digestDeclarative(receipt)[:24] + receipt.Fingerprint = digestDeclarative(receipt) + candidate.LastReceipt = &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 { @@ -179,10 +213,24 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o "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": map[string]any{"transition_id": transition.ID, "invocation_fingerprint": fresh.Ready.InvocationFingerprint}, + "receipt": runtimeContext.state.LastReceipt, }, 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 { @@ -233,6 +281,14 @@ func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compi if state.SchemaRevision != declarativeRunSchemaRevision || state.RunID != runID || state.ProgramFingerprint != compiled.Fingerprint || state.EntryID != entry.ID || state.TargetID != entry.Target || state.StateRevision == 0 || state.EntryInputs == nil || state.Facts == nil { return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative run identity or schema changed") } + if state.LastReceipt != nil { + receipt := *state.LastReceipt + fingerprint := receipt.Fingerprint + receipt.Fingerprint = "" + if fingerprint == "" || fingerprint != digestDeclarative(receipt) || state.LastTransition != state.LastReceipt.TransitionID || state.LastInvocation != state.LastReceipt.InvocationFingerprint || state.StateRevision != state.LastReceipt.ResultStateRevision { + return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: declarative transition receipt is invalid") + } + } if len(provided) != 0 && !equalStringMaps(provided, state.EntryInputs) { return declarativeRuntimeContext{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: entry inputs changed across the run") } diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index cdda173..de8b1d2 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" @@ -16,11 +17,11 @@ func declarativeInvocationDocument() controlprogram.Document { 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{"healthcheck"}}, + 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: "healthcheck", Subject: "incident", Kind: "observation"}}, + Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}}, Operators: []controlprogram.Operator{{ - ID: "restart", Authority: controlprogram.AuthorityRequirement{}, Verifier: "healthcheck", ExecutionContext: "preserve", + 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"}}}, @@ -105,14 +106,77 @@ func TestGeneratedDeclarativeDriverSuspendsAnswersRestartsAndExecutes(t *testing t.Fatal(err) } - completedRaw, err := captureStdout(t, func() error { + 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 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"}, + "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"}, + "target-not-established": {func(document *controlprogram.Document) { + document.Operators[0].StateEffect.Assignments[0] = controlprogram.StateAssignment{Facet: "incident", Value: &open} + }, "do not establish its target"}, + } { + t.Run(name, func(t *testing.T) { + document := declarativeInvocationDocument() + test.mutate(&document) + compiled, err := controlprogram.Compile(document, nil) + if err != nil { + t.Fatal(err) + } + if err := validateDeclarativeFlow(compiled); err == nil || !strings.Contains(err.Error(), test.witness) { + t.Fatalf("validation = %v, want %q", err, test.witness) + } + }) + } } diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index efa7049..f9d3246 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -320,6 +320,11 @@ func declarativeFlow(document controlprogram.Document) (bool, error) { // 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") @@ -329,22 +334,146 @@ func validateDeclarativeFlow(compiled controlprogram.Compiled) error { 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.Effects) != 0 || operator.StateEffect == nil || operator.StateEffect.Kind != "assignments" || operator.ExecutionContext != "preserve" { + 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 _, 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: 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 { + 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 facets[assignment.Facet].Kind == "enum" || contract.Type.Kind != "string" { + 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)) + for _, assignment := range effect.Assignments { + 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) + } + 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 cfa3115..def3fed 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -651,6 +651,14 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque 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 @@ -691,6 +699,47 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque 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 diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index f9784de..075dd05 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -343,13 +343,13 @@ 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: "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, Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "publication_id", Producer: controlprogram.ParameterProducer{ - Kind: controlprogram.ParameterSourceState, Facet: "publication", AvailableWhen: &controlprogram.Predicate{True: &truth}, + Kind: controlprogram.ParameterSourceState, Facet: "publication_id", AvailableWhen: ptrPredicate(flowKnown("publication_id")), }}}, }}, Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ @@ -359,6 +359,11 @@ func productDeliveryDocument(programID string) controlprogram.Document { } } +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)) @@ -1200,7 +1205,7 @@ func TestFlowExecutionLeaseSerializesProjectionPublicationThroughEffect(t *testi func TestFlowValidationRejectsMissingProductionRecoveryClosure(t *testing.T) { // control-law: published-flows-close-recovery-in-the-production-composition document := productDeliveryDocument("product-delivery") - available := document.Transitions[0].Guard + 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, diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index 25c64c7..d7e6c10 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -255,9 +255,52 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T 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("") diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index c223f37..48b62a5 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -311,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, Parameters: resolved.Parameters} + 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} 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, op.Parameters = resolved.Verifier, resolved.Recovery, &resolved.StateEffect, resolved.ExecutionContext, resolved.Parameters + op.Verifier, op.Recovery, op.StateEffect, op.ExecutionContext, op.Parameters, op.Outputs, op.StateInputs = resolved.Verifier, resolved.Recovery, &resolved.StateEffect, resolved.ExecutionContext, resolved.Parameters, resolved.Outputs, resolved.StateInputs } } var err error @@ -332,6 +332,12 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi 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 op.ExecutionContext != "preserve" && op.ExecutionContext != "advance" { return nil, invalid("operators."+op.ID+".execution_context", "must be preserve or advance") } @@ -382,6 +388,8 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi 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) _ = normalizeStateEffect(expectedBinding.StateEffect, facets) if !sameOperatorSemantics(*op, *expectedBinding) { return nil, invalid("operators."+op.ID, "compiled binding semantics drift") @@ -710,7 +718,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 != "" || len(value.Parameters) != 0 + 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 } 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 ab9c249..322a604 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -188,6 +188,12 @@ func TestInvocationCompletenessChecksEntryAndStateAvailabilityPerEntry(t *testin stateBound := parameterProgram() 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") { diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 1c7797d..478c99a 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -123,6 +123,12 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { t.Fatalf("checked product-delivery IR is stale: frontend=%s checked=%s", compiled.Fingerprint, reference.Fingerprint) } var packageProducer, publicationProducer controlprogram.ParameterProducer + publicationOutputDeclared := false + for _, operator := range compiled.Document.Operators { + if operator.ID == "publication.execute" && len(operator.Outputs) == 1 && operator.Outputs[0].ID == "publication_id" && operator.Outputs[0].Type.Kind == "string" { + publicationOutputDeclared = 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") { @@ -142,6 +148,9 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { if publicationProducer.Kind != controlprogram.ParameterSourceReceipt || publicationProducer.Transition != "publication.execute" || publicationProducer.Field != "publication_id" { t.Fatalf("publication identity producer = %#v", publicationProducer) } + if !publicationOutputDeclared { + t.Fatal("publication receipt producer has no trusted runtime output declaration") + } 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) } diff --git a/boatstack/controlprogram/invocation_compile.go b/boatstack/controlprogram/invocation_compile.go index f449cda..8c0d949 100644 --- a/boatstack/controlprogram/invocation_compile.go +++ b/boatstack/controlprogram/invocation_compile.go @@ -47,6 +47,55 @@ func normalizeOperatorParameters(op *Operator, resolver BindingResolver) error { 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) { + 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 normalizeValueType(value *ValueTypeDefinition, resolver BindingResolver, field string) error { switch value.Kind { case "string": @@ -138,7 +187,7 @@ func normalizeInvocationCompleteness(document *Document, operators map[string]Op 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, resolver, field) + deps, err := normalizeProducer(&binding.Producer, contract, *transition, entries, work, facets, transitions, operators, resolver, field) if err != nil { return err } @@ -157,7 +206,7 @@ func normalizeInvocationCompleteness(document *Document, operators map[string]Op 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, resolver BindingResolver, field string) ([]string, error) { +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") } @@ -188,14 +237,34 @@ func normalizeProducer(producer *ParameterProducer, contract OperatorParameter, if err := normalizePredicate(producer.AvailableWhen, facets); err != nil { return nil, invocationIncomplete(transition.ID, contract.ID, "has an invalid state availability predicate: "+err.Error()) } - if !predicateImplies(transition.Guard, *producer.AvailableWhen) { - return nil, invocationIncomplete(transition.ID, contract.ID, "state availability is not implied by the transition guard") + 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 || !predicateImplies(transition.Guard, prior.Target) { return nil, invocationIncomplete(transition.ID, contract.ID, "receipt is not guaranteed before the consuming transition") } + 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 ParameterSourceWorkOutput: contractWork, ok := work[producer.Work] if !ok { @@ -305,6 +374,9 @@ func compatibleWorkOutputType(mediaType, wanted string) bool { } 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) { @@ -319,6 +391,35 @@ func predicateImplies(guard, condition Predicate) bool { 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 { diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 211f853..b746583 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -192,9 +192,26 @@ type Operator struct { 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"` 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"` +} + type StateEffect struct { Kind string `json:"kind"` Preconditions []StatePrecondition `json:"preconditions,omitempty"` @@ -292,6 +309,8 @@ type ResolvedOperator struct { StateEffect StateEffect ExecutionContext string Parameters []OperatorParameter + Outputs []OperatorOutput + StateInputs []OperatorStateInput } type ResolvedParameterResolver struct { diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 4e862d7..fcd7bd9 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -57,7 +57,9 @@ 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) + fingerprint, err := transitionFingerprint(transition, outputs, stateInputs) if err != nil { return controlprogram.ResolvedOperator{}, err } @@ -87,6 +89,8 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res Verifier: transition.Verifier, Recovery: string(transition.Interruption.Recovery), StateEffect: projectStateEffect(transition.StateEffect), ExecutionContext: executionContextFor(transition), Parameters: projectOperatorParameters(transition), + Outputs: outputs, + StateInputs: stateInputs, }, nil } @@ -258,8 +262,44 @@ func projectOperatorParameters(transition delivery.Transition) []controlprogram. return result } -func transitionFingerprint(value delivery.Transition) (string, error) { - encoded, err := json.Marshal(value) +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", "transaction_id")} + case "publication.reconcile": + return []controlprogram.OperatorStateInput{known("publication_id", "publication_id"), known("transaction_id", "transaction_id")} + default: + return nil + } +} + +func transitionFingerprint(value delivery.Transition, outputs []controlprogram.OperatorOutput, stateInputs []controlprogram.OperatorStateInput) (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"` + }{Transition: value, Outputs: outputs, StateInputs: stateInputs}) if err != nil { return "", err } diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 99b3fce..69a8898 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -26,7 +26,7 @@ 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, Parameters: publicationIDStateParameter(guard)}}, @@ -42,9 +42,10 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. return compiled, resolver } -func publicationIDStateParameter(available controlprogram.Predicate) []controlprogram.TransitionParameterBinding { +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", AvailableWhen: &available, + Kind: controlprogram.ParameterSourceState, Facet: "publication_id", AvailableWhen: &available, }}} } @@ -308,7 +309,7 @@ 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"}}, diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index f5253c0..dac6ddd 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -166,6 +166,11 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution updateDecisionTrace(decisionTrace, decision) return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } + 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) @@ -195,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, @@ -338,13 +366,6 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if request.Requested != request.Prescription.TransitionID { return result, fmt.Errorf("apply transition does not match prescription") } - invocationFingerprint := "" - if request.InvocationEvidence != nil { - invocationFingerprint = request.InvocationEvidence.InvocationFingerprint - } - if err := request.Prescription.ValidateInvocation(invocationFingerprint); err != nil { - return result, err - } if request.IdempotencyKey != "" { prior, ok, err := e.receipts.FindByIdempotency(ctx, request.Invocation, request.IdempotencyKey) if err != nil { @@ -375,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 } @@ -622,6 +650,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") @@ -637,11 +668,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 3318a16..ad7155d 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -437,6 +437,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) @@ -558,7 +595,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) } @@ -566,7 +622,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) diff --git a/boatstack/invocation/invocation.go b/boatstack/invocation/invocation.go index 46bcc88..26929fa 100644 --- a/boatstack/invocation/invocation.go +++ b/boatstack/invocation/invocation.go @@ -199,6 +199,16 @@ 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 { @@ -333,9 +343,44 @@ func (r InputReceipt) ValidateCurrent(context Context, contract controlprogram.O 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 { diff --git a/boatstack/invocation/invocation_test.go b/boatstack/invocation/invocation_test.go index 8d0789c..d7789d7 100644 --- a/boatstack/invocation/invocation_test.go +++ b/boatstack/invocation/invocation_test.go @@ -140,6 +140,49 @@ func TestInputReceiptRejectsCrossScopeExpiryAndControlDrift(t *testing.T) { } } +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"}) diff --git a/boatstack/testdata/control-programs/incident-response-invocation.flow.ts b/boatstack/testdata/control-programs/incident-response-invocation.flow.ts index e53bb28..1e49803 100644 --- a/boatstack/testdata/control-programs/incident-response-invocation.flow.ts +++ b/boatstack/testdata/control-programs/incident-response-invocation.flow.ts @@ -17,19 +17,19 @@ export default defineFlow({ version: "1", declarations: { authorities: ["human"], - verifiers: ["healthcheck"], + verifiers: ["state-effect"], input_resolvers: ["incident-input"], }, facets: [ facet("incident", "enum", ["open", "mitigated"]), facet("service", "enum", ["degraded", "healthy"]), ], - evidence: [evidence("healthcheck", "service", "observation")], + evidence: [evidence("state-effect", "incident", "state-observation")], operators: [operator("restart", { capabilities: [], - authority: {}, + authority: { any_of: ["human"] }, effects: [], - verifier: "healthcheck", + verifier: "state-effect", execution_context: "preserve", parameters: [ { id: "incident", type: { kind: "string" }, required: true, secret: false, allowed_sources: ["entry-input"], authority: {} }, diff --git a/boatstack/testdata/control-programs/product-delivery-a.flow.ts b/boatstack/testdata/control-programs/product-delivery-a.flow.ts index d937cb1..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, always, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; +import { all, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, @@ -22,7 +22,7 @@ export default defineFlow({ evidence: softwareDeliveryEvidence, operators: trustedOperators(lifecycle), transitions: [trustedTransition({ id: "publication.observe", priority: 77 }, { - parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: always }) }, + parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: fact("publication_id") }) }, })], targets: [marked("published-pr", all( fact("verification", ["current"]), diff --git a/boatstack/testdata/control-programs/product-delivery-b.flow.ts b/boatstack/testdata/control-programs/product-delivery-b.flow.ts index 297f259..edffab4 100644 --- a/boatstack/testdata/control-programs/product-delivery-b.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-b.flow.ts @@ -1,4 +1,4 @@ -import { all, always, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; +import { all, defineFlow, entry, fact, fromState, marked } from "@operatorstack/boatstack"; import { inbox, planInboxResolver, @@ -23,7 +23,7 @@ export default defineFlow({ operators: trustedOperators(lifecycle), transitions: [ trustedTransition({ id: "publication.observe", priority: 77 }, { - parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: always }) }, + parameters: { publication_id: fromState({ facet: "publication_id", availableWhen: fact("publication_id") }) }, }), trustedTransition({ id: "plan.abandon", priority: 31 }), ], diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json index cb15cee..a8acea3 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -1 +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":"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.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.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":{"true":true}}}]},{"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":{"true":true}}}]},{"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":{"true":true}}},{"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":{"true":true}}}]},{"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":{"true":true}}}]},{"id":"publication.observe","operator":"publication.observe","guard":{"true":true},"target":{"true":true},"priority":77,"parameters":[{"parameter":"publication_id","producer":{"kind":"receipt","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":{"true":true}}},{"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":"transaction_id","available_when":{"true":true}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"true":true}}},{"parameter":"transaction_id","producer":{"kind":"state","facet":"transaction_id","available_when":{"true":true}}}]}],"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"]}}]}}],"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"}}]} +{"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":"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.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.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":"receipt","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":"transaction_id","available_when":{"fact":{"facet":"transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"transaction_id","producer":{"kind":"state","facet":"transaction_id","available_when":{"fact":{"facet":"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"]}}]}}],"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"}}]} diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index eef1435..553553a 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -12,6 +12,7 @@ import { always, facet, + fact, fromReceipt, fromState, hostParameter, @@ -180,7 +181,7 @@ export function publicationBodyFingerprint(): ParameterProducer { } function durableValue(facet: string): ParameterProducer { - return fromState({ facet, availableWhen: always }); + return fromState({ facet, availableWhen: fact(facet) }); } function canonicalGateParameters(gate: "build" | "test" | "review" | "change" | "journey"): Record { @@ -325,15 +326,16 @@ 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 } : {}), - ...(options.parameters + ...(Object.keys(parameters).length !== 0 ? { - parameters: Object.entries(options.parameters).map( + parameters: Object.entries(parameters).map( ([parameter, producer]) => ({ parameter, producer }), ), } diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index e822944..9e85dff 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -98,6 +98,8 @@ export interface OperatorDefinition { state_effect?: StateEffectDefinition; execution_context?: "preserve" | "advance"; parameters?: OperatorParameterDefinition[]; + /** Trusted binding-owned committed transition-receipt fields. */ + outputs?: { id: string; type: ValueTypeDefinition }[]; description?: string; } From bd327498f17e8168cbb404d9117efb99ed56a9f4 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 01:29:40 +0100 Subject: [PATCH 05/15] Preserve control evidence across Flow boundaries --- .../cmd/boatstack-helper/declarative_flow.go | 128 +++++++++------ .../boatstack-helper/declarative_flow_test.go | 150 +++++++++++++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 7 + boatstack/cmd/boatstack-helper/main.go | 4 + boatstack/controlprogram/canonical.go | 3 + 5 files changed, 240 insertions(+), 52 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index b9918fd..5166a50 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -21,32 +21,32 @@ import ( "github.com/operatorstack/boatstack/boatstack/invocation" ) -const declarativeRunSchemaRevision = 2 +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"` - Parameters []invocation.ResolvedParameter `json:"parameters"` - HumanActor string `json:"human_actor,omitempty"` - Fingerprint string `json:"fingerprint"` + 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"` - 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"` - LastTransition string `json:"last_transition,omitempty"` - LastInvocation string `json:"last_invocation,omitempty"` - LastReceipt *declarativeTransitionReceipt `json:"last_receipt,omitempty"` - Fingerprint string `json:"fingerprint"` + 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 { @@ -126,16 +126,32 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o 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": runtimeContext.state.LastReceipt, + "receipt": lastDeclarativeReceipt(runtimeContext.state), "receipts": runtimeContext.state.Receipts, }, options.format) } - transition, operator, ok := selectDeclarativeTransition(compiled.Document, runtimeContext.state.Facts) - if !ok { + 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 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 @@ -190,16 +206,17 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o return fmt.Errorf("DECLARATIVE_VERIFICATION_FAILED: transition %q did not establish its compiled target", transition.ID) } candidate.StateRevision++ - candidate.LastTransition = transition.ID - candidate.LastInvocation = fresh.Ready.InvocationFingerprint 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.LastReceipt = &receipt + candidate.Receipts = append(append([]declarativeTransitionReceipt(nil), candidate.Receipts...), receipt) if err := saveDeclarativeRun(runtimeContext.statePath, candidate); err != nil { return err } @@ -213,7 +230,7 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o "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": runtimeContext.state.LastReceipt, + "receipt": lastDeclarativeReceipt(runtimeContext.state), "receipts": runtimeContext.state.Receipts, }, options.format) } @@ -271,23 +288,30 @@ func loadDeclarativeRuntimeContext(ctx context.Context, repository string, compi 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, EntryID: entry.ID, TargetID: entry.Target, StateRevision: 1, EntryInputs: provided, Facts: map[string]string{}} + 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.EntryID != entry.ID || state.TargetID != entry.Target || state.StateRevision == 0 || state.EntryInputs == nil || state.Facts == nil { + 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") } - if state.LastReceipt != nil { - receipt := *state.LastReceipt + expectedRevision := uint64(1) + priorReceiptFingerprint := "" + for index := range state.Receipts { + receipt := state.Receipts[index] fingerprint := receipt.Fingerprint receipt.Fingerprint = "" - if fingerprint == "" || fingerprint != digestDeclarative(receipt) || state.LastTransition != state.LastReceipt.TransitionID || state.LastInvocation != state.LastReceipt.InvocationFingerprint || state.StateRevision != state.LastReceipt.ResultStateRevision { + 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") @@ -339,24 +363,32 @@ func materializeDeclarativeInvocation(runtimeContext declarativeRuntimeContext, return result, materializationContext, err } -func selectDeclarativeTransition(document controlprogram.Document, facts map[string]string) (controlprogram.Transition, controlprogram.Operator, bool) { - operators := map[string]controlprogram.Operator{} - for _, operator := range document.Operators { - operators[operator.ID] = operator - } - transitions := append([]controlprogram.Transition(nil), document.Transitions...) - sort.Slice(transitions, func(i, j int) bool { - if transitions[i].Priority != transitions[j].Priority { - return transitions[i].Priority < transitions[j].Priority +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 } - return transitions[i].ID < transitions[j].ID - }) - for _, transition := range transitions { - if predicateSatisfied(transition.Guard, facts) && !predicateSatisfied(transition.Target, facts) { - return transition, operators[transition.Operator], true + if transition.Priority == bestPriority { + frontier = append(frontier, transition) } } - return controlprogram.Transition{}, controlprogram.Operator{}, false + 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 { diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index de8b1d2..7e4b7e5 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -41,6 +41,10 @@ func declarativeInvocationDocument() controlprogram.Document { } 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") @@ -50,12 +54,34 @@ func declarativeFlowRepository(t *testing.T) string { source, lock := []byte("declarative flow source\n"), []byte("lock\n") writeFixture(t, repository, sourcePath, source) writeFixture(t, repository, lockPath, lock) - writeFlowArtifact(t, repository, declarativeInvocationDocument(), sourcePath, source, 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 @@ -139,6 +165,119 @@ func TestGeneratedDeclarativeDriverSuspendsAnswersRestartsAndExecutes(t *testing } } +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 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 { @@ -166,15 +305,18 @@ func TestDeclarativeRuntimeRejectsSemanticsItCannotProve(t *testing.T) { "target-not-established": {func(document *controlprogram.Document) { document.Operators[0].StateEffect.Assignments[0] = controlprogram.StateAssignment{Facet: "incident", Value: &open} }, "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 { - t.Fatal(err) + if err == nil { + err = validateDeclarativeFlow(compiled) } - if err := validateDeclarativeFlow(compiled); err == nil || !strings.Contains(err.Error(), test.witness) { + if err == nil || !strings.Contains(err.Error(), test.witness) { t.Fatalf("validation = %v, want %q", err, test.witness) } }) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 075dd05..4e7ee2e 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1936,6 +1936,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-") { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index a61ddb9..557ddc0 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -516,6 +516,10 @@ func refreshFlowInvocation(ctx context.Context, operation surfaces.Operation, pr 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 } diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index 48b62a5..c1b51d3 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -426,6 +426,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 == "" { From 13d6847d92bbbbdbdcc5e7e4dc475081f096323a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 01:36:35 +0100 Subject: [PATCH 06/15] Stabilize cross-worktree fixture bytes --- boatstack/cmd/boatstack-helper/declarative_flow_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index 7e4b7e5..03273c1 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -50,6 +50,7 @@ func declarativeFlowRepositoryWithDocument(t *testing.T, document controlprogram 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) From 37c6a874054894263a71742ca77665795cebe220 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 01:55:14 +0100 Subject: [PATCH 07/15] Close declarative invocation control gaps --- .../cmd/boatstack-helper/declarative_flow.go | 21 +++++++ .../boatstack-helper/declarative_flow_test.go | 58 +++++++++++++++++++ .../cmd/boatstack-helper/flow_command.go | 13 +++++ boatstack/controlprogram/canonical_test.go | 31 ++++++++++ .../controlprogram/invocation_compile.go | 11 ++-- 5 files changed, 128 insertions(+), 6 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index 5166a50..e285dc6 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -136,6 +136,27 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o "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 { diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index 03273c1..0b3b7fa 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -234,6 +234,42 @@ func TestDeclarativeSelectionPreservesEqualPriorityFrontier(t *testing.T) { } } +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. @@ -294,6 +330,16 @@ func TestDeclarativeRuntimeRejectsSemanticsItCannotProve(t *testing.T) { 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"}, @@ -306,6 +352,18 @@ func TestDeclarativeRuntimeRejectsSemanticsItCannotProve(t *testing.T) { "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"}, diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index f9d3246..4ce720f 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -340,6 +340,11 @@ func validateDeclarativeFlow(compiled controlprogram.Compiled) error { 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) { @@ -358,6 +363,9 @@ func validateDeclarativeFlow(compiled controlprogram.Compiled) error { 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) } @@ -434,7 +442,9 @@ func predicateRequiresFacetValue(predicate controlprogram.Predicate, facet strin 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 } @@ -452,6 +462,9 @@ func assignmentsEstablishPredicate(effect controlprogram.StateEffect, guard, tar } 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 { diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 322a604..8d59dbc 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -133,6 +133,7 @@ func TestInvocationCompletenessRequiresExactlyOneAdmissibleProducer(t *testing.T "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) { @@ -149,8 +150,34 @@ func TestInvocationCompletenessRequiresExactlyOneAdmissibleProducer(t *testing.T } } +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 TestInvocationCompletenessRejectsUnguaranteedReceiptAndUnknownResolver(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 @@ -165,6 +192,7 @@ func TestInvocationCompletenessRejectsUnguaranteedReceiptAndUnknownResolver(t *t } 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") { @@ -174,6 +202,7 @@ func TestInvocationCompletenessRejectsUnguaranteedReceiptAndUnknownResolver(t *t 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"} @@ -187,6 +216,7 @@ func TestInvocationCompletenessChecksEntryAndStateAvailabilityPerEntry(t *testin } 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} @@ -216,6 +246,7 @@ func TestInvocationCompletenessRejectsResolverDriftCyclesAndValidatorOverride(t }, } 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") { diff --git a/boatstack/controlprogram/invocation_compile.go b/boatstack/controlprogram/invocation_compile.go index 8c0d949..0e80ca3 100644 --- a/boatstack/controlprogram/invocation_compile.go +++ b/boatstack/controlprogram/invocation_compile.go @@ -218,6 +218,9 @@ func normalizeProducer(producer *ParameterProducer, contract OperatorParameter, 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) { @@ -293,9 +296,6 @@ func normalizeProducer(producer *ParameterProducer, contract OperatorParameter, if producer.Binding.Fingerprint != "" && producer.Binding.Fingerprint != resolved.Fingerprint { return nil, invocationIncomplete(transition.ID, contract.ID, "trusted resolver fingerprint drift") } - if !authoritySatisfies(resolved.Authority, contract.Authority) { - return nil, invocationIncomplete(transition.ID, contract.ID, "trusted resolver weakens parameter authority") - } producer.Binding.Fingerprint = resolved.Fingerprint return append([]string(nil), resolved.Dependencies...), nil case ParameterSourceHostInput: @@ -441,9 +441,8 @@ func authorityListSatisfies(actual []string, required AuthorityRequirement) bool return false } -func authoritySatisfies(actual, required AuthorityRequirement) bool { - combined := append(append([]string(nil), actual.AnyOf...), actual.AllOf...) - return authorityListSatisfies(combined, required) +func parameterRequiresAuthority(requirement AuthorityRequirement) bool { + return len(requirement.AnyOf) != 0 || len(requirement.AllOf) != 0 } func parameterDependencyCycle(graph map[string][]string) string { From e8d3b41353fd89226277664798786a7ff1932b1a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 02:26:27 +0100 Subject: [PATCH 08/15] Preserve committed history across upgrade --- .../effects/history_compatibility_test.go | 218 ++++++++++++++++++ .../softwaredelivery/effects/journal.go | 22 +- .../softwaredelivery/effects/receipts.go | 26 ++- .../softwaredelivery/engine/engine.go | 10 +- .../softwaredelivery/engine/engine_test.go | 30 ++- .../softwaredelivery/protocol/admission.go | 28 ++- .../softwaredelivery/protocol/receipt.go | 28 ++- 7 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 boatstack/internal/softwaredelivery/effects/history_compatibility_test.go 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..c87e912 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go @@ -0,0 +1,218 @@ +package effects + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "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), + } + 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: "/repo", RuntimeVersion: "1.0.0", RuntimePath: "/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) { + invocation := model.InvocationContext{ + RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/feature/legacy", + ControllerID: "controller", InvokingPath: "/repo", RuntimeVersion: "1.0.0", RuntimePath: "/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 414e3ee..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,8 +140,14 @@ 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 diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index b63851e..542cf0c 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -137,8 +137,9 @@ func findLatestCommittedFlowForObjective(records []journalRecord, invocation mod } // FindLatestCommittedTransitionOutput returns one effect output only from a -// committed receipt in the current Flow lineage. It never infers an output -// from durable state or provider state. +// 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 { @@ -158,7 +159,7 @@ func findLatestCommittedTransitionOutput(records []journalRecord, flowID string, if receipt.FlowID != flowID || receipt.TransitionID != transitionID || receipt.ResultingStateRevision > maximumRevision { continue } - output, exists := receipt.EffectOutputs.Get(field) + output, exists := committedTransitionOutput(record, transitionID, field) if !exists { continue } @@ -180,6 +181,25 @@ func findLatestCommittedTransitionOutput(records []journalRecord, flowID string, 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. This permits diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index dac6ddd..f561fc4 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -519,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) } @@ -593,10 +599,6 @@ 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(), effectResult.Outputs, nil, startedAt, completedAt) if err != nil { diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index ad7155d..6f081ec 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -150,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 } @@ -634,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/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index defd556..1a1ef53 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -12,6 +12,11 @@ import ( 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"` ID string `json:"id"` @@ -447,7 +452,28 @@ 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) || (a.InvocationFingerprint != "" && len(a.InvocationFingerprint) != 64) { + 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 { diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index ec30905..85b1794 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -14,6 +14,11 @@ import ( 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 const TransitionCommitted TransitionFactKind = "transition-committed" @@ -250,7 +255,28 @@ 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 == "") || From 9eb5f8762d0d1cfe150c53f277792feb0f3fe958 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 02:40:55 +0100 Subject: [PATCH 09/15] Fix Windows compatibility fixtures --- .../softwaredelivery/effects/history_compatibility_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go index c87e912..50af362 100644 --- a/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go +++ b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go @@ -126,7 +126,7 @@ func TestPreviousCommittedHistoryRemainsReadableAndResumable(t *testing.T) { root := t.TempDir() invocation := model.InvocationContext{ RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/feature/legacy", - ControllerID: "controller", InvokingPath: "/repo", RuntimeVersion: "1.0.0", RuntimePath: "/runtime", RuntimeFingerprint: "runtime", + 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"} @@ -181,9 +181,10 @@ func TestPreviousCommittedHistoryRemainsReadableAndResumable(t *testing.T) { } 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: "/repo", RuntimeVersion: "1.0.0", RuntimePath: "/runtime", RuntimeFingerprint: "runtime", + 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"} From 0ab6fce7f1eb6dc25305444200fde211ab19581f Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 08:54:16 +0100 Subject: [PATCH 10/15] Complete product delivery invocation recovery --- .../cmd/boatstack-helper/control_bundle.go | 2 +- .../boatstack-helper/delegation_runtime.go | 6 +- .../cmd/boatstack-helper/flow_runtime.go | 31 +++- .../cmd/boatstack-helper/flow_runtime_test.go | 155 +++++++++++++++++- .../frontend_conformance_test.go | 2 +- boatstack/flow/softwaredelivery/skills.go | 51 ++++++ .../flow/softwaredelivery/skills_test.go | 41 +++++ .../flow/standard/supervisor_parity_test.go | 16 ++ boatstack/flow/standard/transitions.json | 9 +- boatstack/internal/runtime/control_bundle.go | 88 ++++++++++ .../internal/runtime/control_bundle_test.go | 85 ++++++++++ .../softwaredelivery/durable/state.go | 30 ++++ .../effects/history_compatibility_test.go | 14 ++ .../softwaredelivery/effects/receipts.go | 32 +++- .../softwaredelivery/effects/receipts_test.go | 70 +++++++- .../softwaredelivery/effects/state_reducer.go | 25 ++- .../effects/state_reducer_test.go | 31 ++++ .../softwaredelivery/plant/observer.go | 11 +- .../softwaredelivery/plant/observer_test.go | 39 +++++ .../softwaredelivery/protocol/admission.go | 8 +- .../softwaredelivery/supervisor/supervisor.go | 10 +- .../product-delivery-planning-package.flow.ts | 38 +++-- ...product-delivery-planning-package.raw.json | 2 +- .../boatstack-transition-catalog.md | 60 +++---- 24 files changed, 756 insertions(+), 100 deletions(-) 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/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 60cd35f..010c92f 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -19,7 +19,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) -func canReprojectDelegation(layout ports.ControllerLayout, invocation model.InvocationContext, objective model.Objective, prior, current delegation.Request) (bool, error) { +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 { @@ -28,7 +28,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo if prior.ControlBundleFingerprint == current.ControlBundleFingerprint { return false, nil } - return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, objective, current.ControlBundleFingerprint) + return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint) } func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lock, *surfaces.Response, error) { @@ -88,7 +88,7 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo 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, request.Objective, record.Request, delegation.Request{ + 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, diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index def3fed..a9a7867 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -179,8 +179,7 @@ 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 { - runtimeObjective := model.Objective{ID: options.objectiveID, TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: deliveryID} - reprojected, reprojectErr := canReprojectDelegation(layout, invocation, runtimeObjective, bound, delegationRequest) + reprojected, reprojectErr := canReprojectDelegation(layout, invocation, bound, delegationRequest) if reprojectErr != nil { return commandOptions{}, reprojectErr } @@ -607,10 +606,10 @@ 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 @@ -622,7 +621,14 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma 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") } @@ -801,12 +807,19 @@ func bindPrescribedRepositoryInvocation(ctx context.Context, request surfaces.Re } func resolveBoundPlan(repository string, entry controlprogram.Entry, entryObjective softwareflow.EntryObjective, options commandOptions) (string, string, error) { + if !options.activeFlowBound { + 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 4e7ee2e..c1b25be 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -523,6 +523,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, + activeFlowBound: true, }) if err != nil { t.Fatal(err) @@ -662,6 +663,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) @@ -1416,8 +1444,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"}) @@ -1427,8 +1455,7 @@ 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) } - 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, @@ -1439,11 +1466,33 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { if resumed.runID != initial.runID { t.Fatalf("run identity changed: %s != %s", resumed.runID, initial.runID) } - if source, ok := resumed.workInputs["plan"]; !ok || source.Value != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { + 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 plan != managed || deliveryID != "delivery-one" { + t.Fatalf("active plan = %q delivery = %q; want %q and delivery-one", plan, deliveryID, managed) + } +} + func TestExplainUsesFlowContextAndCreatesNoStateEffectOrReceipt(t *testing.T) { // control-law: controller-observation-cannot-change-controller repository := flowRepository(t) @@ -1628,6 +1677,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) @@ -1739,6 +1874,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) @@ -2119,8 +2255,15 @@ func TestDelegationReprojectionRequiresAChangedControlBundle(t *testing.T) { } changedInput := request changedInput.InputFingerprints = []string{"changed-plan"} - admitted, err := canReprojectDelegation(ports.ControllerLayout{}, model.InvocationContext{}, model.Objective{}, request, changedInput) + 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/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 478c99a..8d13427 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) { diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 7b10a2e..d2f3847 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -58,6 +58,7 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s diagnostics := "" workProtocol := "" inputProtocol := "" + gateEvidenceProtocol := "" entryInputProtocol := "" programReconciliation := "" publication := "" @@ -157,6 +158,43 @@ 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 @@ -257,6 +295,19 @@ func hasHostInputProducer(transitions []controlprogram.Transition) bool { 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) { for _, entry := range entries { if entry.Target == target { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index ff3eb04..3606c76 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -178,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/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 c2f69fc..a3c7fd1 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" ] } ], 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/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/history_compatibility_test.go b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go index 50af362..a4e8449 100644 --- a/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go +++ b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go @@ -12,6 +12,7 @@ import ( "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" @@ -58,6 +59,19 @@ func legacyCommittedRecord(t *testing.T, transitionID catalog.TransitionID, clas 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) diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 542cf0c..3b834ab 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -202,9 +202,11 @@ func committedTransitionOutput(record journalRecord, transitionID catalog.Transi // 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. This permits -// a fresh delegation request; it never carries prior delegation authority. -func InstallationReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, objective model.Objective, controlBundleFingerprint string) (bool, error) { +// 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) @@ -212,15 +214,14 @@ func InstallationReprojectionAdmits(layout ports.ControllerLayout, flowID string }); err != nil { return false, err } - return installationReprojectionAdmits(records, flowID, invocation, objective, controlBundleFingerprint) + return installationReprojectionAdmits(records, flowID, invocation, controlBundleFingerprint) } -func installationReprojectionAdmits(records []journalRecord, flowID string, invocation model.InvocationContext, objective model.Objective, controlBundleFingerprint string) (bool, error) { +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 || - receipt.ObjectiveID != objective.ID || receipt.TargetID != objective.TargetID || receipt.TrustedClass != objective.TrustedObjectiveClass() || receipt.DeliveryID != objective.DeliveryID { + if !installationTransition || receipt.FlowID != flowID || receipt.ControlBundleTargetFingerprint != controlBundleFingerprint { continue } authorized := sameStateLineage(record.Admission.Invocation, invocation) @@ -263,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 diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index 8a17767..ef2cfc3 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -58,12 +58,11 @@ func TestTransitionOutputComesFromLatestCommittedReceiptInFlowLineage(t *testing func TestAcceptedProgramReconciliationAuthorizesFreshDelegationRequestOnly(t *testing.T) { invoking := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main", ControllerID: "controller"} - objective := model.Objective{ID: "objective-product-delivery-run-one", TargetID: "published-pr", TrustedClass: model.ObjectiveOpenPR, DeliveryID: "one"} 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.ID, TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: objective.DeliveryID, + 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" @@ -71,14 +70,15 @@ func TestAcceptedProgramReconciliationAuthorizesFreshDelegationRequestOnly(t *te {Admission: protocol.Admission{Invocation: invoking}, Receipt: &first}, {Admission: protocol.Admission{Invocation: invoking}, Receipt: &second}, } - admitted, err := installationReprojectionAdmits(records, "run-one", invoking, objective, "bundle-c") + admitted, err := installationReprojectionAdmits(records, "run-one", invoking, "bundle-c") if err != nil || !admitted { t.Fatalf("installation reprojection admitted=%t err=%v", admitted, err) } - wrongObjective := objective - wrongObjective.DeliveryID = "other" - if admitted, err := installationReprojectionAdmits(records, "run-one", invoking, wrongObjective, "bundle-c"); err != nil || admitted { - t.Fatalf("foreign objective installation 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) } } @@ -137,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/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..88a3e22 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") diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 1a1ef53..a9aea51 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -477,7 +477,13 @@ func (a Admission) validateIdentity(schemaVersion int) error { 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/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/testdata/control-programs/product-delivery-planning-package.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts index cc7f385..74f982e 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -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 }, @@ -74,16 +75,29 @@ export default defineFlow({ work: [planning], operators: trustedOperators(lifecycle), transitions: trustedSoftwareDeliveryTransitions(lifecycle, { planningPackageWork: planning }), - 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"), - })], + 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")], + }), + ], }); diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json index a8acea3..1d4397e 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -1 +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":"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.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.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":"receipt","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":"transaction_id","available_when":{"fact":{"facet":"transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"transaction_id","producer":{"kind":"state","facet":"transaction_id","available_when":{"fact":{"facet":"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"]}}]}}],"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"}}]} +{"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":"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":"receipt","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":"transaction_id","available_when":{"fact":{"facet":"transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"transaction_id","producer":{"kind":"state","facet":"transaction_id","available_when":{"fact":{"facet":"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-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index 8d73827..aa2eca0 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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | | `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`db937c88b58b449dca1585379d3f4953cd6be4cbecdb788e702e35c234882d6a` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`. From 778b2644db405733bb429bb4c3f255791f1c8b04 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 08:59:49 +0100 Subject: [PATCH 11/15] Preserve bound plan identity on Flow resume --- .../cmd/boatstack-helper/flow_runtime.go | 12 ++++++++++ .../cmd/boatstack-helper/flow_runtime_test.go | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index a9a7867..4bedcc2 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -808,6 +808,18 @@ func bindPrescribedRepositoryInvocation(ctx context.Context, request surfaces.Re 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 diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index c1b25be..f76c3ea 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1806,6 +1806,28 @@ func TestFlowEntryPreservesSelectedPlanFilenameBeforeMaterialization(t *testing. } } +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 := resumed.workInputs["plan"]; !ok || source.Value != expected { + t.Fatalf("resumed entry input = %#v, present=%t; want %q", source, ok, expected) + } +} + func TestFlowEntryRejectsAmbiguousPlanFilenameOnResume(t *testing.T) { // control-law: a-run-cannot-resume-through-a-different-case-colliding-plan-identity repository := flowRepository(t) From bf3b209a0585195a511a6be3df8e148c74a956d3 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 16 Aug 2026 09:28:57 +0100 Subject: [PATCH 12/15] Keep invocation producers materializable --- boatstack/cmd/boatstack-helper/main_test.go | 2 +- boatstack/controlprogram/canonical_test.go | 73 +++++++++++++++++++ .../controlprogram/invocation_compile.go | 13 ++++ boatstack/flow/softwaredelivery/bindings.go | 2 +- .../flow/softwaredelivery/definition_test.go | 16 ++++ boatstack/flow/standard/transitions.json | 7 +- .../effects/command_boundary.go | 11 ++- .../effects/command_boundary_test.go | 28 +++++++ .../softwaredelivery/plant/observer_test.go | 37 ++++++++++ ...product-delivery-planning-package.raw.json | 2 +- .../boatstack-transition-catalog.md | 60 +++++++-------- .../boatstack-software-delivery/src/index.ts | 1 - 12 files changed, 210 insertions(+), 42 deletions(-) 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/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 8d59dbc..486dd4e 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -235,6 +235,79 @@ func TestInvocationCompletenessChecksEntryAndStateAvailabilityPerEntry(t *testin } } +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{ diff --git a/boatstack/controlprogram/invocation_compile.go b/boatstack/controlprogram/invocation_compile.go index 0e80ca3..a84784e 100644 --- a/boatstack/controlprogram/invocation_compile.go +++ b/boatstack/controlprogram/invocation_compile.go @@ -282,6 +282,19 @@ func normalizeProducer(producer *ParameterProducer, contract OperatorParameter, 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") diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index fcd7bd9..503cb01 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -288,7 +288,7 @@ func projectOperatorStateInputs(transition delivery.Transition) []controlprogram case "workspace.reconcile": return []controlprogram.OperatorStateInput{known("transaction_id", "transaction_id")} case "publication.reconcile": - return []controlprogram.OperatorStateInput{known("publication_id", "publication_id"), known("transaction_id", "transaction_id")} + return []controlprogram.OperatorStateInput{known("transaction_id", "transaction_id")} default: return nil } diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 69a8898..22aa1c6 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -155,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" { + 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 { diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json index a3c7fd1..3b1379a 100644 --- a/boatstack/flow/standard/transitions.json +++ b/boatstack/flow/standard/transitions.json @@ -6236,11 +6236,6 @@ ], "idempotent": true, "parameters": [ - { - "name": "publication_id", - "required": true, - "secret": false - }, { "name": "transaction_id", "required": true, @@ -6367,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/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index f5e9637..3aa9163 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -152,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 diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index cc8d6da..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) diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index 88a3e22..3b5535b 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -492,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/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json index 1d4397e..c217153 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -1 +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":"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":"receipt","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":"transaction_id","available_when":{"fact":{"facet":"transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"transaction_id","producer":{"kind":"state","facet":"transaction_id","available_when":{"fact":{"facet":"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"}}]}]} +{"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":"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":"receipt","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":"transaction_id","available_when":{"fact":{"facet":"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":"transaction_id","available_when":{"fact":{"facet":"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-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index aa2eca0..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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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`
`564279a6691d0f7cf3e92911b4bd66edf025860b643cdb57d8f47b143ded1191` | `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/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 553553a..7ef0a3e 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -256,7 +256,6 @@ export function standardSoftwareDeliveryParameters(step: TrustedStep): Record Date: Sun, 16 Aug 2026 10:03:37 +0100 Subject: [PATCH 13/15] Require explicit publication identity provenance --- .../cmd/boatstack-helper/flow_runtime.go | 2 +- .../cmd/boatstack-helper/flow_runtime_test.go | 97 +++++++++++++++++++ boatstack/controlprogram/canonical.go | 10 +- boatstack/controlprogram/canonical_test.go | 68 ++++++++++++- .../frontend_conformance_test.go | 15 +-- .../controlprogram/invocation_compile.go | 83 +++++++++++++++- boatstack/controlprogram/ir.go | 39 +++++--- boatstack/flow/softwaredelivery/bindings.go | 24 +++-- boatstack/invocation/invocation.go | 6 ++ boatstack/invocation/invocation_test.go | 33 +++++++ ...product-delivery-planning-package.raw.json | 2 +- .../boatstack-software-delivery/src/index.ts | 11 ++- packages/boatstack/src/index.ts | 24 +++++ 13 files changed, 374 insertions(+), 40 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 4bedcc2..449240b 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -361,7 +361,7 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp stateValues := softwareflow.StateParameterValues(state) receiptValues := map[string]invocation.Value{} for _, binding := range transition.Parameters { - if binding.Producer.Kind != controlprogram.ParameterSourceReceipt { + 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) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index f76c3ea..a003837 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -28,6 +28,7 @@ import ( "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" ) @@ -359,6 +360,102 @@ 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 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"}}} diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index c1b51d3..a9a37da 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -311,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, Parameters: resolved.Parameters, Outputs: resolved.Outputs, StateInputs: resolved.StateInputs} + 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, op.Parameters, op.Outputs, op.StateInputs = resolved.Verifier, resolved.Recovery, &resolved.StateEffect, resolved.ExecutionContext, resolved.Parameters, resolved.Outputs, resolved.StateInputs + 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 @@ -338,6 +338,9 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi 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") } @@ -390,6 +393,7 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi _ = 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") @@ -721,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 != "" || len(value.Parameters) != 0 || len(value.Outputs) != 0 || len(value.StateInputs) != 0 + 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 486dd4e..98bc98f 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -21,10 +21,22 @@ type delegationResolver struct { 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 @@ -175,7 +187,7 @@ func TestInvocationCompletenessRequiresAuthorityReceiptProducer(t *testing.T) { } } -func TestInvocationCompletenessRejectsUnguaranteedReceiptAndUnknownResolver(t *testing.T) { +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} @@ -187,8 +199,8 @@ func TestInvocationCompletenessRejectsUnguaranteedReceiptAndUnknownResolver(t *t 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 is not guaranteed") { - t.Fatalf("unguaranteed receipt result = %v", err) + 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() @@ -200,6 +212,56 @@ func TestInvocationCompletenessRejectsUnguaranteedReceiptAndUnknownResolver(t *t } } +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{} diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 8d13427..5989d1b 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -123,10 +123,13 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { t.Fatalf("checked product-delivery IR is stale: frontend=%s checked=%s", compiled.Fingerprint, reference.Fingerprint) } var packageProducer, publicationProducer controlprogram.ParameterProducer - publicationOutputDeclared := false + publicationStateInputDeclared, publicationReceiptInputDeclared := false, false for _, operator := range compiled.Document.Operators { - if operator.ID == "publication.execute" && len(operator.Outputs) == 1 && operator.Outputs[0].ID == "publication_id" && operator.Outputs[0].Type.Kind == "string" { - publicationOutputDeclared = true + 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 { @@ -145,11 +148,11 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { 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.ParameterSourceReceipt || publicationProducer.Transition != "publication.execute" || publicationProducer.Field != "publication_id" { + 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 !publicationOutputDeclared { - t.Fatal("publication receipt producer has no trusted runtime output declaration") + 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) diff --git a/boatstack/controlprogram/invocation_compile.go b/boatstack/controlprogram/invocation_compile.go index a84784e..6ec2e9d 100644 --- a/boatstack/controlprogram/invocation_compile.go +++ b/boatstack/controlprogram/invocation_compile.go @@ -8,7 +8,7 @@ import ( var parameterSourceKinds = map[ParameterSourceKind]bool{ ParameterSourceEntryInput: true, ParameterSourceState: true, - ParameterSourceReceipt: true, ParameterSourceWorkOutput: true, + ParameterSourceReceipt: true, ParameterSourceStateOrReceipt: true, ParameterSourceWorkOutput: true, ParameterSourceTrustedResolver: true, ParameterSourceHostInput: true, } @@ -80,7 +80,7 @@ func normalizeOperatorStateInputs(op *Operator, facets map[string]Facet) error { 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) { + 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 @@ -96,6 +96,34 @@ func normalizeOperatorStateInputs(op *Operator, facets map[string]Facet) error { 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": @@ -255,8 +283,18 @@ func normalizeProducer(producer *ParameterProducer, contract OperatorParameter, } case ParameterSourceReceipt: prior, ok := transitions[producer.Transition] - if !ok || !validID(producer.Field) || prior.Priority >= transition.Priority || !predicateImplies(transition.Guard, prior.Target) { - return nil, invocationIncomplete(transition.ID, contract.ID, "receipt is not guaranteed before the consuming 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 @@ -268,6 +306,41 @@ func normalizeProducer(producer *ParameterProducer, contract OperatorParameter, 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 { @@ -333,6 +406,8 @@ func rejectProducerExtraneousFields(value ParameterProducer, field string) error 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: diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index b746583..73d4c80 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -118,6 +118,7 @@ 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" @@ -182,19 +183,20 @@ type TransitionParameterBinding struct { } 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"` - Parameters []OperatorParameter `json:"parameters,omitempty"` - Outputs []OperatorOutput `json:"outputs,omitempty"` - StateInputs []OperatorStateInput `json:"state_inputs,omitempty"` - 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 @@ -212,6 +214,16 @@ type OperatorStateInput struct { 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 { Kind string `json:"kind"` Preconditions []StatePrecondition `json:"preconditions,omitempty"` @@ -311,6 +323,7 @@ type ResolvedOperator struct { Parameters []OperatorParameter Outputs []OperatorOutput StateInputs []OperatorStateInput + ReceiptInputs []OperatorReceiptInput } type ResolvedParameterResolver struct { diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 503cb01..ae9a14b 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -59,7 +59,8 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res transition.ExecutionContext = executionContextFor(transition) outputs := projectOperatorOutputs(transition) stateInputs := projectOperatorStateInputs(transition) - fingerprint, err := transitionFingerprint(transition, outputs, stateInputs) + receiptInputs := projectOperatorReceiptInputs(transition) + fingerprint, err := transitionFingerprint(transition, outputs, stateInputs, receiptInputs) if err != nil { return controlprogram.ResolvedOperator{}, err } @@ -91,6 +92,7 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res Parameters: projectOperatorParameters(transition), Outputs: outputs, StateInputs: stateInputs, + ReceiptInputs: receiptInputs, }, nil } @@ -242,7 +244,7 @@ func projectOperatorParameters(transition delivery.Transition) []controlprogram. 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.ParameterSourceReceipt, controlprogram.ParameterSourceState} + 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"): @@ -294,12 +296,20 @@ func projectOperatorStateInputs(transition delivery.Transition) []controlprogram } } -func transitionFingerprint(value delivery.Transition, outputs []controlprogram.OperatorOutput, stateInputs []controlprogram.OperatorStateInput) (string, error) { +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"` - }{Transition: value, Outputs: outputs, StateInputs: stateInputs}) + 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/invocation/invocation.go b/boatstack/invocation/invocation.go index 26929fa..abf4e0d 100644 --- a/boatstack/invocation/invocation.go +++ b/boatstack/invocation/invocation.go @@ -287,6 +287,12 @@ func materializeOne(contract controlprogram.OperatorParameter, producer controlp 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 diff --git a/boatstack/invocation/invocation_test.go b/boatstack/invocation/invocation_test.go index d7789d7..964990f 100644 --- a/boatstack/invocation/invocation_test.go +++ b/boatstack/invocation/invocation_test.go @@ -91,6 +91,39 @@ func TestZeroParameterTransitionStillProducesInvocationEvidence(t *testing.T) { } } +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()}} diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json index c217153..8b42f18 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -1 +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":"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":"receipt","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":"transaction_id","available_when":{"fact":{"facet":"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":"transaction_id","available_when":{"fact":{"facet":"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"}}]}]} +{"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":"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":"transaction_id","available_when":{"fact":{"facet":"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":"transaction_id","available_when":{"fact":{"facet":"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/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 7ef0a3e..1c8d378 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -13,8 +13,8 @@ import { always, facet, fact, - fromReceipt, fromState, + fromStateOrReceipt, hostParameter, operator, trustedParameterResolver, @@ -245,7 +245,14 @@ export function standardSoftwareDeliveryParameters(step: TrustedStep): Record Date: Sun, 16 Aug 2026 10:25:45 +0100 Subject: [PATCH 14/15] Bind reconciliation to observed recovery identity --- .../cmd/boatstack-helper/flow_runtime.go | 14 ++ .../cmd/boatstack-helper/flow_runtime_test.go | 134 ++++++++++++++++++ boatstack/flow/softwaredelivery/bindings.go | 4 +- .../flow/softwaredelivery/definition_test.go | 2 +- .../softwaredelivery/parameter_runtime.go | 30 ++++ .../parameter_runtime_test.go | 18 +++ ...product-delivery-planning-package.raw.json | 2 +- .../boatstack-software-delivery/src/index.ts | 13 +- 8 files changed, 211 insertions(+), 6 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 449240b..3de2ddd 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -23,6 +23,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/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" @@ -359,6 +360,19 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp entryInputs[id] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value.Value, Provenance: "entry-input:" + value.Fingerprint} } 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 { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index a003837..3a9d7bd 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -366,6 +366,140 @@ func (r publicationReconcileRunner) CombinedOutput(context.Context, string, stri 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 diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index ae9a14b..1c53319 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -288,9 +288,9 @@ func projectOperatorStateInputs(transition delivery.Transition) []controlprogram case "publication.observe", "publication.correct": return []controlprogram.OperatorStateInput{known("publication_id", "publication_id")} case "workspace.reconcile": - return []controlprogram.OperatorStateInput{known("transaction_id", "transaction_id")} + return []controlprogram.OperatorStateInput{known("transaction_id", RecoveryTransactionFacet)} case "publication.reconcile": - return []controlprogram.OperatorStateInput{known("transaction_id", "transaction_id")} + return []controlprogram.OperatorStateInput{known("transaction_id", RecoveryTransactionFacet)} default: return nil } diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 22aa1c6..3276afe 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -166,7 +166,7 @@ func TestPublicationReconcileBindingDoesNotRequireUncommittedPublicationOutput(t t.Fatal(err) } if len(resolved.Parameters) != 1 || resolved.Parameters[0].ID != "transaction_id" || - len(resolved.StateInputs) != 1 || resolved.StateInputs[0].Parameter != "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) } } diff --git a/boatstack/flow/softwaredelivery/parameter_runtime.go b/boatstack/flow/softwaredelivery/parameter_runtime.go index 8e78c5a..8af2121 100644 --- a/boatstack/flow/softwaredelivery/parameter_runtime.go +++ b/boatstack/flow/softwaredelivery/parameter_runtime.go @@ -13,6 +13,7 @@ import ( "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" @@ -21,6 +22,8 @@ import ( 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. @@ -50,6 +53,33 @@ func StateParameterValues(state durable.State) map[string]invocation.Value { 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 { diff --git a/boatstack/flow/softwaredelivery/parameter_runtime_test.go b/boatstack/flow/softwaredelivery/parameter_runtime_test.go index 71a7d05..aa54765 100644 --- a/boatstack/flow/softwaredelivery/parameter_runtime_test.go +++ b/boatstack/flow/softwaredelivery/parameter_runtime_test.go @@ -6,8 +6,10 @@ import ( "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" ) @@ -56,3 +58,19 @@ func TestAdmittedPlanningPackageFingerprintMaterializesManifestIdentity(t *testi 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/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json index 8b42f18..fae7581 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -1 +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":"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":"transaction_id","available_when":{"fact":{"facet":"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":"transaction_id","available_when":{"fact":{"facet":"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"}}]}]} +{"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/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 1c8d378..41cb29a 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -58,6 +58,7 @@ export const softwareDeliveryFacets: FacetDefinition[] = [ "source_revision", "preview_fingerprint", "publication_id", + "recovery_transaction_id", "transaction_id", "transaction_transition", "workspace_base_ref", @@ -184,6 +185,14 @@ 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(), @@ -260,10 +269,10 @@ export function standardSoftwareDeliveryParameters(step: TrustedStep): Record Date: Sun, 16 Aug 2026 10:40:34 +0100 Subject: [PATCH 15/15] Reject invalid declarative assignment types --- .../boatstack-helper/declarative_flow_test.go | 35 +++++++++++++++++++ .../cmd/boatstack-helper/flow_command.go | 9 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index 0b3b7fa..6941b9e 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -349,6 +349,17 @@ func TestDeclarativeRuntimeRejectsSemanticsItCannotProve(t *testing.T) { "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"}, @@ -381,3 +392,27 @@ func TestDeclarativeRuntimeRejectsSemanticsItCannotProve(t *testing.T) { }) } } + +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/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 4ce720f..de04d46 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -376,6 +376,13 @@ func validateDeclarativeFlow(compiled controlprogram.Compiled) error { } } 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 } @@ -386,7 +393,7 @@ func validateDeclarativeFlow(compiled controlprogram.Compiled) error { 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 facets[assignment.Facet].Kind == "enum" || contract.Type.Kind != "string" { + 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) } }