Skip to content

Commit f700c7c

Browse files
authored
Make software-delivery state effects declarative (#208)
* Declare software delivery state effects * Apply declared effects across protocol runtimes * Bind declarative effects to durable control law * Close declarative durable-state siblings
1 parent 4208aa1 commit f700c7c

34 files changed

Lines changed: 3003 additions & 427 deletions

boatstack/core/transitions.json

Lines changed: 283 additions & 20 deletions
Large diffs are not rendered by default.

boatstack/delivery/control.go

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ type ParameterSpec = catalog.ParameterSpec
3535
type InterruptionContract = catalog.InterruptionContract
3636
type PolicyContract = catalog.PolicyContract
3737
type Reversibility = catalog.Reversibility
38+
type StateEffect = catalog.StateEffect
39+
type StateEffectKind = catalog.StateEffectKind
40+
type StateAssignment = catalog.StateAssignment
41+
type StatePrecondition = catalog.StatePrecondition
42+
type StateValueReference = catalog.StateValueReference
43+
type StateFacet = model.StateFacet
3844
type ObjectiveKind = model.ObjectiveKind
3945
type ProtocolPhase = model.ProtocolPhase
4046
type FactStatus = model.FactStatus
@@ -87,11 +93,19 @@ const (
8793
PhaseUnresolved = model.PhaseUnresolved
8894
PhaseAbandoned = model.PhaseAbandoned
8995

90-
FactKnown = model.FactKnown
91-
FactAbsent = model.FactAbsent
92-
FactUnknown = model.FactUnknown
93-
FactStale = model.FactStale
94-
FactConflicting = model.FactConflicting
96+
FactKnown = model.FactKnown
97+
98+
StateEffectAssignments = catalog.StateEffectAssignments
99+
StateEffectNative = catalog.StateEffectNative
100+
101+
StateFacetInstallation = model.StateFacetInstallation
102+
StateFacetProgram = model.StateFacetProgram
103+
StateFacetControl = model.StateFacetControl
104+
StateFacetProduct = model.StateFacetProduct
105+
FactAbsent = model.FactAbsent
106+
FactUnknown = model.FactUnknown
107+
FactStale = model.FactStale
108+
FactConflicting = model.FactConflicting
95109

96110
Reversible = catalog.Reversible
97111
Compensatable = catalog.Compensatable
@@ -118,7 +132,7 @@ const (
118132
FacetObjective = model.FacetObjective
119133
)
120134

121-
const ProgramSchemaVersion = 3
135+
const ProgramSchemaVersion = 4
122136

123137
func KernelEffectCapabilities(transition Transition) []Capability {
124138
return catalog.KernelEffectCapabilities(transition)
@@ -583,7 +597,10 @@ func compileSupervisoryProgram(runtime ProgramRuntimeManifest, compatibility, do
583597
for _, capability := range transition.RequiredCapabilities {
584598
capabilities = append(capabilities, general.Capability(capability))
585599
}
586-
facets := append([]string(nil), transition.OwnedResources...)
600+
facets := make([]string, 0, len(transition.OwnedFacets)+1)
601+
for _, facet := range transition.OwnedFacets {
602+
facets = append(facets, string(facet))
603+
}
587604
mutation := general.PreserveObjective
588605
if transition.Policy.BindsRequestedObjective {
589606
mutation = general.BindObjectiveMutation
@@ -691,6 +708,9 @@ func validateProgramRuntime(manifest ProgramRuntimeManifest) error {
691708
}
692709
if manifest.RuntimeMode == ProgramRuntimeProtocol {
693710
for _, condition := range transition.TargetConditions {
711+
if stateEffectTargetsFacet(transition.StateEffect, condition.Facet) {
712+
continue
713+
}
694714
if !strings.HasPrefix(string(condition.Facet), manifest.ID+".") {
695715
return fmt.Errorf("protocol ProgramRuntime transition %q targets non-owned fact %q", transition.ID, condition.Facet)
696716
}
@@ -720,6 +740,16 @@ func stringSet(values []string) map[string]bool {
720740
return result
721741
}
722742

743+
func stateEffectTargetsFacet(effect StateEffect, target FacetName) bool {
744+
for _, assignment := range effect.Assignments {
745+
facet, ok := catalog.DeclaredStateResolverFacet(assignment.Facet)
746+
if ok && facet == target {
747+
return true
748+
}
749+
}
750+
return false
751+
}
752+
723753
func transitionSet(values []TransitionID) map[TransitionID]bool {
724754
result := make(map[TransitionID]bool, len(values))
725755
for _, value := range values {
@@ -836,6 +866,9 @@ func validateExtension(manifest ExtensionManifest, seen, reserved map[string]boo
836866
}
837867
seenTransitions[transition.ID] = true
838868
for _, condition := range transition.TargetConditions {
869+
if stateEffectTargetsFacet(transition.StateEffect, condition.Facet) {
870+
continue
871+
}
839872
if !strings.HasPrefix(string(condition.Facet), manifest.ID+".") {
840873
return fmt.Errorf("extension transition %q targets non-owned fact %q", transition.ID, condition.Facet)
841874
}
@@ -1037,6 +1070,18 @@ func cloneTransition(value Transition) Transition {
10371070
value.DeclaredCapabilities = append([]Capability(nil), value.DeclaredCapabilities...)
10381071
value.RequiredEvidence = append([]string(nil), value.RequiredEvidence...)
10391072
value.OwnedResources = append([]string(nil), value.OwnedResources...)
1073+
value.OwnedFacets = append([]model.StateFacet(nil), value.OwnedFacets...)
1074+
value.StateEffect.Preconditions = append([]catalog.StatePrecondition(nil), value.StateEffect.Preconditions...)
1075+
for index := range value.StateEffect.Preconditions {
1076+
value.StateEffect.Preconditions[index].Values = append([]string(nil), value.StateEffect.Preconditions[index].Values...)
1077+
}
1078+
value.StateEffect.Assignments = append([]catalog.StateAssignment(nil), value.StateEffect.Assignments...)
1079+
for index := range value.StateEffect.Assignments {
1080+
if literal := value.StateEffect.Assignments[index].Value; literal != nil {
1081+
copy := *literal
1082+
value.StateEffect.Assignments[index].Value = &copy
1083+
}
1084+
}
10401085
value.LocalEffects = append([]catalog.EffectID(nil), value.LocalEffects...)
10411086
value.ExternalEffects = append([]catalog.EffectID(nil), value.ExternalEffects...)
10421087
value.Parameters = append([]catalog.ParameterSpec(nil), value.Parameters...)

boatstack/delivery/control_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ func TestProgramFingerprintBindsCompositionAndPolicyInputs(t *testing.T) {
104104
recoveryTransition.SelectionClass = delivery.SelectionExtensionRecovery
105105
recoveryTransition.SourcePhases = []delivery.ProtocolPhase{delivery.PhaseRecovery}
106106
recoveryTransition.TargetPhases = []delivery.ProtocolPhase{delivery.PhaseActive}
107+
activePhase := string(delivery.PhaseActive)
108+
recoveryTransition.StateEffect.Assignments = []delivery.StateAssignment{{Facet: "phase", Value: &activePhase}}
107109
recoveryTransition.Effect = "boatstack.release-note.recover-effect"
108110
recoveryTransition.LocalEffects = []delivery.EffectID{recoveryTransition.Effect}
109111
recoveryTransition.Verifier = "boatstack.release-note.recover-verifier"

boatstack/delivery/extension.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"time"
88
)
99

10-
const ExtensionProtocolVersion = 2
10+
const ExtensionProtocolVersion = 3
1111

1212
type ExtensionOperation string
1313

boatstack/delivery/program_manifest.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ type ProgramTransition struct {
5858
RequiredCapabilities []Capability `json:"required_capabilities"`
5959
RequiredEvidence []string `json:"required_evidence"`
6060
OwnedResources []string `json:"owned_resources,omitempty"`
61+
OwnedFacets []StateFacet `json:"owned_facets"`
62+
StateEffect StateEffect `json:"state_effect"`
6163
Effect EffectID `json:"effect,omitempty"`
6264
LocalEffects []EffectID `json:"local_effects,omitempty"`
6365
ExternalEffects []EffectID `json:"external_effects,omitempty"`
@@ -337,6 +339,7 @@ func (value ProgramTransition) runtimeTransition() Transition {
337339
RequiredIdentity: value.RequiredIdentity, Authority: value.Authority, AuthorityAll: value.AuthorityAll,
338340
RequiredCapabilities: value.RequiredCapabilities,
339341
RequiredEvidence: value.RequiredEvidence, OwnedResources: value.OwnedResources, Effect: value.Effect,
342+
OwnedFacets: value.OwnedFacets, StateEffect: value.StateEffect,
340343
LocalEffects: value.LocalEffects, ExternalEffects: value.ExternalEffects, Idempotent: value.Idempotent,
341344
Parameters: value.Parameters, Prescription: value.Prescription, SourcePredicate: value.SourcePredicate,
342345
SourceConditions: value.SourceConditions, AdmissionPredicate: value.AdmissionPredicate,
@@ -469,6 +472,24 @@ func normalizeProgramTransition(value Transition) (Transition, error) {
469472
if err != nil {
470473
return Transition{}, err
471474
}
475+
value.OwnedFacets, err = uniqueSorted(value.OwnedFacets, func(v StateFacet) string { return string(v) })
476+
if err != nil {
477+
return Transition{}, err
478+
}
479+
for index := range value.StateEffect.Preconditions {
480+
value.StateEffect.Preconditions[index].Values, err = uniqueSorted(value.StateEffect.Preconditions[index].Values, func(v string) string { return v })
481+
if err != nil {
482+
return Transition{}, err
483+
}
484+
}
485+
value.StateEffect.Preconditions, err = uniqueSorted(value.StateEffect.Preconditions, func(v StatePrecondition) string { return v.Facet })
486+
if err != nil {
487+
return Transition{}, err
488+
}
489+
value.StateEffect.Assignments, err = uniqueSorted(value.StateEffect.Assignments, func(v StateAssignment) string { return v.Facet })
490+
if err != nil {
491+
return Transition{}, err
492+
}
472493
value.LocalEffects, err = uniqueSorted(value.LocalEffects, func(v EffectID) string { return string(v) })
473494
if err != nil {
474495
return Transition{}, err

boatstack/delivery/program_manifest_test.go

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) {
6666
},
6767
"target-phase": func(value *delivery.ProgramManifest) {
6868
value.Transitions[0].TargetPhases = []delivery.ProtocolPhase{delivery.PhaseFrontier}
69+
phase := string(delivery.PhaseFrontier)
70+
value.Transitions[0].StateEffect.Assignments[0].Value = &phase
6971
},
7072
"authority": func(value *delivery.ProgramManifest) {
7173
value.Transitions[0].Authority = []delivery.AuthorityClass{delivery.AuthorityHuman}
@@ -83,12 +85,23 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) {
8385
value.Transitions[0].Verifier = "alternate.verifier"
8486
},
8587
"postcondition": func(value *delivery.ProgramManifest) {
86-
value.Transitions[0].TargetConditions[0].Values = []string{"alternate"}
88+
value.Transitions[0].TargetConditions[0].Values = []string{"published"}
89+
published := "published"
90+
value.Transitions[0].StateEffect.Assignments[1].Value = &published
8791
},
8892
"recovery": func(value *delivery.ProgramManifest) {
8993
value.Transitions[0].Interruption.Recovery = "alternate.recover"
9094
},
9195
"priority": func(value *delivery.ProgramManifest) { value.Transitions[0].Priority++ },
96+
"owned-facets": func(value *delivery.ProgramManifest) {
97+
value.Transitions[0].OwnedFacets = []delivery.StateFacet{delivery.StateFacetControl}
98+
value.Transitions[0].StateEffect.Assignments = value.Transitions[0].StateEffect.Assignments[:1]
99+
value.Transitions[0].TargetConditions = []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetProgram, "current")}
100+
},
101+
"state-effect": func(value *delivery.ProgramManifest) {
102+
nonterminal := "nonterminal"
103+
value.Transitions[0].StateEffect.Assignments = append(value.Transitions[0].StateEffect.Assignments, delivery.StateAssignment{Facet: "terminal", Value: &nonterminal})
104+
},
92105
}
93106
for name, mutate := range mutations {
94107
t.Run(name, func(t *testing.T) {
@@ -108,6 +121,37 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) {
108121
}
109122
}
110123

124+
func TestProgramManifestCanonicalizesStateEffectSets(t *testing.T) {
125+
fixture := func() delivery.ProgramManifest {
126+
manifest := programFixture()
127+
transition := &manifest.Transitions[0]
128+
transition.SourcePhases = []delivery.ProtocolPhase{delivery.PhaseActive, delivery.PhaseObserved}
129+
transition.SourceConditions = append(transition.SourceConditions, delivery.KnownCondition(delivery.FacetTerminal, "nonterminal"))
130+
transition.TargetConditions = append(transition.TargetConditions, delivery.KnownCondition(delivery.FacetTerminal, "established"))
131+
phase, terminal, deliveryState := string(delivery.PhaseTerminal), "established", "terminal"
132+
transition.StateEffect = delivery.StateEffect{
133+
Kind: delivery.StateEffectAssignments,
134+
Preconditions: []delivery.StatePrecondition{
135+
{Facet: "phase", Values: []string{string(delivery.PhaseActive), string(delivery.PhaseObserved)}},
136+
{Facet: "terminal", Values: []string{"nonterminal"}},
137+
},
138+
Assignments: []delivery.StateAssignment{{Facet: "phase", Value: &phase}, {Facet: "terminal", Value: &terminal}, {Facet: "delivery", Value: &deliveryState}},
139+
}
140+
return manifest
141+
}
142+
143+
oneManifest := fixture()
144+
one := loadManifest(t, oneManifest)
145+
twoManifest := fixture()
146+
reverse(twoManifest.Transitions[0].StateEffect.Preconditions)
147+
reverse(twoManifest.Transitions[0].StateEffect.Preconditions[1].Values)
148+
reverse(twoManifest.Transitions[0].StateEffect.Assignments)
149+
two := loadManifest(t, twoManifest)
150+
if one.Fingerprint() != two.Fingerprint() {
151+
t.Fatalf("state-effect set ordering changed executable identity: %s != %s", one.Fingerprint(), two.Fingerprint())
152+
}
153+
}
154+
111155
func TestProgramManifestNamespaceAndCompatibilityBoundary(t *testing.T) {
112156
// control-law: only-compatible-validated-programs-reach-the-runtime-registry
113157
first := programFixture()
@@ -152,6 +196,22 @@ func TestProgramManifestNamespaceAndCompatibilityBoundary(t *testing.T) {
152196
{"under-declared-kernel-effect", func(value *delivery.ProgramManifest) {
153197
value.Capabilities.CapabilitySurface = []delivery.Capability{delivery.CapabilityRepositoryWrite}
154198
}, runtimeFixture(), delivery.ProgramInvalid},
199+
{"host-native-state-handler", func(value *delivery.ProgramManifest) {
200+
value.Transitions[0].StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "abandon-delivery"}
201+
}, runtimeFixture(), delivery.ProgramInvalid},
202+
{"product-only-owned-facets", func(value *delivery.ProgramManifest) {
203+
value.Transitions[0].OwnedFacets = []delivery.StateFacet{delivery.StateFacetProduct}
204+
}, runtimeFixture(), delivery.ProgramInvalid},
205+
{"unclosed-verified-configuration", func(value *delivery.ProgramManifest) {
206+
verified := "verified"
207+
value.Transitions[0].SourceConditions = append(value.Transitions[0].SourceConditions, delivery.KnownCondition(delivery.FacetConfiguration, "unsupported"))
208+
value.Transitions[0].TargetConditions = append(value.Transitions[0].TargetConditions, delivery.KnownCondition(delivery.FacetConfiguration, verified))
209+
value.Transitions[0].StateEffect.Assignments = []delivery.StateAssignment{{Facet: "configuration", Value: &verified}}
210+
}, runtimeFixture(), delivery.ProgramInvalid},
211+
{"optional-assignment-parameter", func(value *delivery.ProgramManifest) {
212+
value.Transitions[0].Parameters = append(value.Transitions[0].Parameters, delivery.ParameterSpec{Name: "optional_state"})
213+
value.Transitions[0].StateEffect.Assignments = []delivery.StateAssignment{{Facet: "phase", ValueFrom: delivery.StateValueReference{Parameter: "optional_state"}}}
214+
}, runtimeFixture(), delivery.ProgramInvalid},
155215
{"duplicate-condition", func(value *delivery.ProgramManifest) {
156216
value.Transitions[0].SourceConditions = append(value.Transitions[0].SourceConditions, value.Transitions[0].SourceConditions[0])
157217
}, runtimeFixture(), delivery.ProgramInvalid},
@@ -234,18 +294,21 @@ func TestValidatedProgramIsTheKernelRegistry(t *testing.T) {
234294
}
235295

236296
func programFixture() delivery.ProgramManifest {
297+
activePhase := string(delivery.PhaseActive)
237298
recovery := delivery.ProgramTransition{
238299
ID: "recover", Version: 1, SelectionClass: delivery.SelectionProgramRecovery, Class: delivery.EventRecovery,
239300
SourcePhases: []delivery.ProtocolPhase{delivery.PhaseRecovery}, TargetPhases: []delivery.ProtocolPhase{delivery.PhaseActive},
240301
RequiredIdentity: []string{"repository-id"}, Authority: []delivery.AuthorityClass{delivery.AuthorityRepository}, RequiredCapabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite}, RequiredEvidence: []string{"snapshot"},
241-
OwnedResources: []string{"program.state"}, Effect: "program.recover", LocalEffects: []delivery.EffectID{"program.recover"}, Idempotent: true,
302+
OwnedResources: []string{"program.state"}, OwnedFacets: []delivery.StateFacet{delivery.StateFacetControl}, StateEffect: delivery.StateEffect{Kind: delivery.StateEffectAssignments, Assignments: []delivery.StateAssignment{{Facet: "phase", Value: &activePhase}}},
303+
Effect: "program.recover", LocalEffects: []delivery.EffectID{"program.recover"}, Idempotent: true,
242304
Prescription: delivery.Prescription{Operation: "recover", ExpectedPostcondition: "active"}, SourcePredicate: "recovery-required",
243-
SourceConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetRecovery, "required")}, AdmissionPredicate: "exact-admission",
305+
SourceConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetRecovery, "required"), delivery.KnownCondition(delivery.FacetProgram, "current")}, AdmissionPredicate: "exact-admission",
244306
TargetPredicate: "active", TargetConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetProgram, "current")}, Verifier: "program.current",
245307
Interruption: interruption("recover"), Reversibility: delivery.Reversible, TerminalEffect: "none",
246308
PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "local", Policy: delivery.PolicyContract{ObjectiveScope: delivery.ObjectiveScopeOptionalPreserve}, Priority: 1,
247309
}
248310
advance := recovery
311+
terminalPhase, terminalDelivery := string(delivery.PhaseTerminal), "terminal"
249312
advance.ID = "advance"
250313
advance.SelectionClass = delivery.SelectionProgramProgress
251314
advance.Class = delivery.EventOwnedLocal
@@ -260,6 +323,8 @@ func programFixture() delivery.ProgramManifest {
260323
advance.SourceConditions = []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetProgram, "current"), delivery.KnownCondition(delivery.FacetDelivery, "active")}
261324
advance.TargetPredicate = "terminal"
262325
advance.TargetConditions = []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetDelivery, "terminal")}
326+
advance.OwnedFacets = []delivery.StateFacet{delivery.StateFacetControl, delivery.StateFacetProduct}
327+
advance.StateEffect.Assignments = []delivery.StateAssignment{{Facet: "phase", Value: &terminalPhase}, {Facet: "delivery", Value: &terminalDelivery}}
263328
advance.Verifier = "program.terminal"
264329
advance.Policy.ObjectiveScope = delivery.ObjectiveScopeBoundExact
265330
return delivery.ProgramManifest{

boatstack/delivery/program_runtime.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"fmt"
77
)
88

9-
const ProgramRuntimeProtocolVersion = 2
9+
const ProgramRuntimeProtocolVersion = 3
1010

1111
type ProgramRuntimeMode string
1212

boatstack/extension/releasenote/releasenote.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func (Extension) ExtensionManifest(context.Context) (delivery.ExtensionManifest,
4242
ObjectiveKinds: []model.ObjectiveKind{model.ObjectiveOpenPR, model.ObjectiveMerged}, RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id", "controller-id", "topology", "host", "correlation-id"},
4343
Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot-fingerprint", "objective", "facet:" + FactID},
4444
OwnedResources: []string{Resource}, Effect: Effect, LocalEffects: []catalog.EffectID{Effect}, Idempotent: true,
45+
OwnedFacets: []model.StateFacet{model.StateFacetControl}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectAssignments},
4546
Prescription: catalog.Prescription{Operation: Transition, ExpectedPostcondition: "release-note evidence is verified"},
4647
SourcePredicate: "reference-release-note-missing", AdmissionPredicate: "exact-extension-admission", TargetPredicate: "reference-release-note-verified", Verifier: Verifier,
4748
SourceConditions: []catalog.FacetCondition{

boatstack/extension/subprocess/testdata/reference_extension.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
request = json.load(sys.stdin)
77
operation = request["operation"]
88
response = {
9-
"protocol_version": 2,
9+
"protocol_version": 3,
1010
"operation": operation,
1111
"extension_id": request["extension_id"],
1212
"extension_version": request["extension_version"],
@@ -17,7 +17,7 @@
1717
response["manifest"] = {
1818
"id": "fixture.echo",
1919
"version": "1.0.0",
20-
"protocol_version": 2,
20+
"protocol_version": 3,
2121
"settings_schema": {"type": "object"},
2222
"facts": ["fixture.echo.present"],
2323
"capabilities": ["command.execute"],

0 commit comments

Comments
 (0)