Skip to content

Commit eea96ec

Browse files
authored
Bind declarative state effects to admission and target law (#209)
* Close declarative state effect boundaries * Preserve append-only release history * Preserve journal continuity across runtime updates
1 parent f700c7c commit eea96ec

15 files changed

Lines changed: 172 additions & 74 deletions

boatstack/delivery/program_manifest_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ func programFixture() delivery.ProgramManifest {
316316
advance.TargetPhases = []delivery.ProtocolPhase{delivery.PhaseTerminal}
317317
advance.ObjectiveKinds = []delivery.ObjectiveKind{delivery.ObjectiveVerified}
318318
advance.Authority = []delivery.AuthorityClass{delivery.AuthorityHuman, delivery.AuthorityRepository}
319+
advance.RequiredCapabilities = []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityProductMutate}
319320
advance.Effect = "program.advance"
320321
advance.LocalEffects = []delivery.EffectID{"program.advance"}
321322
advance.Prescription = delivery.Prescription{Operation: "advance", Arguments: []string{"--exact"}, ExpectedPostcondition: "terminal"}
@@ -331,15 +332,15 @@ func programFixture() delivery.ProgramManifest {
331332
SchemaVersion: delivery.ProgramSchemaVersion, ProgramID: "test-program", ProgramVersion: "1", RequiresRuntime: ">=1.0.0",
332333
Capabilities: delivery.ProgramCapabilities{
333334
Effects: []string{"program.advance", "program.recover"}, Verifiers: []string{"program.current", "program.terminal"},
334-
CapabilitySurface: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute},
335+
CapabilitySurface: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityProductMutate},
335336
},
336337
OwnedResources: []string{"program.state"}, ObjectiveContracts: []delivery.ObjectiveContract{{ObjectiveKind: delivery.ObjectiveVerified, Conditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetDelivery, "terminal")}}},
337338
Transitions: []delivery.ProgramTransition{advance, recovery},
338339
}
339340
}
340341

341342
func runtimeFixture() delivery.RuntimeCompatibility {
342-
return delivery.RuntimeCompatibility{Version: "v1.2.3", Effects: []string{"program.advance", "program.recover", "alternate.effect"}, Verifiers: []string{"program.current", "program.terminal", "alternate.verifier"}, Capabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityHumanApprove}}
343+
return delivery.RuntimeCompatibility{Version: "v1.2.3", Effects: []string{"program.advance", "program.recover", "alternate.effect"}, Verifiers: []string{"program.current", "program.terminal", "alternate.verifier"}, Capabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityProductMutate, delivery.CapabilityHumanApprove}}
343344
}
344345

