Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions boatstack/flow/softwaredelivery/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions boatstack/flow/softwaredelivery/definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
3 changes: 3 additions & 0 deletions release-notes/2026-08-21-work-dependency-target-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
Loading