diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index 8d3f2541..c79a4c67 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -73,6 +73,7 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim selected := make([]delivery.Transition, 0, len(d.compiled.Document.Transitions)) seen := map[delivery.TransitionID]bool{} + targetsByTransition := map[string][]model.TargetID{} var admittedPackageWork *delivery.WorkContract var promotionPlanOutput string for _, declaration := range d.compiled.Document.Transitions { @@ -154,8 +155,12 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim if err := requireReachableEntryInputs(transition, entriesByTarget); err != nil { return delivery.ProgramRuntimeManifest{}, err } + targetsByTransition[declaration.ID] = append([]model.TargetID(nil), transition.TargetIDs...) selected = append(selected, transition) } + if err := requireWorkOutputTargetCoverage(d.compiled.Document, targetsByTransition); err != nil { + return delivery.ProgramRuntimeManifest{}, err + } if promotionPlanOutput != "" { if admittedPackageWork == nil { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("%s requires %s with foreground work", PlanningPackagePromote, WorkPackageAdmit) @@ -219,6 +224,63 @@ func requireCompleteWorkPackageLifecycle(selectedIDs map[delivery.TransitionID]b return nil } +// requireWorkOutputTargetCoverage rejects work-output dependencies whose +// producer transition cannot be selected under every objective that can +// select the consumer. Compilation proves only predicate and priority +// ordering; it never sees trusted TargetIDs. Without this check, a run +// targeting a consumer-only objective redirects to a producer transition +// that targeted resolution refuses, and the unchanged state re-selects the +// consumer: a permanent zero-progress path. Per-edge coverage extends +// transitively across dependency chains. +func requireWorkOutputTargetCoverage(document controlprogram.Document, targetsByTransition map[string][]model.TargetID) error { + producersByWork := map[string][]string{} + for _, declaration := range document.Transitions { + if declaration.Work != "" { + producersByWork[declaration.Work] = append(producersByWork[declaration.Work], declaration.ID) + } + } + workByID := map[string]controlprogram.WorkContract{} + for _, work := range document.Work { + workByID[work.ID] = work + } + requireCoverage := func(consumerID, producerWork string) error { + producers := producersByWork[producerWork] + if len(producers) != 1 { + return fmt.Errorf("transition %q work output %q does not have exactly one producer transition", consumerID, producerWork) + } + producerID := producers[0] + if producerID == consumerID { + return nil + } + if !containsAll(targetsByTransition[producerID], targetsByTransition[consumerID]) { + return fmt.Errorf("transition %q consumes work output of transition %q, whose supported targets %v do not cover consumer targets %v", consumerID, producerID, targetsByTransition[producerID], targetsByTransition[consumerID]) + } + return nil + } + for _, declaration := range document.Transitions { + for _, binding := range declaration.Parameters { + if binding.Producer.Kind != controlprogram.ParameterSourceWorkOutput { + continue + } + if err := requireCoverage(declaration.ID, binding.Producer.Work); err != nil { + return err + } + } + if declaration.Work == "" { + continue + } + for _, input := range workByID[declaration.Work].Inputs { + if input.Producer.Kind != controlprogram.ParameterSourceWorkOutput { + continue + } + if err := requireCoverage(declaration.ID, input.Producer.Work); err != nil { + return err + } + } + } + return nil +} + func requireReachableEntryInputs(transition delivery.Transition, entriesByTarget map[model.TargetID][]controlprogram.Entry) error { if transition.Work == nil || len(transition.Work.Inputs) == 0 { return nil diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 7c250188..ddde7032 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -615,6 +615,103 @@ func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) t.Fatal("trusted plan.abandon transition was not selected") } +func abandonmentWorkDependencyDocument(t *testing.T, producerID string, producerPriority int) controlprogram.Document { + t.Helper() + truth := true + summary := "Summarize the delivery outcome." + summaryDigest := sha256.Sum256([]byte(summary)) + note := "Publish the closing note from the summary report." + noteDigest := sha256.Sum256([]byte(note)) + document := controlprogram.Document{ + Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, + Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, + Facets: []controlprogram.Facet{ + {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, + {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, + {ID: "delivery", Kind: "string"}, {ID: "workspace", Kind: "string"}, + {ID: "publication_id", Kind: "string"}, {ID: "plan", Kind: "string"}, + {ID: "phase", Kind: "string"}, {ID: "terminal", Kind: "string"}, + }, + Operators: []controlprogram.Operator{ + {ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}, + {ID: "plan.abandon", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/plan.abandon", Version: "1"}}, + }, + Work: []controlprogram.WorkContract{ + { + ID: "summary", Instructions: controlprogram.WorkAsset{Path: "summary.md", SHA256: hex.EncodeToString(summaryDigest[:]), Content: summary}, + Outputs: []controlprogram.WorkOutput{{ID: "report", Path: "report.md", MediaType: "text/markdown", Required: true}}, + }, + { + ID: "publish-note", Instructions: controlprogram.WorkAsset{Path: "publish-note.md", SHA256: hex.EncodeToString(noteDigest[:]), Content: note}, + Inputs: []controlprogram.WorkInput{{ID: "report", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "summary", Output: "report"}}}, + Outputs: []controlprogram.WorkOutput{{ID: "note", Path: "note.md", MediaType: "text/markdown", Required: true}}, + }, + }, + Transitions: []controlprogram.Transition{ + {ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77, Work: "publish-note", Parameters: publicationIDStateParameter(controlprogram.Predicate{True: &truth})}, + {ID: "plan.abandon", Operator: "plan.abandon", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 31}, + }, + Targets: []controlprogram.Target{ + {ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("verification", "current"), fact("configuration", "verified"), fact("runtime", "verified"), fact("publication", "open")}}}, + {ID: "safely-abandoned", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("delivery", "discarded"), {Fact: &controlprogram.FactPredicate{Facet: "workspace", Statuses: []string{"known"}, Values: []string{"abandoned", "absent"}}}}}}, + }, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr"}, {ID: "abandon", Target: "safely-abandoned"}}, + } + attached := false + for index := range document.Transitions { + if document.Transitions[index].ID == producerID { + document.Transitions[index].Work, document.Transitions[index].Priority = "summary", producerPriority + attached = true + } + } + if !attached { + truth := true + document.Operators = append(document.Operators, controlprogram.Operator{ID: producerID, Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/" + producerID, Version: "1"}}) + document.Transitions = append(document.Transitions, controlprogram.Transition{ID: producerID, Operator: producerID, Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: producerPriority, Work: "summary"}) + } + return document +} + +func TestWorkOutputProducerMustCoverConsumerTargets(t *testing.T) { + // control-law: every objective that can select a work-output consumer must + // also admit its required producer transition. Compilation proves only + // predicate and priority ordering, so a program may attach producer work to + // plan.abandon (safely-abandoned only) and dependent consumer work to + // publication.observe (published-pr): a published-pr run then selects the + // consumer, redirects to the missing producer output, targeted resolution + // refuses plan.abandon for that objective, and the unchanged state + // re-selects the consumer — a permanent zero-progress path. + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + uncovered, err := controlprogram.Compile(abandonmentWorkDependencyDocument(t, "plan.abandon", 31), resolver) + if err != nil { + t.Fatal(err) + } + definition, err := softwareflow.NewDefinition(uncovered, resolver) + if err == nil { + _, err = definition.RuntimeManifest(context.Background()) + } + if err == nil || !strings.Contains(err.Error(), "do not cover consumer targets") { + t.Fatalf("uncovered work dependency result = %v", err) + } + + // plan.validate supports every trusted class the consumer supports, so the + // same dependency with a covering producer must stay admissible. + covered, err := controlprogram.Compile(abandonmentWorkDependencyDocument(t, "plan.validate", 50), resolver) + if err != nil { + t.Fatal(err) + } + definition, err = softwareflow.NewDefinition(covered, resolver) + if err != nil { + t.Fatal(err) + } + if _, err = definition.RuntimeManifest(context.Background()); err != nil { + t.Fatalf("covered work dependency was rejected: %v", err) + } +} + func TestCompiledBindingDriftFailsClosed(t *testing.T) { truth := true compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) diff --git a/release-notes/2026-08-21-work-dependency-target-coverage.md b/release-notes/2026-08-21-work-dependency-target-coverage.md new file mode 100644 index 00000000..54dbe1c0 --- /dev/null +++ b/release-notes/2026-08-21-work-dependency-target-coverage.md @@ -0,0 +1,3 @@ +### Reject work dependencies whose producer cannot serve the consumer objective + +RuntimeManifest now requires every work-output producer transition to support all targets its consumer supports, checked after trusted TargetIDs are projected. Programs that attach producer work to an objective the consumer's run can never select are rejected at manifest construction instead of entering a permanent zero-progress selection loop at runtime.