From 5c3222ce532b638dd8435867f03263f160704e02 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 21 Aug 2026 01:42:24 +0100 Subject: [PATCH 1/2] Reject work dependencies whose producer cannot serve the consumer objective Compilation proves only predicate and priority ordering for work-output dependencies, so a program could 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. RuntimeManifest now requires, after trusted TargetIDs are projected, that every work-output producer's target set covers its consumer's target set, over both transition-parameter and foreground-work-input edges. --- boatstack/flow/softwaredelivery/definition.go | 62 ++++++++++++ .../flow/softwaredelivery/definition_test.go | 97 +++++++++++++++++++ 2 files changed, 159 insertions(+) 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}) From 7f7a07aef13a3cfe162ec44adab1c039987b4d00 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 21 Aug 2026 01:46:46 +0100 Subject: [PATCH 2/2] Add release note for work-dependency target coverage --- release-notes/2026-08-21-work-dependency-target-coverage.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 release-notes/2026-08-21-work-dependency-target-coverage.md 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.