345346
func loadManifest(t *testing.T, manifest delivery.ProgramManifest) delivery.ControlProgram {

boatstack/internal/softwaredelivery/catalog/capability.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ package catalog
33
import (
44
"fmt"
55
"sort"
6-
"strings"
6+
7+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
78
)
89

910
// Capability names a kernel-enforced class of effect. A declaration narrows
@@ -131,17 +132,17 @@ func KernelEffectCapabilities(transition Transition) []Capability {
131132
if transition.RuntimeExecution {
132133
required[CapabilityCommandExecute] = true
133134
}
135+
for _, facet := range transition.OwnedFacets {
136+
if facet == model.StateFacetProduct {
137+
required[CapabilityProductMutate] = true
138+
}
139+
}
134140
id := string(transition.Effect)
135141
switch id {
136142
case "gate.build.record", "gate.test.record", "workspace.cut", "workspace.sync", "workspace.cleanup", "workspace.reap",
137143
"publication.observe", "publication.reconcile", "publication.execute", "publication.correct":
138144
required[CapabilityCommandExecute] = true
139145
}
140-
if strings.HasPrefix(id, "objective.") || strings.HasPrefix(id, "plan.") || strings.HasPrefix(id, "workspace.") ||
141-
strings.HasPrefix(id, "gate.") || strings.HasPrefix(id, "evidence.") || strings.HasPrefix(id, "delivery.") ||
142-
strings.HasPrefix(id, "publication.") {
143-
required[CapabilityProductMutate] = true
144-
}
145146
if id == "publication.preview" {
146147
required[CapabilityPublicationPrepare] = true
147148
}

boatstack/internal/softwaredelivery/catalog/capability_test.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package catalog
22

3-
import "testing"
3+
import (
4+
"testing"
5+
6+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
7+
)
48

59
func TestCapabilityVocabularyFailsClosed(t *testing.T) {
610
if _, err := NormalizeCapabilities("test", []Capability{"production.nuke"}); err == nil {
@@ -15,6 +19,7 @@ func TestKernelEffectClassificationCannotBeWeakenedByTransitionDeclaration(t *te
1519
// control-law: repository-authored requirements cannot under-classify a kernel effect
1620
transition := Transition{
1721
ID: "program/publish", Class: EventOwnedExternal, Effect: "publication.execute",
22+
OwnedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct},
1823
RequiredCapabilities: []Capability{CapabilityRepositoryWrite},
1924
DeclaredCapabilities: []Capability{CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, CapabilityPublicationPublish},
2025
}
@@ -26,6 +31,18 @@ func TestKernelEffectClassificationCannotBeWeakenedByTransitionDeclaration(t *te
2631
}
2732
}
2833

34+
func TestProductOwnershipRequiresProductMutationForArbitraryEffect(t *testing.T) {
35+
transition := Transition{
36+
ID: "program/advance", Class: EventOwnedLocal, Effect: "acme.advance",
37+
OwnedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct},
38+
RequiredCapabilities: []Capability{CapabilityRepositoryWrite},
39+
}
40+
required := NewCapabilitySet(RequiredCapabilities(transition)...)
41+
if !required[CapabilityProductMutate] {
42+
t.Fatalf("product-owning arbitrary effect was under-classified: %v", required.Sorted())
43+
}
44+
}
45+
2946
func TestCapabilityClassesHaveNoImplicitHierarchy(t *testing.T) {
3047
granted := AuthorityCapabilities(AuthoritySet{AuthorityProvider: true})
3148
if !granted[CapabilityPublicationPublish] || granted[CapabilityPublicationPrepare] || granted[CapabilityRepositoryWrite] {

boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ func TestDeclarativeAssignmentsRejectApplyTimeOnlyValueConstraints(t *testing.T)
126126
}
127127
}
128128

129+
func TestStateAssignmentMustSatisfyEveryTargetCondition(t *testing.T) {
130+
assignment := literalAssignment("delivery", string(model.DeliveryPublished))
131+
transition := Transition{
132+
TargetConditions: []FacetCondition{
133+
{Facet: model.FacetDelivery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.DeliveryPublished)}},
134+
{Facet: model.FacetDelivery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.DeliveryTerminal)}},
135+
},
136+
}
137+
if stateAssignmentMatchesTarget(transition, assignment) {
138+
t.Fatal("assignment matched only the first of two target conditions")
139+
}
140+
}
141+
129142
func closureTransition(source FacetCondition, assignments ...StateAssignment) Transition {
130143
transition := Transition{
131144
ID: "test.transition", SourcePhases: []model.ProtocolPhase{model.PhaseActive}, TargetPhases: []model.ProtocolPhase{model.PhaseActive},

boatstack/internal/softwaredelivery/catalog/state_facet.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,26 @@ func DurableStateFacetPolicy(transition Transition) (StateFacetPolicy, error) {
157157
return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: writes}, nil
158158
}
159159

160+
// RecoveryStateFacets reconstructs the interrupted transition's write envelope
161+
// from admission-bound capability identity. Privileged core facets remain
162+
// closed over the fixed transition IDs that own them; repository-authored
163+
// programs can recover only control and admitted product mutations.
164+
func RecoveryStateFacets(id TransitionID, required []Capability) []model.StateFacet {
165+
switch id {
166+
case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.update":
167+
return []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}
168+
case "installation.initialize", "installation.reconcile-update":
169+
return []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation, model.StateFacetProgram}
170+
case "repository.attach", "catalog.reconcile":
171+
return []model.StateFacet{model.StateFacetControl, model.StateFacetProgram}
172+
}
173+
writes := []model.StateFacet{model.StateFacetControl}
174+
if NewCapabilitySet(required...).ContainsAll([]Capability{CapabilityProductMutate}) {
175+
writes = append(writes, model.StateFacetProduct)
176+
}
177+
return writes
178+
}
179+
160180
func containsStateFacet(values []model.StateFacet, wanted model.StateFacet) bool {
161181
for _, value := range values {
162182
if value == wanted {

boatstack/internal/softwaredelivery/catalog/transition.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -858,22 +858,35 @@ func stateAssignmentMatchesTarget(t Transition, assignment StateAssignment) bool
858858
if target.Facet != facet {
859859
continue
860860
}
861-
if len(target.Statuses) != 1 || target.Statuses[0] != model.FactKnown {
861+
if !containsFactStatus(target.Statuses, model.FactKnown) {
862862
return false
863863
}
864864
if len(target.Values) == 0 {
865-
return true
865+
continue
866866
}
867+
matched := false
867868
for _, value := range target.Values {
868869
if value == *assignment.Value {
869-
return true
870+
matched = true
871+
break
870872
}
871873
}
872-
return false
874+
if !matched {
875+
return false
876+
}
873877
}
874878
return true
875879
}
876880

881+
func containsFactStatus(values []model.FactStatus, wanted model.FactStatus) bool {
882+
for _, value := range values {
883+
if value == wanted {
884+
return true
885+
}
886+
}
887+
return false
888+
}
889+
877890
func cloneConditions(values []FacetCondition) []FacetCondition {
878891
result := make([]FacetCondition, len(values))
879892
for index, value := range values {

boatstack/internal/softwaredelivery/effects/cas_integration_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) {
141141
t.Fatalf("canonical committed journal count=%d err=%v", len(committedJournals), err)
142142
}
143143
committedRaw, err := os.ReadFile(committedJournals[0])
144-
if err != nil || !bytes.Contains(committedRaw, []byte(committed.Receipt.ID)) || !bytes.Contains(committedRaw, []byte("committed_effects")) {
144+
if err != nil || !bytes.Contains(committedRaw, []byte(committed.Receipt.ID)) || !bytes.Contains(committedRaw, []byte("committed_effects")) ||
145+
!bytes.Contains(committedRaw, []byte(`"schema_version": 8`)) || !bytes.Contains(committedRaw, []byte(`"allowed_state_facets"`)) {
145146
t.Fatalf("committed journal lacks its complete transition fact: %v %q", err, committedRaw)
146147
}
147148
// Simulate a crash after canonical commit but before the passive receipt

boatstack/internal/softwaredelivery/effects/journal.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ func NewJournal(resolver ports.InvocationResolver, clock ports.Clock) (*Journal,
3333
}
3434

3535
type journalRecord struct {
36-
SchemaVersion int `json:"schema_version"`
37-
Admission protocol.Admission `json:"admission"`
38-
TransitionID catalog.TransitionID `json:"transition_id"`
39-
TransitionClass catalog.EventClass `json:"transition_class"`
36+
SchemaVersion int `json:"schema_version"`
37+
Admission protocol.Admission `json:"admission"`
38+
TransitionID catalog.TransitionID `json:"transition_id"`
39+
TransitionClass catalog.EventClass `json:"transition_class"`
40+
// AllowedStateFacets preserves the current schema-8 record shape. Recovery
41+
// authority is reconstructed from Admission.RequiredCapabilities instead.
4042
AllowedStateFacets []model.StateFacet `json:"allowed_state_facets"`
4143
ReconcilesProgram bool `json:"reconciles_program,omitempty"`
4244
Status string `json:"status"`
Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,13 @@
11
package effects
22

33
import (
4-
"os"
5-
"path/filepath"
6-
"strings"
74
"testing"
8-
)
95

10-
func TestPriorJournalSchemaRequiresExplicitReset(t *testing.T) {
11-
path := filepath.Join(t.TempDir(), "adm-prior.pending")
12-
raw := []byte(`{"schema_version":7,"admission":{"id":"adm-prior"},"transition_id":"plan.create","transition_class":"owned-local","status":"begun"}`)
13-
if err := os.WriteFile(path, raw, 0o600); err != nil {
14-
t.Fatal(err)
15-
}
6+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
7+
)
168

17-
if _, err := readJournal(path); err == nil || !strings.Contains(err.Error(), "invalid transaction journal") {
18-
t.Fatalf("read prior journal schema error = %v, want explicit invalid journal refusal", err)
19-
}
20-
got, err := os.ReadFile(path)
21-
if err != nil {
22-
t.Fatal(err)
23-
}
24-
if string(got) != string(raw) {
25-
t.Fatalf("prior journal changed during refusal:\n got %s\nwant %s", got, raw)
9+
func TestInstallationUpdateKeepsCurrentJournalSchema(t *testing.T) {
10+
if protocol.JournalSchemaVersion != 8 {
11+
t.Fatalf("journal schema = %d, want current schema 8 for in-flight installation updates", protocol.JournalSchemaVersion)
2612
}
2713
}

boatstack/internal/softwaredelivery/effects/recovery.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID, in
147147
if err != nil {
148148
return nil, err
149149
}
150-
allowed := model.UnionStateFacets(record.AllowedStateFacets, []model.StateFacet{model.StateFacetControl})
150+
allowed := catalog.RecoveryStateFacets(record.TransitionID, record.Admission.RequiredCapabilities)
151151
if _, err := validateAllowedStateFacets(recovery, staged, allowed); err != nil {
152152
return nil, err
153153
}

0 commit comments

Comments
 (0)