Skip to content

Commit fffbab0

Browse files
authored
Reject work dependencies whose producer cannot serve the consumer objective (#234)
* 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. * Add release note for work-dependency target coverage
1 parent ae9e371 commit fffbab0

3 files changed

Lines changed: 162 additions & 0 deletions

File tree

boatstack/flow/softwaredelivery/definition.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim
7373

7474
selected := make([]delivery.Transition, 0, len(d.compiled.Document.Transitions))
7575
seen := map[delivery.TransitionID]bool{}
76+
targetsByTransition := map[string][]model.TargetID{}
7677
var admittedPackageWork *delivery.WorkContract
7778
var promotionPlanOutput string
7879
for _, declaration := range d.compiled.Document.Transitions {
@@ -154,8 +155,12 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim
154155
if err := requireReachableEntryInputs(transition, entriesByTarget); err != nil {
155156
return delivery.ProgramRuntimeManifest{}, err
156157
}
158+
targetsByTransition[declaration.ID] = append([]model.TargetID(nil), transition.TargetIDs...)
157159
selected = append(selected, transition)
158160
}
161+
if err := requireWorkOutputTargetCoverage(d.compiled.Document, targetsByTransition); err != nil {
162+
return delivery.ProgramRuntimeManifest{}, err
163+
}
159164
if promotionPlanOutput != "" {
160165
if admittedPackageWork == nil {
161166
return delivery.ProgramRuntimeManifest{}, fmt.Errorf("%s requires %s with foreground work", PlanningPackagePromote, WorkPackageAdmit)
@@ -219,6 +224,63 @@ func requireCompleteWorkPackageLifecycle(selectedIDs map[delivery.TransitionID]b
219224
return nil
220225
}
221226

227+
// requireWorkOutputTargetCoverage rejects work-output dependencies whose
228+
// producer transition cannot be selected under every objective that can
229+
// select the consumer. Compilation proves only predicate and priority
230+
// ordering; it never sees trusted TargetIDs. Without this check, a run
231+
// targeting a consumer-only objective redirects to a producer transition
232+
// that targeted resolution refuses, and the unchanged state re-selects the
233+
// consumer: a permanent zero-progress path. Per-edge coverage extends
234+
// transitively across dependency chains.
235+
func requireWorkOutputTargetCoverage(document controlprogram.Document, targetsByTransition map[string][]model.TargetID) error {
236+
producersByWork := map[string][]string{}
237+
for _, declaration := range document.Transitions {
238+
if declaration.Work != "" {
239+
producersByWork[declaration.Work] = append(producersByWork[declaration.Work], declaration.ID)
240+
}
241+
}
242+
workByID := map[string]controlprogram.WorkContract{}
243+
for _, work := range document.Work {
244+
workByID[work.ID] = work
245+
}
246+
requireCoverage := func(consumerID, producerWork string) error {
247+
producers := producersByWork[producerWork]
248+
if len(producers) != 1 {
249+
return fmt.Errorf("transition %q work output %q does not have exactly one producer transition", consumerID, producerWork)
250+
}
251+
producerID := producers[0]
252+
if producerID == consumerID {
253+
return nil
254+
}
255+
if !containsAll(targetsByTransition[producerID], targetsByTransition[consumerID]) {
256+
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])
257+
}
258+
return nil
259+
}
260+
for _, declaration := range document.Transitions {
261+
for _, binding := range declaration.Parameters {
262+
if binding.Producer.Kind != controlprogram.ParameterSourceWorkOutput {
263+
continue
264+
}
265+
if err := requireCoverage(declaration.ID, binding.Producer.Work); err != nil {
266+
return err
267+
}
268+
}
269+
if declaration.Work == "" {
270+
continue
271+
}
272+
for _, input := range workByID[declaration.Work].Inputs {
273+
if input.Producer.Kind != controlprogram.ParameterSourceWorkOutput {
274+
continue
275+
}
276+
if err := requireCoverage(declaration.ID, input.Producer.Work); err != nil {
277+
return err
278+
}
279+
}
280+
}
281+
return nil
282+
}
283+
222284
func requireReachableEntryInputs(transition delivery.Transition, entriesByTarget map[model.TargetID][]controlprogram.Entry) error {
223285
if transition.Work == nil || len(transition.Work.Inputs) == 0 {
224286
return nil

boatstack/flow/softwaredelivery/definition_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,103 @@ func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T)
615615
t.Fatal("trusted plan.abandon transition was not selected")
616616
}
617617

618+
func abandonmentWorkDependencyDocument(t *testing.T, producerID string, producerPriority int) controlprogram.Document {
619+
t.Helper()
620+
truth := true
621+
summary := "Summarize the delivery outcome."
622+
summaryDigest := sha256.Sum256([]byte(summary))
623+
note := "Publish the closing note from the summary report."
624+
noteDigest := sha256.Sum256([]byte(note))
625+
document := controlprogram.Document{
626+
Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision,
627+
Program: controlprogram.Program{ID: "product-delivery", Version: "1"},
628+
Facets: []controlprogram.Facet{
629+
{ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"},
630+
{ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"},
631+
{ID: "delivery", Kind: "string"}, {ID: "workspace", Kind: "string"},
632+
{ID: "publication_id", Kind: "string"}, {ID: "plan", Kind: "string"},
633+
{ID: "phase", Kind: "string"}, {ID: "terminal", Kind: "string"},
634+
},
635+
Operators: []controlprogram.Operator{
636+
{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}},
637+
{ID: "plan.abandon", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/plan.abandon", Version: "1"}},
638+
},
639+
Work: []controlprogram.WorkContract{
640+
{
641+
ID: "summary", Instructions: controlprogram.WorkAsset{Path: "summary.md", SHA256: hex.EncodeToString(summaryDigest[:]), Content: summary},
642+
Outputs: []controlprogram.WorkOutput{{ID: "report", Path: "report.md", MediaType: "text/markdown", Required: true}},
643+
},
644+
{
645+
ID: "publish-note", Instructions: controlprogram.WorkAsset{Path: "publish-note.md", SHA256: hex.EncodeToString(noteDigest[:]), Content: note},
646+
Inputs: []controlprogram.WorkInput{{ID: "report", Producer: controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceWorkOutput, Work: "summary", Output: "report"}}},
647+
Outputs: []controlprogram.WorkOutput{{ID: "note", Path: "note.md", MediaType: "text/markdown", Required: true}},
648+
},
649+
},
650+
Transitions: []controlprogram.Transition{
651+
{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})},
652+
{ID: "plan.abandon", Operator: "plan.abandon", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 31},
653+
},
654+
Targets: []controlprogram.Target{
655+
{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("verification", "current"), fact("configuration", "verified"), fact("runtime", "verified"), fact("publication", "open")}}},
656+
{ID: "safely-abandoned", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("delivery", "discarded"), {Fact: &controlprogram.FactPredicate{Facet: "workspace", Statuses: []string{"known"}, Values: []string{"abandoned", "absent"}}}}}},
657+
},
658+
Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr"}, {ID: "abandon", Target: "safely-abandoned"}},
659+
}
660+
attached := false
661+
for index := range document.Transitions {
662+
if document.Transitions[index].ID == producerID {
663+
document.Transitions[index].Work, document.Transitions[index].Priority = "summary", producerPriority
664+
attached = true
665+
}
666+
}
667+
if !attached {
668+
truth := true
669+
document.Operators = append(document.Operators, controlprogram.Operator{ID: producerID, Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/" + producerID, Version: "1"}})
670+
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"})
671+
}
672+
return document
673+
}
674+
675+
func TestWorkOutputProducerMustCoverConsumerTargets(t *testing.T) {
676+
// control-law: every objective that can select a work-output consumer must
677+
// also admit its required producer transition. Compilation proves only
678+
// predicate and priority ordering, so a program may attach producer work to
679+
// plan.abandon (safely-abandoned only) and dependent consumer work to
680+
// publication.observe (published-pr): a published-pr run then selects the
681+
// consumer, redirects to the missing producer output, targeted resolution
682+
// refuses plan.abandon for that objective, and the unchanged state
683+
// re-selects the consumer — a permanent zero-progress path.
684+
resolver, err := softwareflow.NewResolver(context.Background())
685+
if err != nil {
686+
t.Fatal(err)
687+
}
688+
uncovered, err := controlprogram.Compile(abandonmentWorkDependencyDocument(t, "plan.abandon", 31), resolver)
689+
if err != nil {
690+
t.Fatal(err)
691+
}
692+
definition, err := softwareflow.NewDefinition(uncovered, resolver)
693+
if err == nil {
694+
_, err = definition.RuntimeManifest(context.Background())
695+
}
696+
if err == nil || !strings.Contains(err.Error(), "do not cover consumer targets") {
697+
t.Fatalf("uncovered work dependency result = %v", err)
698+
}
699+
700+
// plan.validate supports every trusted class the consumer supports, so the
701+
// same dependency with a covering producer must stay admissible.
702+
covered, err := controlprogram.Compile(abandonmentWorkDependencyDocument(t, "plan.validate", 50), resolver)
703+
if err != nil {
704+
t.Fatal(err)
705+
}
706+
definition, err = softwareflow.NewDefinition(covered, resolver)
707+
if err != nil {
708+
t.Fatal(err)
709+
}
710+
if _, err = definition.RuntimeManifest(context.Background()); err != nil {
711+
t.Fatalf("covered work dependency was rejected: %v", err)
712+
}
713+
}
714+
618715
func TestCompiledBindingDriftFailsClosed(t *testing.T) {
619716
truth := true
620717
compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Reject work dependencies whose producer cannot serve the consumer objective
2+
3+
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.

0 commit comments

Comments
 (0)