From d9c354cfbd4ac6b0921b9228bca2783f3ba06748 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 15:51:25 +0100 Subject: [PATCH 1/7] Resolve foreground work inputs from committed results --- .../cmd/boatstack-helper/flow_runtime.go | 263 ++++++++++++++++-- .../cmd/boatstack-helper/flow_runtime_test.go | 8 +- boatstack/cmd/boatstack-helper/main.go | 1 + .../work_output_invocation_test.go | 6 +- boatstack/controlprogram/artifact.go | 2 +- boatstack/controlprogram/canonical.go | 70 ++++- boatstack/controlprogram/canonical_test.go | 49 +++- boatstack/controlprogram/ir.go | 6 +- boatstack/flow/softwaredelivery/definition.go | 6 +- .../flow/softwaredelivery/definition_test.go | 2 +- .../flow/softwaredelivery/work_package.go | 2 +- .../softwaredelivery/workpackage/package.go | 12 +- .../softwaredelivery/catalog/transition.go | 10 +- .../softwaredelivery/effects/artifacts.go | 2 +- .../softwaredelivery/effects/receipts.go | 112 ++++++++ .../softwaredelivery/effects/receipts_test.go | 63 +++++ .../foregroundwork/manager.go | 35 ++- .../foregroundwork/manager_test.go | 51 +++- .../softwaredelivery/protocol/work.go | 100 ++++++- .../softwaredelivery/protocol/work_test.go | 27 ++ .../softwaredelivery/surfaces/protocol.go | 5 +- .../incident-response.raw.json | 2 +- ...product-delivery-planning-package.raw.json | 2 +- docs/architecture/compiler-and-artifacts.md | 4 +- ...vocation-parameters-and-foreground-work.md | 4 +- docs/control-program-ir.md | 2 +- .../planning-and-foreground-work.md | 5 + docs/typescript/base-sdk.md | 7 +- packages/boatstack/src/index.ts | 27 +- ...26-08-19-resolve-foreground-work-inputs.md | 3 + 30 files changed, 795 insertions(+), 93 deletions(-) create mode 100644 release-notes/2026-08-19-resolve-foreground-work-inputs.md diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 82a22df0..38ce3bb4 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -168,10 +168,21 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } else if options.runID == "" { return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active abandonment has no committed run identity") } + options.entryInputValues = map[string]protocol.WorkInputValue{} options.workInputs = map[string]protocol.WorkInputValue{} for _, input := range entry.Inputs { if plan != "" { - options.workInputs[input.ID] = protocol.WorkInputValue{Value: plan, Fingerprint: planFingerprint} + options.entryInputValues[input.ID] = protocol.WorkInputValue{Value: plan, Fingerprint: planFingerprint} + } + } + for _, work := range compiled.Document.Work { + for _, input := range work.Inputs { + if input.Producer.Kind != controlprogram.ParameterSourceEntryInput { + continue + } + if value, found := options.entryInputValues[input.Producer.Input]; found { + options.workInputs[foregroundWorkInputKey(work.ID, input.ID)] = value + } } } options.repository = repository @@ -191,6 +202,10 @@ 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") } + options, err = bindAvailableCommittedWorkInputs(ctx, compiled, entry, options) + if err != nil { + return commandOptions{}, err + } bundle, bundleFingerprint, err := bindControlBundle(ctx, repository, "", nil) if err != nil { return commandOptions{}, err @@ -281,7 +296,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, RunID: options.runID, ProgramID: options.programID, ProgramFingerprint: compiled.Fingerprint, ControlBundleFingerprint: bundleFingerprint, EntryID: options.entryID, TargetID: string(objective.TargetID), ObjectiveID: options.objectiveID, DeliveryID: deliveryID, - InputFingerprints: entryInputFingerprints(options.workInputs), RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, + InputFingerprints: entryInputFingerprints(options.entryInputValues), RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, InitialWorktreeID: invocation.WorktreeID, InitialRef: invocation.Ref, EntryActivationAuthorities: append([]string(nil), entry.Requires.Authorities...), BindingFingerprint: bindingFingerprint, RequestedAuthorities: delegatedAuthorities, @@ -756,7 +771,7 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp 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 { + for id, value := range options.entryInputValues { entryInputs[id] = invocation.Value{Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Canonical: value.Value, Provenance: "entry-input:" + value.Fingerprint} } stateValues := softwareflow.StateParameterValues(state) @@ -793,6 +808,10 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp for _, work := range compiled.Document.Work { workByID[work.ID] = work } + options.workInputs, err = resolveForegroundWorkInputs(layout, state.Revision, invocationContext, compiled, entry, *transition, options, workByID) + if err != nil { + return commandOptions{}, err + } for _, binding := range transition.Parameters { if binding.Producer.Kind != controlprogram.ParameterSourceWorkOutput { continue @@ -801,34 +820,47 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp 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) + producerTransition, producerErr := uniqueWorkProducerTransition(compiled.Document.Transitions, work.ID) + if producerErr != nil { + return commandOptions{}, producerErr + } + var output protocol.WorkOutputEvidence + var resultFingerprint, provenance string + if producerTransition.ID == transition.ID { + 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 } - 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 + 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 + } + var found bool + output, found = workOutputByID(record.Result.Outputs, binding.Producer.Output) + if !found { + return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q lacks output %q", work.ID, binding.Producer.Output) } - foundOutput = true - kind := "string" - if output.MediaType == "application/json" { - kind = "json" + resultFingerprint, provenance = record.Result.ResultFingerprint, "work-output-candidate:"+output.SHA256 + } else { + committed, found, resolveErr := resolveCommittedWorkOutput(layout, state.Revision, invocationContext, compiled, entry, options, producerTransition, work, binding.Producer.Output) + if resolveErr != nil { + return commandOptions{}, resolveErr } - 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 !found { + return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q has no applicable committed result", work.ID) + } + output, resultFingerprint, provenance = committed.Output, committed.Work.ResultFingerprint, "work-output-committed:"+committed.Receipt.ID } - if !foundOutput { - return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q lacks output %q", work.ID, binding.Producer.Output) + 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: provenance, ProducerFingerprint: resultFingerprint} } store := invocation.Store{Root: layout.FlowRoot, Writer: effects.NewRuntimeStore()} inputReceipts, err := store.LoadReceipts(options.runID, transition.ID) @@ -892,6 +924,177 @@ func transitionUsesHostInput(transition controlprogram.Transition) bool { return false } +func uniqueWorkProducerTransition(transitions []controlprogram.Transition, workID string) (controlprogram.Transition, error) { + var producer controlprogram.Transition + for _, candidate := range transitions { + if candidate.Work != workID { + continue + } + if producer.ID != "" { + return controlprogram.Transition{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q has ambiguous producer transitions", workID) + } + producer = candidate + } + if producer.ID == "" { + return controlprogram.Transition{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q has no producer transition", workID) + } + return producer, nil +} + +func workOutputByID(outputs []protocol.WorkOutputEvidence, outputID string) (protocol.WorkOutputEvidence, bool) { + for _, output := range outputs { + if output.ID == outputID { + return output, true + } + } + return protocol.WorkOutputEvidence{}, false +} + +func foregroundWorkInputKey(workID, inputID string) string { return workID + "/" + inputID } + +func bindAvailableCommittedWorkInputs(ctx context.Context, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions) (commandOptions, error) { + hasCommittedInputs := false + for _, work := range compiled.Document.Work { + for _, input := range work.Inputs { + if input.Producer.Kind == controlprogram.ParameterSourceWorkOutput { + hasCommittedInputs = true + break + } + } + } + if !hasCommittedInputs { + return options, nil + } + host := options.host + if host == "" { + host = "cli" + } + resolver, err := plant.NewResolver("") + if err != nil { + return commandOptions{}, err + } + current, err := resolver.ResolveInvocation(ctx, options.repository, host, "flow-work-inputs-"+options.runID) + if err != nil { + return commandOptions{}, err + } + layout, current, err := resolver.ResolveLayout(ctx, current) + if err != nil { + return commandOptions{}, err + } + raw, err := os.ReadFile(layout.StatePath) + if os.IsNotExist(err) { + return options, nil + } + if err != nil { + return commandOptions{}, err + } + state, err := durable.DecodeState(raw) + if err != nil { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: decode durable state: %w", err) + } + workByID := map[string]controlprogram.WorkContract{} + for _, work := range compiled.Document.Work { + workByID[work.ID] = work + } + for _, consumer := range compiled.Document.Work { + for _, input := range consumer.Inputs { + if input.Producer.Kind != controlprogram.ParameterSourceWorkOutput { + continue + } + source, ok := workByID[input.Producer.Work] + if !ok { + return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q references unknown work %q", consumer.ID, input.ID, input.Producer.Work) + } + producer, producerErr := uniqueWorkProducerTransition(compiled.Document.Transitions, source.ID) + if producerErr != nil { + return commandOptions{}, producerErr + } + committed, found, resolveErr := resolveCommittedWorkOutput(layout, state.Revision, current, compiled, entry, options, producer, source, input.Producer.Output) + if resolveErr != nil { + return commandOptions{}, resolveErr + } + if !found { + continue + } + provenance := protocol.WorkOutputProvenance{ + ReceiptID: committed.Receipt.ID, TransitionID: committed.Receipt.TransitionID, WorkID: committed.Work.ContractID, OutputID: committed.Output.ID, + ResultFingerprint: committed.Work.ResultFingerprint, ContractFingerprint: committed.Work.ContractFingerprint, OutputSHA256: committed.Output.SHA256, + } + options.workInputs[foregroundWorkInputKey(consumer.ID, input.ID)] = protocol.WorkInputValue{Value: committed.Output.Content, Fingerprint: committed.Output.SHA256, WorkOutput: &provenance} + } + } + return options, nil +} + +func resolveCommittedWorkOutput(layout ports.ControllerLayout, maximumRevision uint64, current model.InvocationContext, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions, producerTransition controlprogram.Transition, work controlprogram.WorkContract, outputID string) (effects.CommittedWorkOutput, bool, error) { + contract, err := softwareflow.RuntimeWorkContract(work) + if err != nil { + return effects.CommittedWorkOutput{}, false, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: %w", err) + } + objective := model.Objective{ + ID: options.objectiveID, TargetID: model.TargetID(options.targetID), TrustedClass: model.TargetID(options.trustedObjectiveClass), DeliveryID: options.deliveryID, + } + committed, found, err := effects.FindApplicableCommittedWorkOutput(layout, effects.CommittedWorkOutputSelector{ + FlowID: options.runID, ProgramID: compiled.Document.Program.ID, ProgramFingerprint: compiled.Fingerprint, EntryID: entry.ID, + Objective: objective, Invocation: current, TransitionID: catalog.TransitionID(producerTransition.ID), Work: *contract, OutputID: outputID, MaximumRevision: maximumRevision, + }) + if err != nil { + return effects.CommittedWorkOutput{}, false, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q output %q: %w", work.ID, outputID, err) + } + return committed, found, nil +} + +func resolveForegroundWorkInputs(layout ports.ControllerLayout, maximumRevision uint64, current model.InvocationContext, compiled controlprogram.Compiled, entry controlprogram.Entry, transition controlprogram.Transition, options commandOptions, workByID map[string]controlprogram.WorkContract) (map[string]protocol.WorkInputValue, error) { + values := map[string]protocol.WorkInputValue{} + for key, value := range options.workInputs { + values[key] = value + } + if transition.Work == "" { + return values, nil + } + work, ok := workByID[transition.Work] + if !ok { + return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: transition %q references unknown work %q", transition.ID, transition.Work) + } + for _, input := range work.Inputs { + switch input.Producer.Kind { + case controlprogram.ParameterSourceEntryInput: + value, found := options.entryInputValues[input.Producer.Input] + if !found { + return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q entry input %q is unavailable", work.ID, input.Producer.Input) + } + values[foregroundWorkInputKey(work.ID, input.ID)] = value + case controlprogram.ParameterSourceWorkOutput: + source, declared := workByID[input.Producer.Work] + if !declared { + return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q references unknown work %q", work.ID, input.ID, input.Producer.Work) + } + producer, producerErr := uniqueWorkProducerTransition(compiled.Document.Transitions, source.ID) + if producerErr != nil { + return nil, producerErr + } + if producer.ID == transition.ID { + return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q cannot consume its own candidate result", work.ID, input.ID) + } + committed, found, resolveErr := resolveCommittedWorkOutput(layout, maximumRevision, current, compiled, entry, options, producer, source, input.Producer.Output) + if resolveErr != nil { + return nil, resolveErr + } + if !found { + return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q has no applicable committed producer result", work.ID, input.ID) + } + provenance := protocol.WorkOutputProvenance{ + ReceiptID: committed.Receipt.ID, TransitionID: committed.Receipt.TransitionID, WorkID: committed.Work.ContractID, OutputID: committed.Output.ID, + ResultFingerprint: committed.Work.ResultFingerprint, ContractFingerprint: committed.Work.ContractFingerprint, OutputSHA256: committed.Output.SHA256, + } + values[foregroundWorkInputKey(work.ID, input.ID)] = protocol.WorkInputValue{Value: committed.Output.Content, Fingerprint: committed.Output.SHA256, WorkOutput: &provenance} + default: + return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q has an unsupported producer", work.ID, input.ID) + } + } + return values, 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 { @@ -916,9 +1119,9 @@ func validateWorkOutputProducer(record foregroundwork.Record, work controlprogra } expectedInputs := map[string]protocol.WorkInputValue{} for _, input := range work.Inputs { - value, ok := options.workInputs[input.EntryInput] + value, ok := options.workInputs[foregroundWorkInputKey(work.ID, input.ID)] if !ok { - return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q entry input %q is unavailable", work.ID, input.EntryInput) + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q is unavailable", work.ID, input.ID) } expectedInputs[input.ID] = value } @@ -927,7 +1130,9 @@ func validateWorkOutputProducer(record foregroundwork.Record, work controlprogra } for _, input := range request.Inputs { expected, ok := expectedInputs[input.ID] - if !ok || input.Value != expected.Value || input.Fingerprint != expected.Fingerprint { + provenanceMatches := input.WorkOutput == nil && expected.WorkOutput == nil || + input.WorkOutput != nil && expected.WorkOutput != nil && *input.WorkOutput == *expected.WorkOutput + if !ok || input.Value != expected.Value || input.Fingerprint != expected.Fingerprint || !provenanceMatches { return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input binding changed", work.ID) } } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 92098f4d..e1c68bdd 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1162,7 +1162,7 @@ func TestFlowRunIdentitySurvivesWorkspaceTransfer(t *testing.T) { if resumed.runID != initial.runID { t.Fatalf("workspace transfer changed Flow run identity: %q != %q", 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.entryInputValues["plan"]; !ok || source.Value != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { t.Fatalf("destination entry input = %#v, %t", source, ok) } @@ -2449,7 +2449,7 @@ func TestFreshFlowEntryPreservesInboxProducerAcrossDelegationContext(t *testing. 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", "inbox", "delivery-one.md") { + if source, ok := resumed.entryInputValues["plan"]; !ok || source.Value != filepath.Join(resumed.repository, ".boatstack", "plans", "inbox", "delivery-one.md") { t.Fatalf("resumed entry input = %#v, present=%t", source, ok) } } @@ -2784,7 +2784,7 @@ func TestFlowEntryPreservesSelectedPlanFilenameBeforeMaterialization(t *testing. t.Fatal(err) } expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery.MD") - if source, ok := resumed.workInputs["plan"]; !ok || source.Value != expected { + if source, ok := resumed.entryInputValues["plan"]; !ok || source.Value != expected { t.Fatalf("resumed entry input = %#v, present=%t; want %q", source, ok, expected) } } @@ -2806,7 +2806,7 @@ func TestFlowEntryResumeIgnoresUnrelatedNewInboxPlan(t *testing.T) { t.Fatal(err) } expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery.md") - if source, ok := resumed.workInputs["plan"]; !ok || source.Value != expected { + if source, ok := resumed.entryInputValues["plan"]; !ok || source.Value != expected { t.Fatalf("resumed entry input = %#v, present=%t; want %q", source, ok, expected) } } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 5d5a02c7..ddab518e 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -86,6 +86,7 @@ type commandOptions struct { delegationRequest delegation.Request delegationReprojection bool delegationRequestProjection bool + entryInputValues map[string]protocol.WorkInputValue workInputs map[string]protocol.WorkInputValue workID string workQuestionPrompt string diff --git a/boatstack/cmd/boatstack-helper/work_output_invocation_test.go b/boatstack/cmd/boatstack-helper/work_output_invocation_test.go index d21c7724..21194037 100644 --- a/boatstack/cmd/boatstack-helper/work_output_invocation_test.go +++ b/boatstack/cmd/boatstack-helper/work_output_invocation_test.go @@ -16,7 +16,7 @@ func TestWorkOutputProducerRejectsStaleExecutionScope(t *testing.T) { // 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"}}, + Inputs: []controlprogram.WorkInput{{ID: "plan", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "plan"}}}, Outputs: []controlprogram.WorkOutput{{ID: "result", Path: "result.json", MediaType: "application/json", Required: true, MaxBytes: 1024}}, } contract, err := softwareflow.RuntimeWorkContract(work) @@ -41,7 +41,7 @@ func TestWorkOutputProducerRejectsStaleExecutionScope(t *testing.T) { } 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)}}, + TransitionID: "planning.admit", Contract: *contract, Inputs: []foregroundwork.InputBinding{{ID: "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} @@ -50,7 +50,7 @@ func TestWorkOutputProducerRejectsStaleExecutionScope(t *testing.T) { 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)}}} + options := commandOptions{runID: "run-1", objectiveID: objective.ID, targetID: string(objective.TargetID), deliveryID: objective.DeliveryID, workInputs: map[string]protocol.WorkInputValue{"planning-package/plan": {Value: "plan.md", Fingerprint: strings.Repeat("e", 64)}}} if err := validateWorkOutputProducer(record, work, compiled, entry, options, current); err != nil { t.Fatal(err) } diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 6ddcb8f1..44bdde8e 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -17,7 +17,7 @@ import ( const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 7 + ArtifactSchemaRevision = 8 ) type Artifact struct { diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index c6c59bd4..4221a6b9 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -150,6 +150,9 @@ func compile(document Document, resolver BindingResolver, assets AssetResolver) if err := normalizeTransitions(&document, facets, operators, work); err != nil { return Compiled{}, err } + if err := normalizeWorkInputProducers(&document, work); err != nil { + return Compiled{}, err + } if err := normalizeInvocationCompleteness(&document, operators, work, facets, resolver); err != nil { return Compiled{}, err } @@ -170,12 +173,6 @@ func compile(document Document, resolver BindingResolver, assets AssetResolver) } func normalizeWork(document *Document, assets AssetResolver) (map[string]WorkContract, error) { - entryInputs := map[string]bool{} - for _, entry := range document.Entries { - for _, input := range entry.Inputs { - entryInputs[input.ID] = true - } - } seen := map[string]WorkContract{} for i := range document.Work { contract := &document.Work[i] @@ -191,7 +188,7 @@ func normalizeWork(document *Document, assets AssetResolver) (map[string]WorkCon inputIDs := map[string]bool{} for j := range contract.Inputs { input := &contract.Inputs[j] - if !validID(input.ID) || !validID(input.EntryInput) || inputIDs[input.ID] || !entryInputs[input.EntryInput] { + if !validID(input.ID) || inputIDs[input.ID] { return nil, invalid(fmt.Sprintf("work.%s.inputs[%d]", contract.ID, j), "invalid or duplicate work input") } inputIDs[input.ID] = true @@ -257,6 +254,65 @@ func normalizeWork(document *Document, assets AssetResolver) (map[string]WorkCon return seen, nil } +func normalizeWorkInputProducers(document *Document, work map[string]WorkContract) error { + entryInputs := map[string]bool{} + for _, entry := range document.Entries { + for _, input := range entry.Inputs { + entryInputs[input.ID] = true + } + } + transitionsByWork := map[string][]Transition{} + for _, transition := range document.Transitions { + if transition.Work != "" { + transitionsByWork[transition.Work] = append(transitionsByWork[transition.Work], transition) + } + } + for i := range document.Work { + contract := &document.Work[i] + for j := range contract.Inputs { + input := &contract.Inputs[j] + field := fmt.Sprintf("work.%s.inputs[%d].producer", contract.ID, j) + if input.Producer.Kind != ParameterSourceEntryInput && input.Producer.Kind != ParameterSourceWorkOutput { + return invalid(field, "foreground-work inputs require an entry-input or work-output producer") + } + if err := rejectProducerExtraneousFields(input.Producer, field); err != nil { + return err + } + switch input.Producer.Kind { + case ParameterSourceEntryInput: + if !validID(input.Producer.Input) || !entryInputs[input.Producer.Input] { + return invalid(field, "references an unknown entry input") + } + case ParameterSourceWorkOutput: + source, ok := work[input.Producer.Work] + if !ok || source.ID == contract.ID || !validID(input.Producer.Output) { + return invalid(field, "references an unknown or self-produced foreground-work output") + } + found := false + for _, output := range source.Outputs { + if output.ID == input.Producer.Output { + found = output.Required + } + } + if !found { + return invalid(field, "references an optional or unknown foreground-work output") + } + producers := transitionsByWork[source.ID] + if len(producers) != 1 { + return invalid(field, "work output does not have exactly one producer transition") + } + for _, consumer := range transitionsByWork[contract.ID] { + producer := producers[0] + if producer.ID == consumer.ID || producer.Priority >= consumer.Priority || !predicateImplies(consumer.Guard, producer.Target) { + return invalid(field, "work output is not guaranteed before the consuming foreground work") + } + } + } + } + } + return nil +} + func resolveWorkAsset(asset *WorkAsset, resolver AssetResolver, limit int64, field string) error { if !safeRelative(asset.Path) { return invalid(field+".path", "must be a canonical repository-relative path") diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 18f09d79..50431953 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -478,7 +478,7 @@ func incidentWorkProgram() controlprogram.Document { digest := sha256.Sum256([]byte(instructions)) document.Work = []controlprogram.WorkContract{{ ID: "diagnose", Instructions: controlprogram.WorkAsset{Path: "instructions.md", SHA256: hex.EncodeToString(digest[:]), Content: instructions}, - Inputs: []controlprogram.WorkInput{{ID: "incident", EntryInput: "incident"}}, + Inputs: []controlprogram.WorkInput{{ID: "incident", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "incident"}}}, Outputs: []controlprogram.WorkOutput{{ID: "diagnosis", Path: "diagnosis.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}, Description: "presentation only", }} @@ -552,7 +552,7 @@ func TestForegroundWorkRejectsUnboundAssetsInputsAndTransitions(t *testing.T) { "unresolved-asset": func(value *controlprogram.Document) { value.Work[0].Instructions.Content, value.Work[0].Instructions.SHA256 = "", "" }, - "unknown-entry-input": func(value *controlprogram.Document) { value.Work[0].Inputs[0].EntryInput = "missing" }, + "unknown-entry-input": func(value *controlprogram.Document) { value.Work[0].Inputs[0].Producer.Input = "missing" }, "unreferenced-work": func(value *controlprogram.Document) { value.Transitions[0].Work = "" }, "unknown-work": func(value *controlprogram.Document) { value.Transitions[0].Work = "missing" }, } { @@ -566,6 +566,51 @@ func TestForegroundWorkRejectsUnboundAssetsInputsAndTransitions(t *testing.T) { } } +func foregroundWorkDependencyProgram() controlprogram.Document { + document := incidentWorkProgram() + instructions := "Use the committed diagnosis to produce a response." + digest := sha256.Sum256([]byte(instructions)) + document.Work = append(document.Work, controlprogram.WorkContract{ + ID: "respond", Instructions: controlprogram.WorkAsset{Path: "respond.md", SHA256: hex.EncodeToString(digest[:]), Content: instructions}, + Inputs: []controlprogram.WorkInput{{ID: "diagnosis", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "diagnose", Output: "diagnosis"}}}, + Outputs: []controlprogram.WorkOutput{{ID: "response", Path: "response.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}, + }) + operator := document.Operators[0] + operator.ID = "dispatch" + document.Operators = append(document.Operators, operator) + document.Transitions = append(document.Transitions, controlprogram.Transition{ + ID: "dispatch", Operator: "dispatch", Work: "respond", Priority: 20, + Guard: fact("incident", "mitigated"), Target: fact("incident", "mitigated"), + }) + return document +} + +func TestForegroundWorkInputsRequireOnePriorCommittedProducer(t *testing.T) { + if _, err := controlprogram.Compile(foregroundWorkDependencyProgram(), nil); err != nil { + t.Fatal(err) + } + for name, test := range map[string]struct { + mutate func(*controlprogram.Document) + witness string + }{ + "unsupported-source": {func(value *controlprogram.Document) { + value.Work[1].Inputs[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "incident"} + }, "entry-input or work-output"}, + "self-dependency": {func(value *controlprogram.Document) { value.Work[1].Inputs[0].Producer.Work = "respond" }, "self-produced"}, + "optional-output": {func(value *controlprogram.Document) { value.Work[0].Outputs[0].Required = false }, "optional or unknown"}, + "invalid-order": {func(value *controlprogram.Document) { value.Transitions[1].Priority = 5 }, "not guaranteed"}, + "unimplied-target": {func(value *controlprogram.Document) { value.Transitions[1].Guard = fact("incident", "open") }, "not guaranteed"}, + } { + t.Run(name, func(t *testing.T) { + document := foregroundWorkDependencyProgram() + test.mutate(&document) + if _, err := controlprogram.Compile(document, nil); err == nil || !strings.Contains(err.Error(), test.witness) { + t.Fatalf("compile result = %v", err) + } + }) + } +} + func TestCanonicalFingerprintIgnoresOrderingAndDescriptions(t *testing.T) { // control-law: canonical-program-identity-binds-executable-semantics-only base, err := controlprogram.Compile(incidentProgram(), nil) diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index b869c387..ba7d1c40 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,7 +7,7 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 7 + SchemaRevision = 8 ) type Document struct { @@ -64,8 +64,8 @@ type WorkAsset struct { } type WorkInput struct { - ID string `json:"id"` - EntryInput string `json:"entry_input"` + ID string `json:"id"` + Producer ParameterProducer `json:"producer"` } type WorkOutput struct { diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index d32b706c..8d3f2541 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -230,8 +230,8 @@ func requireReachableEntryInputs(transition delivery.Transition, entriesByTarget declared[input.ID] = true } for _, input := range transition.Work.Inputs { - if !declared[input.EntryInput] { - return fmt.Errorf("transition %q foreground work %q requires entry input %q, but reachable entry %q does not declare it", transition.ID, transition.Work.ID, input.EntryInput, entry.ID) + if input.Producer.Kind == controlprogram.ParameterSourceEntryInput && !declared[input.Producer.Input] { + return fmt.Errorf("transition %q foreground work %q requires entry input %q, but reachable entry %q does not declare it", transition.ID, transition.Work.ID, input.Producer.Input, entry.ID) } } } @@ -247,7 +247,7 @@ func RuntimeWorkContract(declaration controlprogram.WorkContract) (*delivery.Wor InstructionSHA256: declaration.Instructions.SHA256, InstructionContent: declaration.Instructions.Content, } for _, input := range declaration.Inputs { - work.Inputs = append(work.Inputs, delivery.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) + work.Inputs = append(work.Inputs, delivery.WorkInput{ID: input.ID, Producer: input.Producer}) } for _, output := range declaration.Outputs { runtimeOutput := delivery.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index c057a596..7c250188 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -380,7 +380,7 @@ func TestForegroundWorkInputsMustExistOnEveryReachableEntry(t *testing.T) { digest := sha256.Sum256([]byte(instructions)) document.Work = []controlprogram.WorkContract{{ ID: "planning", Instructions: controlprogram.WorkAsset{Path: "planning.md", SHA256: hex.EncodeToString(digest[:]), Content: instructions}, - Inputs: []controlprogram.WorkInput{{ID: "plan", EntryInput: "plan"}}, + Inputs: []controlprogram.WorkInput{{ID: "plan", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "plan"}}}, Outputs: []controlprogram.WorkOutput{{ID: "result", Path: "result.md", MediaType: "text/markdown", Required: true}}, }} document.Transitions[0].Work = "planning" diff --git a/boatstack/flow/softwaredelivery/work_package.go b/boatstack/flow/softwaredelivery/work_package.go index 2f440b63..16b19400 100644 --- a/boatstack/flow/softwaredelivery/work_package.go +++ b/boatstack/flow/softwaredelivery/work_package.go @@ -162,7 +162,7 @@ func appendUniqueTarget(values []model.TargetID, target model.TargetID) []model. func validateWorkPackageContract(work delivery.WorkContract) error { portable := workpackage.WorkContract{ID: work.ID, Fingerprint: work.Fingerprint, Instructions: workpackage.Asset{Path: work.InstructionPath, SHA256: work.InstructionSHA256, Content: work.InstructionContent}} for _, input := range work.Inputs { - portable.Inputs = append(portable.Inputs, workpackage.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) + portable.Inputs = append(portable.Inputs, workpackage.WorkInput{ID: input.ID, Producer: input.Producer}) } for _, output := range work.Outputs { item := workpackage.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} diff --git a/boatstack/flow/softwaredelivery/workpackage/package.go b/boatstack/flow/softwaredelivery/workpackage/package.go index 8a7872c0..d38ab12a 100644 --- a/boatstack/flow/softwaredelivery/workpackage/package.go +++ b/boatstack/flow/softwaredelivery/workpackage/package.go @@ -19,13 +19,14 @@ import ( "time" "unicode/utf8" + "github.com/operatorstack/boatstack/boatstack/controlprogram" general "github.com/operatorstack/boatstack/boatstack/kernel" "github.com/santhosh-tekuri/jsonschema/v6" ) const ( ManifestSchemaVersion = 1 - ContractSchemaVersion = 1 + ContractSchemaVersion = 2 WorkReceiptSchemaVersion = 1 ApprovalSchemaVersion = 1 maxPackageMetadataBytes = 16 << 20 @@ -54,8 +55,8 @@ type WorkOutput struct { } type WorkInput struct { - ID string `json:"id"` - EntryInput string `json:"entry_input"` + ID string `json:"id"` + Producer controlprogram.ParameterProducer `json:"producer"` } type WorkContract struct { @@ -622,7 +623,10 @@ func validateContractAssets(work WorkContract) error { } inputIDs := map[string]bool{} for _, input := range work.Inputs { - if !ValidSegment(input.ID) || !ValidSegment(input.EntryInput) || inputIDs[input.ID] { + producer := input.Producer + validProducer := producer.Kind == controlprogram.ParameterSourceEntryInput && ValidSegment(producer.Input) && producer.Facet == "" && producer.AvailableWhen == nil && producer.Transition == "" && producer.Field == "" && producer.Work == "" && producer.Output == "" && producer.Binding == nil && producer.Request == nil || + producer.Kind == controlprogram.ParameterSourceWorkOutput && ValidSegment(producer.Work) && ValidSegment(producer.Output) && producer.Input == "" && producer.Facet == "" && producer.AvailableWhen == nil && producer.Transition == "" && producer.Field == "" && producer.Binding == nil && producer.Request == nil + if !ValidSegment(input.ID) || !validProducer || inputIDs[input.ID] { return fmt.Errorf("embedded work input %q is invalid", input.ID) } inputIDs[input.ID] = true diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index bf288037..5ae5dd8b 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" general "github.com/operatorstack/boatstack/boatstack/kernel" ) @@ -180,8 +181,8 @@ type WorkContract struct { } type WorkInput struct { - ID string `json:"id"` - EntryInput string `json:"entry_input"` + ID string `json:"id"` + Producer controlprogram.ParameterProducer `json:"producer"` } type WorkOutput struct { @@ -701,7 +702,10 @@ func validateWorkContract(t Transition) error { } inputs := map[string]bool{} for _, input := range work.Inputs { - if !semanticID.MatchString(input.ID) || !semanticID.MatchString(input.EntryInput) || inputs[input.ID] { + producer := input.Producer + validProducer := producer.Kind == controlprogram.ParameterSourceEntryInput && semanticID.MatchString(producer.Input) && producer.Facet == "" && producer.AvailableWhen == nil && producer.Transition == "" && producer.Field == "" && producer.Work == "" && producer.Output == "" && producer.Binding == nil && producer.Request == nil || + producer.Kind == controlprogram.ParameterSourceWorkOutput && semanticID.MatchString(producer.Work) && semanticID.MatchString(producer.Output) && producer.Input == "" && producer.Facet == "" && producer.AvailableWhen == nil && producer.Transition == "" && producer.Field == "" && producer.Binding == nil && producer.Request == nil + if !semanticID.MatchString(input.ID) || !validProducer || inputs[input.ID] { return fmt.Errorf("%s: foreground work inputs must be semantic and unique", t.ID) } inputs[input.ID] = true diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index ad84dad3..a3ddea6f 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -814,7 +814,7 @@ func buildWorkPackage(admission protocol.Admission, transition catalog.Transitio } work := workpackage.WorkContract{ID: transition.Work.ID, Fingerprint: transition.Work.Fingerprint, Instructions: workpackage.Asset{Path: transition.Work.InstructionPath, SHA256: transition.Work.InstructionSHA256, Content: transition.Work.InstructionContent}} for _, input := range transition.Work.Inputs { - work.Inputs = append(work.Inputs, workpackage.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) + work.Inputs = append(work.Inputs, workpackage.WorkInput{ID: input.ID, Producer: input.Producer}) } declarations := map[string]catalog.WorkOutput{} for _, output := range transition.Work.Outputs { diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 06895caa..07fda514 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -2,6 +2,8 @@ package effects import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" @@ -100,6 +102,116 @@ func scanCommittedReceipts(layout ports.ControllerLayout, visit func(journalReco return nil } +// CommittedWorkOutputSelector identifies the one producer occurrence that is +// an ancestor of the current Flow state. Program and contract checks are +// applied to that occurrence after selection, so stale current facts cannot +// cause fallback to an older compatible result. +type CommittedWorkOutputSelector struct { + FlowID string + ProgramID string + ProgramFingerprint string + EntryID string + Objective model.Objective + Invocation model.InvocationContext + TransitionID catalog.TransitionID + Work catalog.WorkContract + OutputID string + MaximumRevision uint64 +} + +// CommittedWorkOutput is an ephemeral read projection of identities already +// present in the canonical committed journal. +type CommittedWorkOutput struct { + Receipt protocol.TransitionReceipt + Work protocol.WorkEvidence + Output protocol.WorkOutputEvidence +} + +// FindApplicableCommittedWorkOutput resolves one exact committed producer +// occurrence. It never consults foreground-work records or staging paths. +func FindApplicableCommittedWorkOutput(layout ports.ControllerLayout, selector CommittedWorkOutputSelector) (CommittedWorkOutput, bool, error) { + records := []journalRecord{} + if err := scanCommittedReceipts(layout, func(record journalRecord) error { + records = append(records, record) + return nil + }); err != nil { + return CommittedWorkOutput{}, false, err + } + return findApplicableCommittedWorkOutput(records, selector) +} + +func findApplicableCommittedWorkOutput(records []journalRecord, selector CommittedWorkOutputSelector) (CommittedWorkOutput, bool, error) { + if selector.FlowID == "" || selector.ProgramID == "" || len(selector.ProgramFingerprint) != 64 || selector.EntryID == "" || + selector.Objective.Validate() != nil || selector.Invocation.Validate(true) != nil || selector.TransitionID == "" || + selector.Work.ID == "" || len(selector.Work.Fingerprint) != 64 || selector.OutputID == "" || selector.MaximumRevision == 0 { + return CommittedWorkOutput{}, false, fmt.Errorf("committed work-output selector is incomplete") + } + var selected *journalRecord + for index := range records { + record := &records[index] + receipt := *record.Receipt + if receipt.FlowID != selector.FlowID || receipt.TransitionID != selector.TransitionID || receipt.ResultingStateRevision > selector.MaximumRevision { + continue + } + authorized := sameStateLineage(record.Admission.Invocation, selector.Invocation) + if !authorized && record.Admission.Invocation.ControllerID == selector.Invocation.ControllerID { + var err error + authorized, err = invocationAuthorizedByRecords(records, selector.FlowID, record.Admission.Invocation, selector.Invocation) + if err != nil { + return CommittedWorkOutput{}, false, err + } + } + if !authorized { + continue + } + if selected == nil || receipt.Sequence > selected.Receipt.Sequence { + selected = record + continue + } + if receipt.Sequence == selected.Receipt.Sequence && receipt.ID != selected.Receipt.ID { + return CommittedWorkOutput{}, false, fmt.Errorf("committed work-output producer occurrence is ambiguous at sequence %d", receipt.Sequence) + } + } + if selected == nil { + return CommittedWorkOutput{}, false, nil + } + receipt, admission := *selected.Receipt, selected.Admission + work := admission.Work + if receipt.Program.ID != selector.ProgramID || receipt.Program.Fingerprint != selector.ProgramFingerprint || admission.ExpectedProgramFingerprint != selector.ProgramFingerprint || + !sameObjectiveIdentity(admission.Objective, selector.Objective) || work == nil || work.RunID != selector.FlowID || work.ProgramID != selector.ProgramID || + work.EntryID != selector.EntryID || work.TransitionID != selector.TransitionID || work.ProgramFingerprint != selector.ProgramFingerprint || + work.StateRevision != receipt.PriorStateRevision || work.StateRevision != admission.ExpectedStateRevision || + work.RepositoryID != admission.Invocation.RepositoryID || work.WorktreeID != admission.Invocation.WorktreeID || + work.ContractID != selector.Work.ID || work.ContractFingerprint != selector.Work.Fingerprint || receipt.WorkResultFingerprint != work.ResultFingerprint { + return CommittedWorkOutput{}, false, fmt.Errorf("committed work-output producer occurrence is stale or incompatible") + } + var declaration *catalog.WorkOutput + for index := range selector.Work.Outputs { + if selector.Work.Outputs[index].ID == selector.OutputID { + declaration = &selector.Work.Outputs[index] + break + } + } + if declaration == nil || !declaration.Required { + return CommittedWorkOutput{}, false, fmt.Errorf("committed work-output selector references an optional or unknown output") + } + for _, output := range work.Outputs { + if output.ID != selector.OutputID { + continue + } + digest := sha256.Sum256([]byte(output.Content)) + if output.Path != declaration.Path || output.MediaType != declaration.MediaType || output.Size > declaration.MaxBytes || output.Size != int64(len(output.Content)) || output.SHA256 != hex.EncodeToString(digest[:]) { + return CommittedWorkOutput{}, false, fmt.Errorf("committed work-output does not match its current contract") + } + return CommittedWorkOutput{Receipt: receipt, Work: *work, Output: output}, true, nil + } + return CommittedWorkOutput{}, false, fmt.Errorf("committed work-output producer result lacks output %q", selector.OutputID) +} + +func sameObjectiveIdentity(left, right model.Objective) bool { + return left.ID == right.ID && left.TargetID == right.TargetID && left.TrustedClass == right.TrustedClass && left.DeliveryID == right.DeliveryID && left.FrontierIsStop == right.FrontierIsStop +} + // FindLatestCommittedFlowForObjective returns the authoritative committed flow // identity for the current objective. Projected receipt files are deliberately // not used because projection is best effort. diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index 9986f491..9e0555bf 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -1,6 +1,8 @@ package effects import ( + "path/filepath" + "strings" "testing" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" @@ -8,6 +10,67 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) +func committedWorkOutputFixture(t *testing.T, sequence, revision uint64) (journalRecord, CommittedWorkOutputSelector) { + t.Helper() + invocation := model.InvocationContext{ + RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main", ControllerID: "controller", + InvokingPath: filepath.Join(t.TempDir(), "repo"), RuntimeVersion: "test", RuntimePath: filepath.Join(t.TempDir(), "runtime"), RuntimeFingerprint: "runtime", + Topology: model.TopologyEmbedded, Host: "cursor", Correlation: "proof", + } + objective := model.Objective{ID: "objective-proof", TargetID: "approved", TrustedClass: "approved", DeliveryID: "proof"} + contract := catalog.WorkContract{ + ID: "work-a", Fingerprint: strings.Repeat("c", 64), + Outputs: []catalog.WorkOutput{{ID: "architecture", Path: "architecture.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}, + } + work := &protocol.WorkEvidence{ + RunID: "run-proof", ProgramID: "proof", EntryID: "prove", TransitionID: "produce-a", ProgramFingerprint: strings.Repeat("3", 64), + StateRevision: revision - 1, RepositoryID: "repo", WorktreeID: "worktree", ContractID: contract.ID, ContractFingerprint: contract.Fingerprint, + ResultFingerprint: strings.Repeat("4", 64), Outputs: []protocol.WorkOutputEvidence{{ID: "architecture", Path: "architecture.md", MediaType: "text/markdown", SHA256: sha256Bytes([]byte("architecture")), Size: 12, Content: "architecture"}}, + } + receipt := protocol.TransitionReceipt{ + ID: "trc-producer", FlowID: "run-proof", Sequence: sequence, TransitionID: "produce-a", PriorStateRevision: revision - 1, ResultingStateRevision: revision, + Program: protocol.ProgramIdentity{ID: "proof", Fingerprint: strings.Repeat("3", 64)}, WorkResultFingerprint: work.ResultFingerprint, + } + record := journalRecord{Admission: protocol.Admission{ + Invocation: invocation, Objective: objective, ExpectedProgramFingerprint: strings.Repeat("3", 64), ExpectedStateRevision: revision - 1, Work: work, + }, Receipt: &receipt} + selector := CommittedWorkOutputSelector{ + FlowID: "run-proof", ProgramID: "proof", ProgramFingerprint: strings.Repeat("3", 64), EntryID: "prove", Objective: objective, + Invocation: invocation, TransitionID: "produce-a", Work: contract, OutputID: "architecture", MaximumRevision: revision, + } + return record, selector +} + +func TestCommittedWorkOutputSelectsApplicableOccurrenceWithoutFallback(t *testing.T) { + older, selector := committedWorkOutputFixture(t, 4, 8) + selected, found, err := findApplicableCommittedWorkOutput([]journalRecord{older}, selector) + if err != nil || !found || selected.Receipt.ID != "trc-producer" || selected.Output.Content != "architecture" { + t.Fatalf("selected=%#v found=%t err=%v", selected, found, err) + } + + newer := older + newerReceipt := *older.Receipt + newerReceipt.ID, newerReceipt.Sequence, newerReceipt.ResultingStateRevision, newerReceipt.PriorStateRevision = "trc-stale", 5, 9, 8 + newer.Receipt = &newerReceipt + newer.Admission.ExpectedStateRevision = 8 + newerWork := *older.Admission.Work + newerWork.StateRevision = 8 + newerWork.ProgramFingerprint = strings.Repeat("5", 64) + newer.Admission.Work = &newerWork + selector.MaximumRevision = 9 + if _, found, err := findApplicableCommittedWorkOutput([]journalRecord{older, newer}, selector); err == nil || found || !strings.Contains(err.Error(), "stale or incompatible") { + t.Fatalf("stale applicable occurrence fell back: found=%t err=%v", found, err) + } + + duplicate := newer + duplicateReceipt := *newer.Receipt + duplicateReceipt.ID = "trc-ambiguous" + duplicate.Receipt = &duplicateReceipt + if _, found, err := findApplicableCommittedWorkOutput([]journalRecord{newer, duplicate}, selector); err == nil || found || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("ambiguous maximal occurrence result: found=%t err=%v", found, err) + } +} + func TestActiveFlowIdentityComesFromObjectiveBindingReceipt(t *testing.T) { objective := model.Objective{ID: "objective-product-delivery-run-one", TargetID: "published-pr", TrustedClass: model.ObjectiveOpenPR, DeliveryID: "one"} binding := protocol.TransitionReceipt{ID: "binding", FlowID: "run-original", TransitionID: "objective.bind", ObjectiveID: objective.ID, TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: objective.DeliveryID, ResultingStateRevision: 4} diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager.go b/boatstack/internal/softwaredelivery/foregroundwork/manager.go index a70cc941..7172a12c 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager.go @@ -15,6 +15,7 @@ import ( "time" "unicode/utf8" + "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" @@ -23,7 +24,7 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) -const RecordSchemaVersion = 3 +const RecordSchemaVersion = 4 type Status string @@ -36,10 +37,10 @@ const ( ) type InputBinding struct { - ID string `json:"id"` - EntryInput string `json:"entry_input"` - Value string `json:"value"` - Fingerprint string `json:"fingerprint"` + ID string `json:"id"` + Value string `json:"value"` + Fingerprint string `json:"fingerprint"` + WorkOutput *protocol.WorkOutputProvenance `json:"work_output,omitempty"` } type Question struct { @@ -209,12 +210,16 @@ func (m Manager) Complete(ctx context.Context, invocation model.InvocationContex if err != nil { return err } + inputs := make([]protocol.WorkInputEvidence, 0, len(record.Request.Inputs)) + for _, input := range record.Request.Inputs { + inputs = append(inputs, protocol.WorkInputEvidence{ID: input.ID, Fingerprint: input.Fingerprint, WorkOutput: input.WorkOutput}) + } evidence, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: record.Request.ID, RequestFingerprint: record.Request.Fingerprint, RunID: record.Request.RunID, ProgramID: record.Request.ProgramID, EntryID: record.Request.EntryID, ContractID: record.Request.Contract.ID, ContractFingerprint: record.Request.Contract.Fingerprint, TransitionID: record.Request.TransitionID, ProgramFingerprint: record.Request.ProgramFingerprint, ContextFingerprint: record.Request.ContextFingerprint, StateRevision: record.Request.StateRevision, - RepositoryID: record.Request.RepositoryID, WorktreeID: record.Request.WorktreeID, Outputs: outputs, + RepositoryID: record.Request.RepositoryID, WorktreeID: record.Request.WorktreeID, Inputs: inputs, Outputs: outputs, }) if err != nil { return err @@ -299,11 +304,23 @@ func newRequest(store ports.RuntimeStore, layout ports.ControllerLayout, now tim work := *transition.Work bindings := make([]InputBinding, 0, len(work.Inputs)) for _, input := range work.Inputs { - value, ok := values[input.EntryInput] + value, ok := values[work.ID+"/"+input.ID] if !ok || value.Validate() != nil { - return Request{}, fmt.Errorf("foreground work input %q is not bound by entry input %q", input.ID, input.EntryInput) + return Request{}, fmt.Errorf("foreground work input %q is not bound", input.ID) + } + switch input.Producer.Kind { + case controlprogram.ParameterSourceEntryInput: + if value.WorkOutput != nil { + return Request{}, fmt.Errorf("foreground work entry input %q invents work-output provenance", input.ID) + } + case controlprogram.ParameterSourceWorkOutput: + if value.WorkOutput == nil || value.WorkOutput.WorkID != input.Producer.Work || value.WorkOutput.OutputID != input.Producer.Output { + return Request{}, fmt.Errorf("foreground work input %q does not match its declared producer", input.ID) + } + default: + return Request{}, fmt.Errorf("foreground work input %q has an unsupported producer", input.ID) } - bindings = append(bindings, InputBinding{ID: input.ID, EntryInput: input.EntryInput, Value: value.Value, Fingerprint: value.Fingerprint}) + bindings = append(bindings, InputBinding{ID: input.ID, Value: value.Value, Fingerprint: value.Fingerprint, WorkOutput: value.WorkOutput}) } sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) contextFingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go index 99cb6710..7953bc79 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" @@ -37,7 +38,7 @@ func invocation() model.InvocationContext { } func workInputs(value, fingerprint string) map[string]protocol.WorkInputValue { - return map[string]protocol.WorkInputValue{"incident": {Value: value, Fingerprint: fingerprint}} + return map[string]protocol.WorkInputValue{"diagnose/incident": {Value: value, Fingerprint: fingerprint}} } func fixture(t *testing.T) (foregroundwork.Manager, model.Snapshot, catalog.Transition, string) { @@ -57,7 +58,7 @@ func fixture(t *testing.T) (foregroundwork.Manager, model.Snapshot, catalog.Tran SchemaPath: "schema.json", SchemaSHA256: strings.Repeat("b", 64), SchemaContent: `{"type":"object","properties":{"cause":{"type":"string"}},"required":["cause"],"additionalProperties":false}`, }} - work := &catalog.WorkContract{ID: "diagnose", InstructionPath: "instructions.md", InstructionSHA256: strings.Repeat("a", 64), InstructionContent: "Diagnose.", Inputs: []catalog.WorkInput{{ID: "incident", EntryInput: "incident"}}, Outputs: outputs} + work := &catalog.WorkContract{ID: "diagnose", InstructionPath: "instructions.md", InstructionSHA256: strings.Repeat("a", 64), InstructionContent: "Diagnose.", Inputs: []catalog.WorkInput{{ID: "incident", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceEntryInput, Input: "incident"}}}, Outputs: outputs} fingerprint, err := general.Fingerprint(struct { ID string `json:"id"` InstructionPath string `json:"instruction_path"` @@ -210,6 +211,52 @@ func TestForegroundWorkInputFingerprintInvalidatesOutputsAndStaging(t *testing.T } } +func TestForegroundWorkCommitsProducerProvenanceAndRejectsResultSubstitution(t *testing.T) { + manager, snapshot, transition, _ := fixture(t) + transition.Work.Inputs[0].Producer = controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "work-a", Output: "architecture"} + fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []catalog.WorkInput `json:"inputs,omitempty"` + Outputs []catalog.WorkOutput `json:"outputs"` + }{transition.Work.ID, transition.Work.InstructionPath, transition.Work.InstructionSHA256, transition.Work.InstructionContent, transition.Work.Inputs, transition.Work.Outputs}) + if err != nil { + t.Fatal(err) + } + transition.Work.Fingerprint = fingerprint + value := protocol.WorkInputValue{Value: "architecture", Fingerprint: strings.Repeat("a", 64), WorkOutput: &protocol.WorkOutputProvenance{ + ReceiptID: "trc-a", TransitionID: "produce-a", WorkID: "work-a", OutputID: "architecture", + ResultFingerprint: strings.Repeat("b", 64), ContractFingerprint: strings.Repeat("c", 64), OutputSHA256: strings.Repeat("a", 64), + }} + inputs := map[string]protocol.WorkInputValue{"diagnose/incident": value} + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + first, err := manager.Ensure(context.Background(), invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, inputs) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(first.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":"accepted architecture"}`), 0o600); err != nil { + t.Fatal(err) + } + completed, err := manager.Complete(context.Background(), invocation(), "run-1", "diagnose") + if err != nil || completed.Result == nil || len(completed.Result.Inputs) != 1 || completed.Result.Inputs[0].WorkOutput == nil || completed.Result.Inputs[0].WorkOutput.ReceiptID != "trc-a" { + t.Fatalf("committed input evidence = %#v err=%v", completed.Result, err) + } + + substituted := value + provenance := *value.WorkOutput + provenance.ResultFingerprint = strings.Repeat("d", 64) + substituted.WorkOutput = &provenance + second, err := manager.Ensure(context.Background(), invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, map[string]protocol.WorkInputValue{"diagnose/incident": substituted}) + if err != nil { + t.Fatal(err) + } + if second.Request.Fingerprint == first.Request.Fingerprint || second.Request.StagingRoot == first.Request.StagingRoot { + t.Fatal("different producer result reused the downstream request identity") + } +} + func TestForegroundWorkRejectsMissingInvalidAndEscapingOutputs(t *testing.T) { // control-law: only declared bounded regular staged outputs become work evidence manager, snapshot, transition, root := fixture(t) diff --git a/boatstack/internal/softwaredelivery/protocol/work.go b/boatstack/internal/softwaredelivery/protocol/work.go index c62ee8c2..57cf2b52 100644 --- a/boatstack/internal/softwaredelivery/protocol/work.go +++ b/boatstack/internal/softwaredelivery/protocol/work.go @@ -6,25 +6,72 @@ import ( "fmt" "sort" + "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const WorkEvidenceSchemaVersion = 3 +const WorkEvidenceSchemaVersion = 4 -// WorkInputValue binds the value presented to foreground work to the exact -// bytes selected by the trusted entry-input resolver. Value is an ergonomic -// locator or label; Fingerprint is the immutable execution identity. +// WorkOutputProvenance projects existing committed producer identities into a +// downstream Work input. It is evidence, not a new accepted-result identity. +type WorkOutputProvenance struct { + ReceiptID string `json:"receipt_id"` + TransitionID catalog.TransitionID `json:"transition_id"` + WorkID string `json:"work_id"` + OutputID string `json:"output_id"` + ResultFingerprint string `json:"result_fingerprint"` + ContractFingerprint string `json:"contract_fingerprint"` + OutputSHA256 string `json:"output_sha256"` +} + +func (p WorkOutputProvenance) Validate() error { + if p.ReceiptID == "" || p.TransitionID == "" || p.WorkID == "" || p.OutputID == "" || + !validSHA256(p.ResultFingerprint) || !validSHA256(p.ContractFingerprint) || !validSHA256(p.OutputSHA256) { + return fmt.Errorf("foreground work-output provenance is incomplete") + } + return nil +} + +// WorkInputValue binds the value presented to foreground work to exact source +// bytes. Entry inputs may use an ergonomic locator; Work outputs carry their +// content. Fingerprint is the immutable byte identity in both cases. type WorkInputValue struct { - Value string `json:"value"` - Fingerprint string `json:"fingerprint"` + Value string `json:"value"` + Fingerprint string `json:"fingerprint"` + WorkOutput *WorkOutputProvenance `json:"work_output,omitempty"` } func (v WorkInputValue) Validate() error { if v.Value == "" || !validSHA256(v.Fingerprint) { return fmt.Errorf("foreground work input requires a value and exact fingerprint") } + if v.WorkOutput != nil { + if err := v.WorkOutput.Validate(); err != nil || v.Fingerprint != v.WorkOutput.OutputSHA256 { + return fmt.Errorf("foreground work input has invalid work-output provenance") + } + } + return nil +} + +// WorkInputEvidence commits the immutable identity of one input without +// duplicating its content in every downstream accepted result. +type WorkInputEvidence struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + WorkOutput *WorkOutputProvenance `json:"work_output,omitempty"` +} + +func (e WorkInputEvidence) Validate() error { + if e.ID == "" || !validSHA256(e.Fingerprint) { + return fmt.Errorf("foreground work input evidence is incomplete") + } + if e.WorkOutput != nil { + if err := e.WorkOutput.Validate(); err != nil || e.Fingerprint != e.WorkOutput.OutputSHA256 { + return fmt.Errorf("foreground work input evidence has invalid work-output provenance") + } + } return nil } @@ -58,6 +105,7 @@ type WorkEvidence struct { StateRevision uint64 `json:"state_revision"` RepositoryID string `json:"repository_id"` WorktreeID string `json:"worktree_id"` + Inputs []WorkInputEvidence `json:"inputs,omitempty"` Outputs []WorkOutputEvidence `json:"outputs"` } @@ -68,6 +116,13 @@ func (e WorkEvidence) Validate() error { return fmt.Errorf("foreground work evidence has incomplete identity") } seen := map[string]bool{} + for _, input := range e.Inputs { + if err := input.Validate(); err != nil || seen[input.ID] { + return fmt.Errorf("foreground work input evidence is incomplete or duplicated") + } + seen[input.ID] = true + } + seen = map[string]bool{} for _, output := range e.Outputs { contentDigest := sha256.Sum256([]byte(output.Content)) if output.ID == "" || output.Path == "" || output.MediaType == "" || !validSHA256(output.SHA256) || hex.EncodeToString(contentDigest[:]) != output.SHA256 || output.Size < 0 || int64(len(output.Content)) != output.Size || seen[output.ID] { @@ -97,6 +152,32 @@ func (e WorkEvidence) ValidateCurrent(snapshot model.Snapshot, transition catalo e.StateRevision != snapshot.StateRevision || e.RepositoryID != snapshot.Invocation.RepositoryID || e.WorktreeID != snapshot.Invocation.WorktreeID { return fmt.Errorf("foreground work evidence is stale or belongs to a different transition context") } + declaredInputs := map[string]catalog.WorkInput{} + for _, input := range transition.Work.Inputs { + declaredInputs[input.ID] = input + } + for _, input := range e.Inputs { + contract, ok := declaredInputs[input.ID] + if !ok { + return fmt.Errorf("foreground work input %q does not match the trusted contract", input.ID) + } + switch contract.Producer.Kind { + case controlprogram.ParameterSourceEntryInput: + if input.WorkOutput != nil { + return fmt.Errorf("foreground work entry input %q invents producer provenance", input.ID) + } + case controlprogram.ParameterSourceWorkOutput: + if input.WorkOutput == nil || input.WorkOutput.WorkID != contract.Producer.Work || input.WorkOutput.OutputID != contract.Producer.Output { + return fmt.Errorf("foreground work input %q does not match its declared producer", input.ID) + } + default: + return fmt.Errorf("foreground work input %q has an unsupported producer", input.ID) + } + delete(declaredInputs, input.ID) + } + if len(declaredInputs) != 0 { + return fmt.Errorf("foreground work result is missing input evidence") + } declared := map[string]catalog.WorkOutput{} for _, output := range transition.Work.Outputs { declared[output.ID] = output @@ -122,7 +203,14 @@ func CanonicalWorkOutputs(outputs []WorkOutputEvidence) []WorkOutputEvidence { return result } +func CanonicalWorkInputs(inputs []WorkInputEvidence) []WorkInputEvidence { + result := append([]WorkInputEvidence(nil), inputs...) + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + func SealWorkEvidence(e WorkEvidence) (WorkEvidence, error) { + e.Inputs = CanonicalWorkInputs(e.Inputs) e.Outputs = CanonicalWorkOutputs(e.Outputs) e.ResultFingerprint = "" fingerprint, err := general.Fingerprint(e) diff --git a/boatstack/internal/softwaredelivery/protocol/work_test.go b/boatstack/internal/softwaredelivery/protocol/work_test.go index 4ac47509..f3b306e0 100644 --- a/boatstack/internal/softwaredelivery/protocol/work_test.go +++ b/boatstack/internal/softwaredelivery/protocol/work_test.go @@ -49,3 +49,30 @@ func TestWorkEvidenceSchemaBindsRequiredExecutionIdentity(t *testing.T) { t.Fatalf("schema-2 work evidence validation = %v", err) } } + +func TestWorkEvidenceIdentityBindsProducerResultProvenance(t *testing.T) { + base := workEvidenceFixture() + base.Inputs = []WorkInputEvidence{{ + ID: "architecture", Fingerprint: strings.Repeat("a", 64), + WorkOutput: &WorkOutputProvenance{ + ReceiptID: "trc-producer", TransitionID: "work-a", WorkID: "work-a", OutputID: "architecture", + ResultFingerprint: strings.Repeat("b", 64), ContractFingerprint: strings.Repeat("c", 64), OutputSHA256: strings.Repeat("a", 64), + }, + }} + first, err := SealWorkEvidence(base) + if err != nil { + t.Fatal(err) + } + substituted := base + substituted.Inputs = append([]WorkInputEvidence(nil), base.Inputs...) + producer := *base.Inputs[0].WorkOutput + producer.ResultFingerprint = strings.Repeat("d", 64) + substituted.Inputs[0].WorkOutput = &producer + second, err := SealWorkEvidence(substituted) + if err != nil { + t.Fatal(err) + } + if first.ResultFingerprint == second.ResultFingerprint { + t.Fatal("same input bytes from a different producer result preserved downstream identity") + } +} diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 0d6bc7b4..4ab40bc3 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -20,7 +20,7 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 16 +const SchemaVersion = 17 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var gitObjectIdentity = regexp.MustCompile(`^[0-9a-f]{40,64}$`) @@ -148,7 +148,8 @@ func (r Request) Validate(now time.Time) error { } } for id, input := range r.WorkInputs { - if !flowContextIdentity.MatchString(id) { + parts := strings.Split(id, "/") + if len(parts) != 2 || !flowContextIdentity.MatchString(parts[0]) || !flowContextIdentity.MatchString(parts[1]) { return fmt.Errorf("surface foreground work input has invalid identity %q", id) } if err := input.Validate(); err != nil { diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 0abcf297..e9f6af4a 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1 +1 @@ -{"schema":"control-program","schema_revision":7,"program":{"id":"incident-response","version":"1"},"declarations":{"capabilities":["service.restart"],"authorities":["incident-commander"],"effects":["service.restart"],"verifiers":["healthcheck"]},"facets":[{"id":"incident","kind":"enum","values":["open","mitigated"]},{"id":"service","kind":"enum","values":["degraded","healthy"]}],"evidence":[{"id":"healthcheck","subject":"service","kind":"observation"}],"work":[],"operators":[{"id":"restart","capabilities":["service.restart"],"authority":{"any_of":["incident-commander"]},"effects":["service.restart"],"verifier":"healthcheck","recovery":"restart","execution_context":"preserve","state_effect":{"kind":"assignments","assignments":[{"facet":"incident","value":"mitigated"}]}}],"transitions":[{"id":"restart","operator":"restart","guard":{"fact":{"facet":"incident","statuses":["known"],"values":["open"]}},"target":{"fact":{"facet":"incident","statuses":["known"],"values":["mitigated"]}},"priority":10}],"targets":[{"id":"mitigated","predicate":{"fact":{"facet":"incident","statuses":["known"],"values":["mitigated"]}}}],"entries":[{"id":"respond","target":"mitigated","inputs":[]}]} +{"schema":"control-program","schema_revision":8,"program":{"id":"incident-response","version":"1"},"declarations":{"capabilities":["service.restart"],"authorities":["incident-commander"],"effects":["service.restart"],"verifiers":["healthcheck"]},"facets":[{"id":"incident","kind":"enum","values":["open","mitigated"]},{"id":"service","kind":"enum","values":["degraded","healthy"]}],"evidence":[{"id":"healthcheck","subject":"service","kind":"observation"}],"work":[],"operators":[{"id":"restart","capabilities":["service.restart"],"authority":{"any_of":["incident-commander"]},"effects":["service.restart"],"verifier":"healthcheck","recovery":"restart","execution_context":"preserve","state_effect":{"kind":"assignments","assignments":[{"facet":"incident","value":"mitigated"}]}}],"transitions":[{"id":"restart","operator":"restart","guard":{"fact":{"facet":"incident","statuses":["known"],"values":["open"]}},"target":{"fact":{"facet":"incident","statuses":["known"],"values":["mitigated"]}},"priority":10}],"targets":[{"id":"mitigated","predicate":{"fact":{"facet":"incident","statuses":["known"],"values":["mitigated"]}}}],"entries":[{"id":"respond","target":"mitigated","inputs":[]}]} 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 e2579942..dcd8159c 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":7,"program":{"id":"product-delivery","version":"1","human_identity":"developer"},"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":"work-package","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":"work.package.admit","binding":{"reference":"software-delivery/work.package.admit","version":"1"}},{"id":"work.package.approve","binding":{"reference":"software-delivery/work.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":"work.package.admit","operator":"work.package.admit","guard":{"true":true},"target":{"true":true},"priority":43,"work":"planning-package"},{"id":"work.package.approve","operator":"work.package.approve","guard":{"true":true},"target":{"true":true},"priority":44,"parameters":[{"parameter":"package_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/admitted-work-package-fingerprint","version":"1"}}}]},{"id":"planning.package.promote","operator":"planning.package.promote","guard":{"true":true},"target":{"true":true},"priority":45,"parameters":[{"parameter":"plan_output","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/planning-package-plan-output/plan","version":"1"}}}]},{"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","requires":{"authorities":["human"]},"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":8,"program":{"id":"product-delivery","version":"1","human_identity":"developer"},"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":"work-package","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","producer":{"kind":"entry-input","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":"work.package.admit","binding":{"reference":"software-delivery/work.package.admit","version":"1"}},{"id":"work.package.approve","binding":{"reference":"software-delivery/work.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":"work.package.admit","operator":"work.package.admit","guard":{"true":true},"target":{"true":true},"priority":43,"work":"planning-package"},{"id":"work.package.approve","operator":"work.package.approve","guard":{"true":true},"target":{"true":true},"priority":44,"parameters":[{"parameter":"package_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/admitted-work-package-fingerprint","version":"1"}}}]},{"id":"planning.package.promote","operator":"planning.package.promote","guard":{"true":true},"target":{"true":true},"priority":45,"parameters":[{"parameter":"plan_output","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/planning-package-plan-output/plan","version":"1"}}}]},{"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","requires":{"authorities":["human"]},"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/compiler-and-artifacts.md b/docs/architecture/compiler-and-artifacts.md index 1e132470..b720eb7a 100644 --- a/docs/architecture/compiler-and-artifacts.md +++ b/docs/architecture/compiler-and-artifacts.md @@ -22,7 +22,7 @@ Flow TypeScript ## Control Program document The current document identifies `schema: "control-program"` and -`schema_revision: 7`. Its top-level sections are: +`schema_revision: 8`. Its top-level sections are: | Section | Purpose | | --- | --- | @@ -58,7 +58,7 @@ directories are never claimed wholesale. ## Artifact envelope The committed artifact identifies `schema: "control-program-artifact"` and -`schema_revision: 7`. It contains the compiler version; source and dependency +`schema_revision: 8`. It contains the compiler version; source and dependency lock paths and hashes; the program fingerprint; the canonical projection selection and its fingerprint; hashes of every generated projection; hashes of referenced work assets; and the compiled Control Program document. diff --git a/docs/concepts/invocation-parameters-and-foreground-work.md b/docs/concepts/invocation-parameters-and-foreground-work.md index f197b1e2..b96204fa 100644 --- a/docs/concepts/invocation-parameters-and-foreground-work.md +++ b/docs/concepts/invocation-parameters-and-foreground-work.md @@ -22,7 +22,9 @@ evidence for parameter materialization, not authority to perform an effect. - Each required parameter has exactly one compatible producer. - Trusted resolvers are immutable references and do not grant authority. - Foreground-work completion does not independently advance Flow state. -- The selected transition consumes verified work output. +- Same-transition parameters consume the candidate result being admitted. +- Later transitions and Work contracts consume only the applicable committed result. +- A later result commits the exact producer receipt, result, contract, output, and byte identities. - Requests and answers are correlated to the exact run and generation. - Restart and resume preserve the same run and request lineage. diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index 942a9644..ab327658 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -6,7 +6,7 @@ Boatstack separates authoring languages from executable semantics: TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel ``` -The `control-program` schema is currently `schema_revision: 7`. It is +The `control-program` schema is currently `schema_revision: 8`. It is domain-neutral and declares typed facets, evidence relations, predicate ASTs, operators, capabilities, authority, effects, verification, recovery, bounded foreground work, diff --git a/docs/product-delivery/planning-and-foreground-work.md b/docs/product-delivery/planning-and-foreground-work.md index 46f29f4b..6fbf8e92 100644 --- a/docs/product-delivery/planning-and-foreground-work.md +++ b/docs/product-delivery/planning-and-foreground-work.md @@ -30,6 +30,11 @@ priorities, and the target they serve. `workPackage.work` and Additional work must be explicitly registered and named by every lifecycle step that consumes it. +A Work input may name a required output from one earlier Work transition. The +compiler proves ordering and target reachability. At runtime, the consumer +reads only the applicable canonical committed journal record. Mutable Work +records, staging paths, and filenames cannot substitute that evidence. + Missing actor-owned parameters create `TRANSITION_INPUT_REQUIRED` suspension. The immutable request binds the run, control bundle, transition, parameter, and identity context. `boatstack flow input answer` records a correlated answer diff --git a/docs/typescript/base-sdk.md b/docs/typescript/base-sdk.md index 7beb9811..ec7db2dd 100644 --- a/docs/typescript/base-sdk.md +++ b/docs/typescript/base-sdk.md @@ -22,12 +22,17 @@ requires one compatible producer for each required reachable parameter. `foregroundWork` declares bounded candidate work. `instructionAsset` and `schemaAsset` name repository assets that the compiler later resolves and -fingerprints. `entryInput` binds an input; `workArtifact` declares an output. +fingerprints. `entryInput` binds an input to an entry value. `workInput` can +instead bind an input to a required output from one prior Work transition. +`workArtifact` declares an output. An output may attach `guidance: instructionAsset(...)`; its exact UTF-8 bytes are embedded in the runtime request and participate in the work and program fingerprints. Package instructions coordinate all outputs, while guidance describes only its artifact. Neither grants authority or verifies content. Work completion does not independently advance Flow state. +Cross-transition Work inputs resolve only from the applicable committed +producer result. The downstream result retains the producer receipt, result, +contract, output, and byte identities. ## Authority diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index 1ed99d6e..61e43e39 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -203,10 +203,15 @@ export interface WorkAssetDefinition { content?: string; } -/** Binds one foreground-work input to a declared entry input. */ +/** Sources accepted foreground-work input bytes from an entry or prior Work output. */ +export type WorkInputProducer = + | Extract + | Extract; + +/** Binds one foreground-work input to one explicit producer. */ export interface WorkInputDefinition { id: string; - entry_input: string; + producer: WorkInputProducer; } /** Declares one bounded staged output of foreground work. */ @@ -401,11 +406,21 @@ export function schemaAsset(path: string): WorkAssetDefinition { return { path }; } +/** Binds a foreground-work input to one explicit entry or Work-output producer. + * @category Foreground work + */ +export function workInput(definition: { + id: string; + producer: WorkInputProducer; +}): WorkInputDefinition { + return { id: definition.id, producer: { ...definition.producer } }; +} + /** Binds a foreground-work input to one entry input ID. * @category Foreground work */ export function entryInput(id: string): WorkInputDefinition { - return { id, entry_input: id }; + return workInput({ id, producer: fromEntryInput(id) }); } /** Declares one bounded foreground-work output artifact. @@ -437,7 +452,9 @@ export function foregroundWork(definition: WorkContract): WorkContract { /** Resolves a transition parameter from one declared entry input. * @category Invocation and parameters */ -export function fromEntryInput(input: string): ParameterProducer { +export function fromEntryInput( + input: string, +): Extract { return { kind: "entry-input", input }; } @@ -489,7 +506,7 @@ export function fromStateOrReceipt(definition: { export function fromWorkOutput(definition: { work: string; output: string; -}): ParameterProducer { +}): Extract { return { kind: "work-output", ...definition }; } diff --git a/release-notes/2026-08-19-resolve-foreground-work-inputs.md b/release-notes/2026-08-19-resolve-foreground-work-inputs.md new file mode 100644 index 00000000..eb1a221d --- /dev/null +++ b/release-notes/2026-08-19-resolve-foreground-work-inputs.md @@ -0,0 +1,3 @@ +### Resolve foreground-work inputs from committed results + +Flows can bind a foreground-work input to a required output from an earlier Work transition. Boatstack resolves that input from the applicable committed journal record. The downstream result keeps the exact producer and byte provenance. From d43fda6e68465ec1b6fa615023d11a76a29744b5 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 15:52:48 +0100 Subject: [PATCH 2/7] Emit Control Program schema revision 8 --- .../test/software-delivery.test.mjs | 11 +++++++++++ packages/boatstack/src/index.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/boatstack-software-delivery/test/software-delivery.test.mjs b/packages/boatstack-software-delivery/test/software-delivery.test.mjs index b93af712..65dfacc9 100644 --- a/packages/boatstack-software-delivery/test/software-delivery.test.mjs +++ b/packages/boatstack-software-delivery/test/software-delivery.test.mjs @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + CONTROL_PROGRAM_SCHEMA_REVISION, + defineFlow, +} from "@operatorstack/boatstack"; + import { planningPackage, planningPackagePromote, @@ -37,6 +42,12 @@ function definition(overrides = {}) { }; } +test("emits the current Control Program schema revision", () => { + const result = defineFlow(softwareDelivery(definition())); + assert.equal(CONTROL_PROGRAM_SCHEMA_REVISION, 8); + assert.equal(result.schema_revision, 8); +}); + test("composes canonical domain wiring without hidden policy", () => { const result = softwareDelivery( definition({ diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index 61e43e39..c2305cd9 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -16,7 +16,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 = 7 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 8 as const; /** * A declarative condition over runtime state facts. From 6accf11ff00d564485a57cf4d250368e3c55966d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 16:09:07 +0100 Subject: [PATCH 3/7] Bind committed work to the execution program --- boatstack/cmd/boatstack-helper/flow_runtime.go | 14 +++++++------- .../internal/softwaredelivery/effects/receipts.go | 2 +- .../softwaredelivery/effects/receipts_test.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 38ce3bb4..ffa32e0a 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -808,7 +808,7 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp for _, work := range compiled.Document.Work { workByID[work.ID] = work } - options.workInputs, err = resolveForegroundWorkInputs(layout, state.Revision, invocationContext, compiled, entry, *transition, options, workByID) + options.workInputs, err = resolveForegroundWorkInputs(layout, state.Revision, state.ProgramFingerprint, invocationContext, compiled, entry, *transition, options, workByID) if err != nil { return commandOptions{}, err } @@ -847,7 +847,7 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp } resultFingerprint, provenance = record.Result.ResultFingerprint, "work-output-candidate:"+output.SHA256 } else { - committed, found, resolveErr := resolveCommittedWorkOutput(layout, state.Revision, invocationContext, compiled, entry, options, producerTransition, work, binding.Producer.Output) + committed, found, resolveErr := resolveCommittedWorkOutput(layout, state.Revision, state.ProgramFingerprint, invocationContext, compiled, entry, options, producerTransition, work, binding.Producer.Output) if resolveErr != nil { return commandOptions{}, resolveErr } @@ -1009,7 +1009,7 @@ func bindAvailableCommittedWorkInputs(ctx context.Context, compiled controlprogr if producerErr != nil { return commandOptions{}, producerErr } - committed, found, resolveErr := resolveCommittedWorkOutput(layout, state.Revision, current, compiled, entry, options, producer, source, input.Producer.Output) + committed, found, resolveErr := resolveCommittedWorkOutput(layout, state.Revision, state.ProgramFingerprint, current, compiled, entry, options, producer, source, input.Producer.Output) if resolveErr != nil { return commandOptions{}, resolveErr } @@ -1026,7 +1026,7 @@ func bindAvailableCommittedWorkInputs(ctx context.Context, compiled controlprogr return options, nil } -func resolveCommittedWorkOutput(layout ports.ControllerLayout, maximumRevision uint64, current model.InvocationContext, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions, producerTransition controlprogram.Transition, work controlprogram.WorkContract, outputID string) (effects.CommittedWorkOutput, bool, error) { +func resolveCommittedWorkOutput(layout ports.ControllerLayout, maximumRevision uint64, executionProgramFingerprint string, current model.InvocationContext, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions, producerTransition controlprogram.Transition, work controlprogram.WorkContract, outputID string) (effects.CommittedWorkOutput, bool, error) { contract, err := softwareflow.RuntimeWorkContract(work) if err != nil { return effects.CommittedWorkOutput{}, false, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: %w", err) @@ -1035,7 +1035,7 @@ func resolveCommittedWorkOutput(layout ports.ControllerLayout, maximumRevision u ID: options.objectiveID, TargetID: model.TargetID(options.targetID), TrustedClass: model.TargetID(options.trustedObjectiveClass), DeliveryID: options.deliveryID, } committed, found, err := effects.FindApplicableCommittedWorkOutput(layout, effects.CommittedWorkOutputSelector{ - FlowID: options.runID, ProgramID: compiled.Document.Program.ID, ProgramFingerprint: compiled.Fingerprint, EntryID: entry.ID, + FlowID: options.runID, ProgramID: compiled.Document.Program.ID, ProgramFingerprint: executionProgramFingerprint, EntryID: entry.ID, Objective: objective, Invocation: current, TransitionID: catalog.TransitionID(producerTransition.ID), Work: *contract, OutputID: outputID, MaximumRevision: maximumRevision, }) if err != nil { @@ -1044,7 +1044,7 @@ func resolveCommittedWorkOutput(layout ports.ControllerLayout, maximumRevision u return committed, found, nil } -func resolveForegroundWorkInputs(layout ports.ControllerLayout, maximumRevision uint64, current model.InvocationContext, compiled controlprogram.Compiled, entry controlprogram.Entry, transition controlprogram.Transition, options commandOptions, workByID map[string]controlprogram.WorkContract) (map[string]protocol.WorkInputValue, error) { +func resolveForegroundWorkInputs(layout ports.ControllerLayout, maximumRevision uint64, executionProgramFingerprint string, current model.InvocationContext, compiled controlprogram.Compiled, entry controlprogram.Entry, transition controlprogram.Transition, options commandOptions, workByID map[string]controlprogram.WorkContract) (map[string]protocol.WorkInputValue, error) { values := map[string]protocol.WorkInputValue{} for key, value := range options.workInputs { values[key] = value @@ -1076,7 +1076,7 @@ func resolveForegroundWorkInputs(layout ports.ControllerLayout, maximumRevision if producer.ID == transition.ID { return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q cannot consume its own candidate result", work.ID, input.ID) } - committed, found, resolveErr := resolveCommittedWorkOutput(layout, maximumRevision, current, compiled, entry, options, producer, source, input.Producer.Output) + committed, found, resolveErr := resolveCommittedWorkOutput(layout, maximumRevision, executionProgramFingerprint, current, compiled, entry, options, producer, source, input.Producer.Output) if resolveErr != nil { return nil, resolveErr } diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 07fda514..36524fbe 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -177,7 +177,7 @@ func findApplicableCommittedWorkOutput(records []journalRecord, selector Committ } receipt, admission := *selected.Receipt, selected.Admission work := admission.Work - if receipt.Program.ID != selector.ProgramID || receipt.Program.Fingerprint != selector.ProgramFingerprint || admission.ExpectedProgramFingerprint != selector.ProgramFingerprint || + if receipt.Program.Fingerprint != selector.ProgramFingerprint || admission.ExpectedProgramFingerprint != selector.ProgramFingerprint || !sameObjectiveIdentity(admission.Objective, selector.Objective) || work == nil || work.RunID != selector.FlowID || work.ProgramID != selector.ProgramID || work.EntryID != selector.EntryID || work.TransitionID != selector.TransitionID || work.ProgramFingerprint != selector.ProgramFingerprint || work.StateRevision != receipt.PriorStateRevision || work.StateRevision != admission.ExpectedStateRevision || diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index 9e0555bf..cb872199 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -29,7 +29,7 @@ func committedWorkOutputFixture(t *testing.T, sequence, revision uint64) (journa } receipt := protocol.TransitionReceipt{ ID: "trc-producer", FlowID: "run-proof", Sequence: sequence, TransitionID: "produce-a", PriorStateRevision: revision - 1, ResultingStateRevision: revision, - Program: protocol.ProgramIdentity{ID: "proof", Fingerprint: strings.Repeat("3", 64)}, WorkResultFingerprint: work.ResultFingerprint, + Program: protocol.ProgramIdentity{ID: "boatstack.standard", Fingerprint: strings.Repeat("3", 64)}, WorkResultFingerprint: work.ResultFingerprint, } record := journalRecord{Admission: protocol.Admission{ Invocation: invocation, Objective: objective, ExpectedProgramFingerprint: strings.Repeat("3", 64), ExpectedStateRevision: revision - 1, Work: work, From 5eb85a2f10de9e5bfc98c892709864aaac57ab95 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 20:47:00 +0100 Subject: [PATCH 4/7] Scope committed work inputs to selected transitions --- .../cmd/boatstack-helper/flow_runtime.go | 78 ------------------- .../cmd/boatstack-helper/flow_runtime_test.go | 72 +++++++++++++++++ 2 files changed, 72 insertions(+), 78 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index ffa32e0a..9ed46640 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -202,10 +202,6 @@ 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") } - options, err = bindAvailableCommittedWorkInputs(ctx, compiled, entry, options) - if err != nil { - return commandOptions{}, err - } bundle, bundleFingerprint, err := bindControlBundle(ctx, repository, "", nil) if err != nil { return commandOptions{}, err @@ -952,80 +948,6 @@ func workOutputByID(outputs []protocol.WorkOutputEvidence, outputID string) (pro func foregroundWorkInputKey(workID, inputID string) string { return workID + "/" + inputID } -func bindAvailableCommittedWorkInputs(ctx context.Context, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions) (commandOptions, error) { - hasCommittedInputs := false - for _, work := range compiled.Document.Work { - for _, input := range work.Inputs { - if input.Producer.Kind == controlprogram.ParameterSourceWorkOutput { - hasCommittedInputs = true - break - } - } - } - if !hasCommittedInputs { - return options, nil - } - host := options.host - if host == "" { - host = "cli" - } - resolver, err := plant.NewResolver("") - if err != nil { - return commandOptions{}, err - } - current, err := resolver.ResolveInvocation(ctx, options.repository, host, "flow-work-inputs-"+options.runID) - if err != nil { - return commandOptions{}, err - } - layout, current, err := resolver.ResolveLayout(ctx, current) - if err != nil { - return commandOptions{}, err - } - raw, err := os.ReadFile(layout.StatePath) - if os.IsNotExist(err) { - return options, nil - } - if err != nil { - return commandOptions{}, err - } - state, err := durable.DecodeState(raw) - if err != nil { - return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: decode durable state: %w", err) - } - workByID := map[string]controlprogram.WorkContract{} - for _, work := range compiled.Document.Work { - workByID[work.ID] = work - } - for _, consumer := range compiled.Document.Work { - for _, input := range consumer.Inputs { - if input.Producer.Kind != controlprogram.ParameterSourceWorkOutput { - continue - } - source, ok := workByID[input.Producer.Work] - if !ok { - return commandOptions{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q references unknown work %q", consumer.ID, input.ID, input.Producer.Work) - } - producer, producerErr := uniqueWorkProducerTransition(compiled.Document.Transitions, source.ID) - if producerErr != nil { - return commandOptions{}, producerErr - } - committed, found, resolveErr := resolveCommittedWorkOutput(layout, state.Revision, state.ProgramFingerprint, current, compiled, entry, options, producer, source, input.Producer.Output) - if resolveErr != nil { - return commandOptions{}, resolveErr - } - if !found { - continue - } - provenance := protocol.WorkOutputProvenance{ - ReceiptID: committed.Receipt.ID, TransitionID: committed.Receipt.TransitionID, WorkID: committed.Work.ContractID, OutputID: committed.Output.ID, - ResultFingerprint: committed.Work.ResultFingerprint, ContractFingerprint: committed.Work.ContractFingerprint, OutputSHA256: committed.Output.SHA256, - } - options.workInputs[foregroundWorkInputKey(consumer.ID, input.ID)] = protocol.WorkInputValue{Value: committed.Output.Content, Fingerprint: committed.Output.SHA256, WorkOutput: &provenance} - } - } - return options, nil -} - func resolveCommittedWorkOutput(layout ports.ControllerLayout, maximumRevision uint64, executionProgramFingerprint string, current model.InvocationContext, compiled controlprogram.Compiled, entry controlprogram.Entry, options commandOptions, producerTransition controlprogram.Transition, work controlprogram.WorkContract, outputID string) (effects.CommittedWorkOutput, bool, error) { contract, err := softwareflow.RuntimeWorkContract(work) if err != nil { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index e1c68bdd..e09e6e8b 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1430,6 +1430,78 @@ func TestFlowEntryRejectsCallerOverridesDuringUntargetedResolution(t *testing.T) } } +func TestFlowEntryDoesNotResolveCommittedInputsBeforeTransitionSelection(t *testing.T) { + // control-law: unrelated transitions do not depend on committed Work inputs + // that only another transition consumes. + repository := flowRepository(t) + document := productDeliveryDocument("product-delivery") + instructions := "Produce the declared foreground-work output." + producerAsset := controlprogram.WorkAsset{Path: ".boatstack/work/producer.md", SHA256: hash([]byte(instructions)), Content: instructions} + consumerAsset := controlprogram.WorkAsset{Path: ".boatstack/work/consumer.md", SHA256: hash([]byte(instructions)), Content: instructions} + document.Work = []controlprogram.WorkContract{ + {ID: "producer", Instructions: producerAsset, Outputs: []controlprogram.WorkOutput{{ID: "architecture", Path: "architecture.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}}, + {ID: "consumer", Instructions: consumerAsset, Inputs: []controlprogram.WorkInput{{ID: "architecture", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "producer", Output: "architecture"}}}, Outputs: []controlprogram.WorkOutput{{ID: "result", Path: "result.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}}, + } + truth := true + document.Operators = append(document.Operators, controlprogram.Operator{ID: "publication.execute", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.execute", Version: "1"}}) + available := flowKnown("preview_fingerprint") + document.Transitions = append(document.Transitions, controlprogram.Transition{ + ID: "publication.execute", Operator: "publication.execute", Work: "producer", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 76, + Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "preview_fingerprint", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "preview_fingerprint", AvailableWhen: &available}}}, + }) + document.Transitions[0].Work = "consumer" + softwareResolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + execute, err := softwareResolver.ResolveOperator("software-delivery/publication.execute", "1") + if err != nil { + t.Fatal(err) + } + declared := map[string]bool{} + for _, facet := range document.Facets { + declared[facet.ID] = true + } + for _, assignment := range execute.StateEffect.Assignments { + if !declared[assignment.Facet] { + document.Facets = append(document.Facets, controlprogram.Facet{ID: assignment.Facet, Kind: "string"}) + declared[assignment.Facet] = true + } + } + writeFixture(t, repository, producerAsset.Path, []byte(instructions)) + writeFixture(t, repository, consumerAsset.Path, []byte(instructions)) + 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")) + if err := os.RemoveAll(filepath.Join(repository, ".git")); err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + 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) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "irrelevant-committed-input") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + writeFixture(t, layout.JournalRoot, "irrelevant.committed", []byte("not a journal record")) + + bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + if bound.transitionID != "" || len(bound.workInputs) != 0 { + t.Fatalf("untargeted binding resolved transition Work inputs: transition=%q inputs=%#v", bound.transitionID, bound.workInputs) + } +} + func TestFlowEntryDoesNotMaterializeInternalKernelTransition(t *testing.T) { // control-law: repository invocation contracts govern only transitions in // canonical Flow IR; internal kernel transitions retain their trusted path. From 1a4b040f67011a501fbd3b542be6adfc8ab980fc Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 21:21:25 +0100 Subject: [PATCH 5/7] fix(flow): verify committed work inputs at admission --- .../boatstack-helper/delegation_command.go | 6 + .../cmd/boatstack-helper/flow_runtime.go | 46 ++++-- .../cmd/boatstack-helper/flow_runtime_test.go | 148 +++++++++++++++--- boatstack/cmd/boatstack-helper/main.go | 3 +- boatstack/delivery_controller.go | 73 +++++++++ .../delivery_controller_work_input_test.go | 75 +++++++++ 6 files changed, 315 insertions(+), 36 deletions(-) create mode 100644 boatstack/delivery_controller_work_input_test.go diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 049ef15e..0a2c5a95 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "os" @@ -508,6 +509,11 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa // 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) + var producerRequired *committedWorkProducerRequiredError + if errors.As(err, &producerRequired) { + rebound, err = rebindRepositoryTransition(ctx, request, catalog.TransitionID(producerRequired.TransitionID)) + changed = true + } if err != nil || !changed { return request, response, changed, err } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 9ed46640..03385247 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -49,6 +49,16 @@ type flowCommitRequiredError struct { cause error } +type committedWorkProducerRequiredError struct { + TransitionID string + WorkID string + OutputID string +} + +func (e *committedWorkProducerRequiredError) Error() string { + return fmt.Sprintf("FLOW_WORK_PRODUCER_REQUIRED: transition %q must commit work %q output %q before the selected consumer", e.TransitionID, e.WorkID, e.OutputID) +} + func (e *flowCommitRequiredError) Error() string { return fmt.Sprintf("%s: commit the exact Boatstack control bundle at revision %s before product delegation or repository work: %v", controlBundleCommitRequiredCode, e.revision, e.cause) } @@ -953,9 +963,7 @@ func resolveCommittedWorkOutput(layout ports.ControllerLayout, maximumRevision u if err != nil { return effects.CommittedWorkOutput{}, false, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: %w", err) } - objective := model.Objective{ - ID: options.objectiveID, TargetID: model.TargetID(options.targetID), TrustedClass: model.TargetID(options.trustedObjectiveClass), DeliveryID: options.deliveryID, - } + objective := boundFlowObjective(options) committed, found, err := effects.FindApplicableCommittedWorkOutput(layout, effects.CommittedWorkOutputSelector{ FlowID: options.runID, ProgramID: compiled.Document.Program.ID, ProgramFingerprint: executionProgramFingerprint, EntryID: entry.ID, Objective: objective, Invocation: current, TransitionID: catalog.TransitionID(producerTransition.ID), Work: *contract, OutputID: outputID, MaximumRevision: maximumRevision, @@ -1003,7 +1011,7 @@ func resolveForegroundWorkInputs(layout ports.ControllerLayout, maximumRevision return nil, resolveErr } if !found { - return nil, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q has no applicable committed producer result", work.ID, input.ID) + return nil, &committedWorkProducerRequiredError{TransitionID: producer.ID, WorkID: source.ID, OutputID: input.Producer.Output} } provenance := protocol.WorkOutputProvenance{ ReceiptID: committed.Receipt.ID, TransitionID: committed.Receipt.TransitionID, WorkID: committed.Work.ContractID, OutputID: committed.Output.ID, @@ -1195,6 +1203,7 @@ func bindCommittedActiveRun(options commandOptions, active model.Objective, rece } options.runID, options.deliveryID = receipt.FlowID, active.DeliveryID options.objectiveID, options.targetID, options.trustedObjectiveClass = active.ID, string(active.TargetID), string(active.TrustedObjectiveClass()) + options.objectiveFrontierIsStop = active.FrontierIsStop options.activeFlowBound = true return options, nil } @@ -1227,7 +1236,8 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque 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, maintenanceParameterSurface: maintenanceParameterSurface && request.TransitionID != "" && !repositoryTransition, + objectiveFrontierIsStop: request.Objective.FrontierIsStop, + transitionID: string(request.TransitionID), parameters: parameterFlags, maintenanceParameterSurface: maintenanceParameterSurface && request.TransitionID != "" && !repositoryTransition, controlBundleRevision: request.ControlBundleRevision, }) if err != nil { @@ -1244,6 +1254,7 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque request.Objective.TargetID = model.TargetID(bound.targetID) request.Objective.TrustedClass = model.TargetID(bound.trustedObjectiveClass) request.Objective.DeliveryID = bound.deliveryID + request.Objective.FrontierIsStop = bound.objectiveFrontierIsStop request.Parameters = parameters request.DelegationBindingFingerprint = bound.delegationBindingFingerprint request.DelegationRequestFingerprint = bound.delegationRequestFingerprint @@ -1342,21 +1353,36 @@ func bindPrescribedRepositoryInvocation(ctx context.Context, request surfaces.Re if response.Prescription.InvocationFingerprint != "" { return surfaces.Request{}, false, fmt.Errorf("FLOW_INVOCATION_INVALID: unmaterialized repository prescription carries invocation identity") } + rebound, err := rebindRepositoryTransition(ctx, request, response.Prescription.TransitionID) + if err != nil { + return surfaces.Request{}, false, err + } + return rebound, true, nil +} + +func rebindRepositoryTransition(ctx context.Context, request surfaces.Request, transitionID catalog.TransitionID) (surfaces.Request, error) { rebound := request - rebound.TransitionID = response.Prescription.TransitionID + rebound.TransitionID = transitionID rebound.Parameters = nil rebound.Prescription = protocol.Prescription{} rebound.IdempotencyKey = "" rebound.InvocationEvidence = nil rebound.InputRequest = nil - rebound, err = bindRPCFlowEntry(ctx, rebound) + rebound, err := bindRPCFlowEntry(ctx, rebound) if err != nil { - return surfaces.Request{}, false, err + return surfaces.Request{}, 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 surfaces.Request{}, fmt.Errorf("FLOW_INVOCATION_INCOMPLETE: selected repository transition produced neither an input request nor invocation evidence") + } + return rebound, nil +} + +func boundFlowObjective(options commandOptions) model.Objective { + return model.Objective{ + ID: options.objectiveID, TargetID: model.TargetID(options.targetID), TrustedClass: model.TargetID(options.trustedObjectiveClass), + DeliveryID: options.deliveryID, FrontierIsStop: options.objectiveFrontierIsStop, } - return rebound, true, nil } func resolveBoundPlan(repository string, entry controlprogram.Entry, entryObjective softwareflow.EntryObjective, options commandOptions) (string, string, error) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index e09e6e8b..5c908aa0 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -17,6 +18,7 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/distribution" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" @@ -1433,6 +1435,104 @@ func TestFlowEntryRejectsCallerOverridesDuringUntargetedResolution(t *testing.T) func TestFlowEntryDoesNotResolveCommittedInputsBeforeTransitionSelection(t *testing.T) { // control-law: unrelated transitions do not depend on committed Work inputs // that only another transition consumes. + repository := flowRepositoryWithWorkDependency(t) + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "irrelevant-committed-input") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + writeFixture(t, layout.JournalRoot, "irrelevant.committed", []byte("not a journal record")) + + bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + if bound.transitionID != "" || len(bound.workInputs) != 0 { + t.Fatalf("untargeted binding resolved transition Work inputs: transition=%q inputs=%#v", bound.transitionID, bound.workInputs) + } +} + +func TestMissingCommittedInputRebindsProducerWithSatisfiedFlowTarget(t *testing.T) { + // control-law: missing committed evidence redirects resolution to the exact + // producer even when its state target is already satisfied. + repository := flowRepositoryWithWorkDependency(t) + 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) + } + definition, err := loadFlowDefinition(context.Background(), repository, "product-delivery") + if err != nil { + t.Fatal(err) + } + program, err := distribution.ProgramForRepository(context.Background(), distribution.RepositoryProgramRequest{Repository: repository, Host: "codex", CorrelationID: request.CorrelationID}, definition) + if err != nil { + t.Fatal(err) + } + writeAdmittedFlowProgramState(t, repository, program.Fingerprint()) + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "producer-prerequisite") + if err != nil { + t.Fatal(err) + } + layout, _, 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) + } + state.PreviewFingerprint = strings.Repeat("a", 64) + raw, err = durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { + t.Fatal(err) + } + + candidate := surfaces.Response{Prescription: &protocol.Prescription{SchemaVersion: protocol.PrescriptionSchemaVersion, TransitionID: "publication.observe"}} + _, changed, err := bindPrescribedRepositoryInvocation(context.Background(), request, candidate) + var producerRequired *committedWorkProducerRequiredError + if changed || !errors.As(err, &producerRequired) { + t.Fatalf("missing producer result = changed %t error %v", changed, err) + } + rebound, err := rebindRepositoryTransition(context.Background(), request, catalog.TransitionID(producerRequired.TransitionID)) + if err != nil { + t.Fatal(err) + } + if rebound.TransitionID != "publication.execute" || rebound.InvocationEvidence == nil { + t.Fatalf("producer prerequisite rebound = transition %q evidence %#v", rebound.TransitionID, rebound.InvocationEvidence) + } + stabilizedRequest, stabilized, stabilizedChanged, err := stabilizeRepositoryPrescription(context.Background(), request, candidate) + if err != nil { + t.Fatal(err) + } + if !stabilizedChanged || stabilizedRequest.TransitionID != "publication.execute" || stabilized.Decision == nil { + t.Fatalf("stabilized prerequisite = changed %t transition %q decision %#v", stabilizedChanged, stabilizedRequest.TransitionID, stabilized.Decision) + } +} + +func flowRepositoryWithWorkDependency(t *testing.T) string { + t.Helper() repository := flowRepository(t) document := productDeliveryDocument("product-delivery") instructions := "Produce the declared foreground-work output." @@ -1444,11 +1544,17 @@ func TestFlowEntryDoesNotResolveCommittedInputsBeforeTransitionSelection(t *test } truth := true document.Operators = append(document.Operators, controlprogram.Operator{ID: "publication.execute", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.execute", Version: "1"}}) + document.Operators = append(document.Operators, controlprogram.Operator{ID: "publication.reconcile", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.reconcile", Version: "1"}}) + document.Facets = append(document.Facets, controlprogram.Facet{ID: softwareflow.RecoveryTransactionFacet, Kind: "string"}) available := flowKnown("preview_fingerprint") document.Transitions = append(document.Transitions, controlprogram.Transition{ ID: "publication.execute", Operator: "publication.execute", Work: "producer", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 76, Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "preview_fingerprint", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "preview_fingerprint", AvailableWhen: &available}}}, }) + document.Transitions = append(document.Transitions, controlprogram.Transition{ + ID: "publication.reconcile", Operator: "publication.reconcile", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 75, + Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "transaction_id", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: softwareflow.RecoveryTransactionFacet, AvailableWhen: ptrPredicate(flowKnown(softwareflow.RecoveryTransactionFacet))}}}, + }) document.Transitions[0].Work = "consumer" softwareResolver, err := softwareflow.NewResolver(context.Background()) if err != nil { @@ -1458,14 +1564,26 @@ func TestFlowEntryDoesNotResolveCommittedInputsBeforeTransitionSelection(t *test if err != nil { t.Fatal(err) } + reconcile, err := softwareResolver.ResolveOperator("software-delivery/publication.reconcile", "1") + if err != nil { + t.Fatal(err) + } declared := map[string]bool{} for _, facet := range document.Facets { declared[facet.ID] = true } - for _, assignment := range execute.StateEffect.Assignments { - if !declared[assignment.Facet] { - document.Facets = append(document.Facets, controlprogram.Facet{ID: assignment.Facet, Kind: "string"}) - declared[assignment.Facet] = true + for _, resolved := range []controlprogram.ResolvedOperator{execute, reconcile} { + for _, precondition := range resolved.StateEffect.Preconditions { + if !declared[precondition.Facet] { + document.Facets = append(document.Facets, controlprogram.Facet{ID: precondition.Facet, Kind: "string"}) + declared[precondition.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 + } } } writeFixture(t, repository, producerAsset.Path, []byte(instructions)) @@ -1479,27 +1597,7 @@ func TestFlowEntryDoesNotResolveCommittedInputsBeforeTransitionSelection(t *test 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) - } - invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "irrelevant-committed-input") - if err != nil { - t.Fatal(err) - } - layout, _, err := resolver.ResolveLayout(context.Background(), invocation) - if err != nil { - t.Fatal(err) - } - writeFixture(t, layout.JournalRoot, "irrelevant.committed", []byte("not a journal record")) - - bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) - if err != nil { - t.Fatal(err) - } - if bound.transitionID != "" || len(bound.workInputs) != 0 { - t.Fatalf("untargeted binding resolved transition Work inputs: transition=%q inputs=%#v", bound.transitionID, bound.workInputs) - } + return repository } func TestFlowEntryDoesNotMaterializeInternalKernelTransition(t *testing.T) { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index ddab518e..b94c0387 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -47,6 +47,7 @@ type commandOptions struct { objectiveID string targetID string trustedObjectiveClass string + objectiveFrontierIsStop bool deliveryID string programID string flowProgramFingerprint string @@ -750,7 +751,7 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface } objective := model.Objective{} if options.targetID != "" || options.objectiveID != "" || options.deliveryID != "" { - objective = model.Objective{ID: options.objectiveID, TargetID: model.TargetID(options.targetID), TrustedClass: model.TargetID(options.trustedObjectiveClass), DeliveryID: options.deliveryID} + objective = boundFlowObjective(options) if err := objective.Validate(); err != nil { return surfaces.Request{}, err } diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index e17f15ac..7ccbf078 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -8,6 +8,7 @@ import ( "os" "time" + "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/delivery" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" @@ -169,6 +170,10 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request resolveRequest := engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain, ControlBundle: request.ControlBundle, ControlBundleRevision: request.ControlBundleRevision, InvocationEvidence: request.InvocationEvidence} resolution, resolveErr := k.engine.Resolve(ctx, resolveRequest) if !explain && resolveErr == nil && resolution.Decision.Kind == supervisor.DecisionCandidate && resolution.Decision.Transition != nil && resolution.Decision.Transition.Work != nil { + if verifyErr := k.verifyCommittedWorkInputs(ctx, invocation, request.FlowID, request.ProgramID, request.EntryID, resolution.Objective, resolution.Snapshot, *resolution.Decision.Transition, request.WorkInputs); verifyErr != nil { + response.Error = verifyErr.Error() + return response, verifyErr + } record, workErr := k.work.Ensure(ctx, invocation, request.FlowID, request.ProgramID, request.EntryID, resolution.Objective, resolution.Snapshot, *resolution.Decision.Transition, request.WorkInputs) if workErr != nil { response.Error = workErr.Error() @@ -354,6 +359,74 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request } } +func (k DeliveryController) verifyCommittedWorkInputs(ctx context.Context, invocation model.InvocationContext, flowID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, inputs map[string]protocol.WorkInputValue) error { + if transition.Work == nil { + return nil + } + var layout ports.ControllerLayout + layoutResolved := false + for _, input := range transition.Work.Inputs { + if input.Producer.Kind != controlprogram.ParameterSourceWorkOutput { + continue + } + value, found := inputs[transition.Work.ID+"/"+input.ID] + if !found || value.WorkOutput == nil { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q lacks committed producer provenance", transition.Work.ID, input.ID) + } + producer, err := uniqueRuntimeWorkProducer(k.registry.All(), input.Producer.Work) + if err != nil { + return err + } + if !layoutResolved { + var current model.InvocationContext + layout, current, err = k.resolver.ResolveLayout(ctx, invocation) + if err != nil { + return err + } + invocation = current + layoutResolved = true + } + committed, applicable, err := effects.FindApplicableCommittedWorkOutput(layout, effects.CommittedWorkOutputSelector{ + FlowID: flowID, ProgramID: programID, ProgramFingerprint: snapshot.ProgramFingerprint, EntryID: entryID, + Objective: objective, Invocation: invocation, TransitionID: producer.ID, Work: *producer.Work, + OutputID: input.Producer.Output, MaximumRevision: snapshot.StateRevision, + }) + if err != nil { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q: %w", transition.Work.ID, input.ID, err) + } + if !applicable || !matchesCommittedWorkInput(value, committed) { + return fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q input %q does not match an applicable committed producer result", transition.Work.ID, input.ID) + } + } + return nil +} + +func uniqueRuntimeWorkProducer(transitions []catalog.Transition, workID string) (catalog.Transition, error) { + var producer catalog.Transition + for _, transition := range transitions { + if transition.Work == nil || transition.Work.ID != workID { + continue + } + if producer.ID != "" { + return catalog.Transition{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q has ambiguous producer transitions", workID) + } + producer = transition + } + if producer.ID == "" { + return catalog.Transition{}, fmt.Errorf("FLOW_WORK_EVIDENCE_STALE: work %q has no producer transition", workID) + } + return producer, nil +} + +func matchesCommittedWorkInput(value protocol.WorkInputValue, committed effects.CommittedWorkOutput) bool { + provenance := value.WorkOutput + return provenance != nil && value.Value == committed.Output.Content && value.Fingerprint == committed.Output.SHA256 && + provenance.ReceiptID == committed.Receipt.ID && provenance.TransitionID == committed.Receipt.TransitionID && + provenance.WorkID == committed.Work.ContractID && provenance.OutputID == committed.Output.ID && + provenance.ResultFingerprint == committed.Work.ResultFingerprint && provenance.ContractFingerprint == committed.Work.ContractFingerprint && + provenance.OutputSHA256 == committed.Output.SHA256 +} + func programChangeFor(snapshot *model.Snapshot) *surfaces.ProgramChange { if snapshot == nil || snapshot.Program.Status != model.FactKnown || snapshot.Program.Value != model.ProgramDrift { return nil diff --git a/boatstack/delivery_controller_work_input_test.go b/boatstack/delivery_controller_work_input_test.go new file mode 100644 index 00000000..8f5eb6bc --- /dev/null +++ b/boatstack/delivery_controller_work_input_test.go @@ -0,0 +1,75 @@ +package boatstack + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os/exec" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "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/testprogram" +) + +func TestCommittedWorkInputsRejectUncommittedProvenanceBeforeManager(t *testing.T) { + // control-law: a public Work input cannot manufacture its producer fact. + repository := t.TempDir() + command := exec.Command("git", "init", "-q", repository) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("initialize fixture repository: %v\n%s", err, output) + } + resolver, err := plant.NewResolver(t.TempDir()) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "forged-work-input") + if err != nil { + t.Fatal(err) + } + transitions := testprogram.StandardRegistry().All() + producer, consumer := transitions[0], transitions[1] + producerInstructions := "Produce architecture." + producerDigest := sha256.Sum256([]byte(producerInstructions)) + producer.Work, err = softwareflow.RuntimeWorkContract(controlprogram.WorkContract{ + ID: "producer", Instructions: controlprogram.WorkAsset{Path: "producer.md", SHA256: hex.EncodeToString(producerDigest[:]), Content: producerInstructions}, + Outputs: []controlprogram.WorkOutput{{ID: "architecture", Path: "architecture.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}, + }) + if err != nil { + t.Fatal(err) + } + consumerInstructions := "Consume architecture." + consumerDigest := sha256.Sum256([]byte(consumerInstructions)) + consumer.Work, err = softwareflow.RuntimeWorkContract(controlprogram.WorkContract{ + ID: "consumer", Instructions: controlprogram.WorkAsset{Path: "consumer.md", SHA256: hex.EncodeToString(consumerDigest[:]), Content: consumerInstructions}, + Inputs: []controlprogram.WorkInput{{ID: "architecture", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "producer", Output: "architecture"}}}, + Outputs: []controlprogram.WorkOutput{{ID: "result", Path: "result.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}, + }) + if err != nil { + t.Fatal(err) + } + transitions[0], transitions[1] = producer, consumer + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + controller := DeliveryController{registry: registry, resolver: resolver} + objective := model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, TrustedClass: model.ObjectiveOpenPR, DeliveryID: "delivery"} + snapshot := model.Snapshot{Observation: model.Observation{Invocation: invocation, StateRevision: 2, ProgramFingerprint: strings.Repeat("e", 64)}} + forged := protocol.WorkInputValue{ + Value: "architecture", Fingerprint: strings.Repeat("f", 64), + WorkOutput: &protocol.WorkOutputProvenance{ + ReceiptID: "missing-receipt", TransitionID: producer.ID, WorkID: "producer", OutputID: "architecture", + ResultFingerprint: strings.Repeat("1", 64), ContractFingerprint: producer.Work.Fingerprint, OutputSHA256: strings.Repeat("f", 64), + }, + } + err = controller.verifyCommittedWorkInputs(context.Background(), invocation, "run-one", "program", "entry", objective, snapshot, consumer, map[string]protocol.WorkInputValue{"consumer/architecture": forged}) + if err == nil || !strings.Contains(err.Error(), "does not match an applicable committed producer result") { + t.Fatalf("forged provenance result = %v", err) + } +} From 0b19c6041e4eb8ee864249c7d61e223aa7f981be Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 21:35:59 +0100 Subject: [PATCH 6/7] test(flow): cover inapplicable work producer guard --- .../cmd/boatstack-helper/flow_runtime_test.go | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 5c908aa0..6e60df9a 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1531,7 +1531,35 @@ func TestMissingCommittedInputRebindsProducerWithSatisfiedFlowTarget(t *testing. } } +func TestWorkDependencyRejectsInapplicableProducerGuard(t *testing.T) { + // control-law: a repository Flow cannot admit a Work dependency whose + // producer adds an unrelated source guard that can be false at consumption. + repository := flowRepositoryWithWorkDependencyProducerGuard(t, flowFact("producer_gate", "open")) + 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) + } + definition, err := loadFlowDefinition(context.Background(), repository, "product-delivery") + if err != nil { + t.Fatal(err) + } + _, err = distribution.ProgramForRepository(context.Background(), distribution.RepositoryProgramRequest{Repository: repository, Host: "codex", CorrelationID: request.CorrelationID}, definition) + if err == nil || !strings.Contains(err.Error(), "invalid or phase-duplicating facet condition") { + t.Fatalf("inapplicable producer guard result = %v", err) + } +} + func flowRepositoryWithWorkDependency(t *testing.T) string { + t.Helper() + truth := true + return flowRepositoryWithWorkDependencyProducerGuard(t, controlprogram.Predicate{True: &truth}) +} + +func flowRepositoryWithWorkDependencyProducerGuard(t *testing.T, producerGuard controlprogram.Predicate) string { t.Helper() repository := flowRepository(t) document := productDeliveryDocument("product-delivery") @@ -1546,9 +1574,10 @@ func flowRepositoryWithWorkDependency(t *testing.T) string { document.Operators = append(document.Operators, controlprogram.Operator{ID: "publication.execute", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.execute", Version: "1"}}) document.Operators = append(document.Operators, controlprogram.Operator{ID: "publication.reconcile", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.reconcile", Version: "1"}}) document.Facets = append(document.Facets, controlprogram.Facet{ID: softwareflow.RecoveryTransactionFacet, Kind: "string"}) + document.Facets = append(document.Facets, controlprogram.Facet{ID: "producer_gate", Kind: "string"}) available := flowKnown("preview_fingerprint") document.Transitions = append(document.Transitions, controlprogram.Transition{ - ID: "publication.execute", Operator: "publication.execute", Work: "producer", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 76, + ID: "publication.execute", Operator: "publication.execute", Work: "producer", Guard: producerGuard, Target: controlprogram.Predicate{True: &truth}, Priority: 76, Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "preview_fingerprint", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "preview_fingerprint", AvailableWhen: &available}}}, }) document.Transitions = append(document.Transitions, controlprogram.Transition{ From 88a3ff47e8af01914df566c81533c14e75438c7d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 21:53:32 +0100 Subject: [PATCH 7/7] fix(flow): require refreshable work producers --- .../cmd/boatstack-helper/flow_runtime_test.go | 31 +------------------ boatstack/controlprogram/canonical.go | 5 ++- boatstack/controlprogram/canonical_test.go | 20 ++++++++++++ .../controlprogram/invocation_compile.go | 24 ++++++++++++-- ...vocation-parameters-and-foreground-work.md | 1 + 5 files changed, 48 insertions(+), 33 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 6e60df9a..5c908aa0 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1531,35 +1531,7 @@ func TestMissingCommittedInputRebindsProducerWithSatisfiedFlowTarget(t *testing. } } -func TestWorkDependencyRejectsInapplicableProducerGuard(t *testing.T) { - // control-law: a repository Flow cannot admit a Work dependency whose - // producer adds an unrelated source guard that can be false at consumption. - repository := flowRepositoryWithWorkDependencyProducerGuard(t, flowFact("producer_gate", "open")) - 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) - } - definition, err := loadFlowDefinition(context.Background(), repository, "product-delivery") - if err != nil { - t.Fatal(err) - } - _, err = distribution.ProgramForRepository(context.Background(), distribution.RepositoryProgramRequest{Repository: repository, Host: "codex", CorrelationID: request.CorrelationID}, definition) - if err == nil || !strings.Contains(err.Error(), "invalid or phase-duplicating facet condition") { - t.Fatalf("inapplicable producer guard result = %v", err) - } -} - func flowRepositoryWithWorkDependency(t *testing.T) string { - t.Helper() - truth := true - return flowRepositoryWithWorkDependencyProducerGuard(t, controlprogram.Predicate{True: &truth}) -} - -func flowRepositoryWithWorkDependencyProducerGuard(t *testing.T, producerGuard controlprogram.Predicate) string { t.Helper() repository := flowRepository(t) document := productDeliveryDocument("product-delivery") @@ -1574,10 +1546,9 @@ func flowRepositoryWithWorkDependencyProducerGuard(t *testing.T, producerGuard c document.Operators = append(document.Operators, controlprogram.Operator{ID: "publication.execute", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.execute", Version: "1"}}) document.Operators = append(document.Operators, controlprogram.Operator{ID: "publication.reconcile", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.reconcile", Version: "1"}}) document.Facets = append(document.Facets, controlprogram.Facet{ID: softwareflow.RecoveryTransactionFacet, Kind: "string"}) - document.Facets = append(document.Facets, controlprogram.Facet{ID: "producer_gate", Kind: "string"}) available := flowKnown("preview_fingerprint") document.Transitions = append(document.Transitions, controlprogram.Transition{ - ID: "publication.execute", Operator: "publication.execute", Work: "producer", Guard: producerGuard, Target: controlprogram.Predicate{True: &truth}, Priority: 76, + ID: "publication.execute", Operator: "publication.execute", Work: "producer", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 76, Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "preview_fingerprint", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceState, Facet: "preview_fingerprint", AvailableWhen: &available}}}, }) document.Transitions = append(document.Transitions, controlprogram.Transition{ diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index 4221a6b9..531d1725 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -301,8 +301,11 @@ func normalizeWorkInputProducers(document *Document, work map[string]WorkContrac if len(producers) != 1 { return invalid(field, "work output does not have exactly one producer transition") } + producer := producers[0] + if !predicateImplies(producer.Target, producer.Guard) { + return invalid(field, "work output producer is not refreshable at its target") + } for _, consumer := range transitionsByWork[contract.ID] { - producer := producers[0] if producer.ID == consumer.ID || producer.Priority >= consumer.Priority || !predicateImplies(consumer.Guard, producer.Target) { return invalid(field, "work output is not guaranteed before the consuming foreground work") } diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 50431953..58d97cb7 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -568,6 +568,9 @@ func TestForegroundWorkRejectsUnboundAssetsInputsAndTransitions(t *testing.T) { func foregroundWorkDependencyProgram() controlprogram.Document { document := incidentWorkProgram() + document.Transitions[0].Guard = controlprogram.Predicate{Any: []controlprogram.Predicate{ + fact("incident", "open"), fact("incident", "mitigated"), + }} instructions := "Use the committed diagnosis to produce a response." digest := sha256.Sum256([]byte(instructions)) document.Work = append(document.Work, controlprogram.WorkContract{ @@ -600,6 +603,9 @@ func TestForegroundWorkInputsRequireOnePriorCommittedProducer(t *testing.T) { "optional-output": {func(value *controlprogram.Document) { value.Work[0].Outputs[0].Required = false }, "optional or unknown"}, "invalid-order": {func(value *controlprogram.Document) { value.Transitions[1].Priority = 5 }, "not guaranteed"}, "unimplied-target": {func(value *controlprogram.Document) { value.Transitions[1].Guard = fact("incident", "open") }, "not guaranteed"}, + "non-refreshable-producer": {func(value *controlprogram.Document) { + value.Transitions[0].Guard = fact("incident", "open") + }, "not refreshable"}, } { t.Run(name, func(t *testing.T) { document := foregroundWorkDependencyProgram() @@ -611,6 +617,20 @@ func TestForegroundWorkInputsRequireOnePriorCommittedProducer(t *testing.T) { } } +func TestForegroundWorkDependencyAcceptsNestedTargetImplication(t *testing.T) { + document := foregroundWorkDependencyProgram() + for index := range document.Transitions { + if document.Transitions[index].ID == "dispatch" { + document.Transitions[index].Guard = controlprogram.Predicate{All: []controlprogram.Predicate{{All: []controlprogram.Predicate{ + fact("incident", "mitigated"), fact("service", "degraded"), + }}}} + } + } + if _, err := controlprogram.Compile(document, nil); err != nil { + t.Fatal(err) + } +} + func TestCanonicalFingerprintIgnoresOrderingAndDescriptions(t *testing.T) { // control-law: canonical-program-identity-binds-executable-semantics-only base, err := controlprogram.Compile(incidentProgram(), nil) diff --git a/boatstack/controlprogram/invocation_compile.go b/boatstack/controlprogram/invocation_compile.go index 6ec2e9dc..0f9e4bd5 100644 --- a/boatstack/controlprogram/invocation_compile.go +++ b/boatstack/controlprogram/invocation_compile.go @@ -470,9 +470,29 @@ func predicateImplies(guard, condition Predicate) bool { if string(wanted) == string(actual) { return true } + if len(condition.All) != 0 { + for _, child := range condition.All { + if !predicateImplies(guard, child) { + return false + } + } + return true + } + if len(guard.Any) != 0 { + for _, child := range guard.Any { + if !predicateImplies(child, condition) { + return false + } + } + return true + } for _, child := range guard.All { - encoded, _ := json.Marshal(child) - if string(encoded) == string(wanted) { + if predicateImplies(child, condition) { + return true + } + } + for _, child := range condition.Any { + if predicateImplies(guard, child) { return true } } diff --git a/docs/concepts/invocation-parameters-and-foreground-work.md b/docs/concepts/invocation-parameters-and-foreground-work.md index b96204fa..7ed28fd4 100644 --- a/docs/concepts/invocation-parameters-and-foreground-work.md +++ b/docs/concepts/invocation-parameters-and-foreground-work.md @@ -24,6 +24,7 @@ evidence for parameter materialization, not authority to perform an effect. - Foreground-work completion does not independently advance Flow state. - Same-transition parameters consume the candidate result being admitted. - Later transitions and Work contracts consume only the applicable committed result. +- A cross-transition Work producer remains source-admissible at its own target so current-program evidence can be refreshed. - A later result commits the exact producer receipt, result, contract, output, and byte identities. - Requests and answers are correlated to the exact run and generation. - Restart and resume preserve the same run and request lineage.