diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json
index 0ea528f..0933c9d 100644
--- a/boatstack/core/transitions.json
+++ b/boatstack/core/transitions.json
@@ -185,7 +185,24 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 10
+ "priority": 10,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "phase",
+ "value": "OBSERVED"
+ },
+ {
+ "facet": "engagement",
+ "value": "command"
+ }
+ ]
+ }
},
{
"id": "engagement.renew",
@@ -371,7 +388,24 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 70
+ "priority": 70,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ },
+ {
+ "facet": "engagement",
+ "value": "active"
+ }
+ ]
+ }
},
{
"id": "engagement.release",
@@ -559,7 +593,24 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 95
+ "priority": 95,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "phase",
+ "value": "DORMANT"
+ },
+ {
+ "facet": "engagement",
+ "value": "dormant"
+ }
+ ]
+ }
},
{
"id": "invocation.rebind",
@@ -713,7 +764,19 @@
"objective_scope": "optional-preserve"
},
"priority": 15,
- "allows_identity_rebind": true
+ "allows_identity_rebind": true,
+ "owned_facets": [
+ "control"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "phase",
+ "value": "OBSERVED"
+ }
+ ]
+ }
},
{
"id": "repository.attach",
@@ -886,7 +949,20 @@
"objective_scope": "optional-preserve"
},
"priority": 12,
- "allows_identity_rebind": true
+ "allows_identity_rebind": true,
+ "owned_facets": [
+ "control",
+ "program"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "phase",
+ "value": "OBSERVED"
+ }
+ ]
+ }
},
{
"id": "repository.detach",
@@ -1048,7 +1124,24 @@
"objective_scope": "optional-preserve"
},
"priority": 96,
- "allows_identity_rebind": true
+ "allows_identity_rebind": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "phase",
+ "value": "DORMANT"
+ },
+ {
+ "facet": "engagement",
+ "value": "dormant"
+ }
+ ]
+ }
},
{
"id": "runtime.hydrate",
@@ -1233,7 +1326,15 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 20
+ "priority": 20,
+ "owned_facets": [
+ "control",
+ "installation"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "runtime-verified-settled"
+ }
},
{
"id": "runtime.replace",
@@ -1417,7 +1518,15 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 25
+ "priority": 25,
+ "owned_facets": [
+ "control",
+ "installation"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "runtime-verified-settled"
+ }
},
{
"id": "runtime.reconcile",
@@ -1576,7 +1685,15 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 4
+ "priority": 4,
+ "owned_facets": [
+ "control",
+ "installation"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "runtime-reconcile"
+ }
},
{
"id": "configuration.initialize",
@@ -1751,7 +1868,14 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 22
+ "priority": 22,
+ "owned_facets": [
+ "control"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "configuration-verified-settled"
+ }
},
{
"id": "configuration.mutate",
@@ -1959,7 +2083,14 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 60
+ "priority": 60,
+ "owned_facets": [
+ "control"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "configuration-verified-settled"
+ }
},
{
"id": "configuration.reconcile",
@@ -2104,7 +2235,14 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 3
+ "priority": 3,
+ "owned_facets": [
+ "control"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "configuration-reconcile"
+ }
},
{
"id": "installation.initialize",
@@ -2301,7 +2439,16 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 11
+ "priority": 11,
+ "owned_facets": [
+ "control",
+ "installation",
+ "program"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "installation-initialize"
+ }
},
{
"id": "installation.update",
@@ -2514,7 +2661,38 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 65
+ "priority": 65,
+ "owned_facets": [
+ "control",
+ "installation"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "runtime",
+ "value": "verified"
+ },
+ {
+ "facet": "runtime_version",
+ "value_from": {
+ "parameter": "runtime_version"
+ }
+ },
+ {
+ "facet": "runtime_fingerprint",
+ "value_from": {
+ "parameter": "runtime_sha256"
+ }
+ },
+ {
+ "facet": "runtime_source",
+ "value_from": {
+ "parameter": "source_revision"
+ }
+ }
+ ]
+ }
},
{
"id": "installation.reconcile-update",
@@ -2740,7 +2918,16 @@
"reconciles_program": true,
"objective_scope": "optional-preserve"
},
- "priority": 1
+ "priority": 1,
+ "owned_facets": [
+ "control",
+ "installation",
+ "program"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "installation-reconcile-update"
+ }
},
{
"id": "catalog.reconcile",
@@ -2926,7 +3113,15 @@
"reconciles_program": true,
"objective_scope": "optional-preserve"
},
- "priority": 1
+ "priority": 1,
+ "owned_facets": [
+ "control",
+ "program"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "catalog-reconcile"
+ }
},
{
"id": "objective.bind",
@@ -3070,7 +3265,15 @@
"binds_requested_objective": true,
"objective_scope": "none"
},
- "priority": 30
+ "priority": 30,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "objective-bind"
+ }
},
{
"id": "recovery.resume",
@@ -3218,7 +3421,14 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 2
+ "priority": 2,
+ "owned_facets": [
+ "control"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": []
+ }
},
{
"id": "recovery.rollback",
@@ -3365,7 +3575,14 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 3
+ "priority": 3,
+ "owned_facets": [
+ "control"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": []
+ }
},
{
"id": "recovery.escalate",
@@ -3507,7 +3724,53 @@
"policy": {
"objective_scope": "optional-preserve"
},
- "priority": 5
+ "priority": 5,
+ "owned_facets": [
+ "control"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "recovery",
+ "value": "escalated"
+ },
+ {
+ "facet": "phase",
+ "value": "FRONTIER"
+ },
+ {
+ "facet": "transaction",
+ "value": "none"
+ },
+ {
+ "facet": "transaction_id",
+ "value_from": {
+ "parameter": "transaction_id"
+ }
+ },
+ {
+ "facet": "transaction_transition",
+ "value": "recovery.escalate"
+ },
+ {
+ "facet": "recovery_cause",
+ "value": "recovery requires explicit external resolution"
+ },
+ {
+ "facet": "recovery_source_phase",
+ "value": "RECOVERY"
+ },
+ {
+ "facet": "recovery_resumption",
+ "value": "FRONTIER"
+ },
+ {
+ "facet": "recovery_budget",
+ "value": "0"
+ }
+ ]
+ }
},
{
"id": "external.files-changed",
diff --git a/boatstack/delivery/control.go b/boatstack/delivery/control.go
index 4ffbe8d..9741cb4 100644
--- a/boatstack/delivery/control.go
+++ b/boatstack/delivery/control.go
@@ -35,6 +35,12 @@ type ParameterSpec = catalog.ParameterSpec
type InterruptionContract = catalog.InterruptionContract
type PolicyContract = catalog.PolicyContract
type Reversibility = catalog.Reversibility
+type StateEffect = catalog.StateEffect
+type StateEffectKind = catalog.StateEffectKind
+type StateAssignment = catalog.StateAssignment
+type StatePrecondition = catalog.StatePrecondition
+type StateValueReference = catalog.StateValueReference
+type StateFacet = model.StateFacet
type ObjectiveKind = model.ObjectiveKind
type ProtocolPhase = model.ProtocolPhase
type FactStatus = model.FactStatus
@@ -87,11 +93,19 @@ const (
PhaseUnresolved = model.PhaseUnresolved
PhaseAbandoned = model.PhaseAbandoned
- FactKnown = model.FactKnown
- FactAbsent = model.FactAbsent
- FactUnknown = model.FactUnknown
- FactStale = model.FactStale
- FactConflicting = model.FactConflicting
+ FactKnown = model.FactKnown
+
+ StateEffectAssignments = catalog.StateEffectAssignments
+ StateEffectNative = catalog.StateEffectNative
+
+ StateFacetInstallation = model.StateFacetInstallation
+ StateFacetProgram = model.StateFacetProgram
+ StateFacetControl = model.StateFacetControl
+ StateFacetProduct = model.StateFacetProduct
+ FactAbsent = model.FactAbsent
+ FactUnknown = model.FactUnknown
+ FactStale = model.FactStale
+ FactConflicting = model.FactConflicting
Reversible = catalog.Reversible
Compensatable = catalog.Compensatable
@@ -118,7 +132,7 @@ const (
FacetObjective = model.FacetObjective
)
-const ProgramSchemaVersion = 3
+const ProgramSchemaVersion = 4
func KernelEffectCapabilities(transition Transition) []Capability {
return catalog.KernelEffectCapabilities(transition)
@@ -583,7 +597,10 @@ func compileSupervisoryProgram(runtime ProgramRuntimeManifest, compatibility, do
for _, capability := range transition.RequiredCapabilities {
capabilities = append(capabilities, general.Capability(capability))
}
- facets := append([]string(nil), transition.OwnedResources...)
+ facets := make([]string, 0, len(transition.OwnedFacets)+1)
+ for _, facet := range transition.OwnedFacets {
+ facets = append(facets, string(facet))
+ }
mutation := general.PreserveObjective
if transition.Policy.BindsRequestedObjective {
mutation = general.BindObjectiveMutation
@@ -691,6 +708,9 @@ func validateProgramRuntime(manifest ProgramRuntimeManifest) error {
}
if manifest.RuntimeMode == ProgramRuntimeProtocol {
for _, condition := range transition.TargetConditions {
+ if stateEffectTargetsFacet(transition.StateEffect, condition.Facet) {
+ continue
+ }
if !strings.HasPrefix(string(condition.Facet), manifest.ID+".") {
return fmt.Errorf("protocol ProgramRuntime transition %q targets non-owned fact %q", transition.ID, condition.Facet)
}
@@ -720,6 +740,16 @@ func stringSet(values []string) map[string]bool {
return result
}
+func stateEffectTargetsFacet(effect StateEffect, target FacetName) bool {
+ for _, assignment := range effect.Assignments {
+ facet, ok := catalog.DeclaredStateResolverFacet(assignment.Facet)
+ if ok && facet == target {
+ return true
+ }
+ }
+ return false
+}
+
func transitionSet(values []TransitionID) map[TransitionID]bool {
result := make(map[TransitionID]bool, len(values))
for _, value := range values {
@@ -836,6 +866,9 @@ func validateExtension(manifest ExtensionManifest, seen, reserved map[string]boo
}
seenTransitions[transition.ID] = true
for _, condition := range transition.TargetConditions {
+ if stateEffectTargetsFacet(transition.StateEffect, condition.Facet) {
+ continue
+ }
if !strings.HasPrefix(string(condition.Facet), manifest.ID+".") {
return fmt.Errorf("extension transition %q targets non-owned fact %q", transition.ID, condition.Facet)
}
@@ -1037,6 +1070,18 @@ func cloneTransition(value Transition) Transition {
value.DeclaredCapabilities = append([]Capability(nil), value.DeclaredCapabilities...)
value.RequiredEvidence = append([]string(nil), value.RequiredEvidence...)
value.OwnedResources = append([]string(nil), value.OwnedResources...)
+ value.OwnedFacets = append([]model.StateFacet(nil), value.OwnedFacets...)
+ value.StateEffect.Preconditions = append([]catalog.StatePrecondition(nil), value.StateEffect.Preconditions...)
+ for index := range value.StateEffect.Preconditions {
+ value.StateEffect.Preconditions[index].Values = append([]string(nil), value.StateEffect.Preconditions[index].Values...)
+ }
+ value.StateEffect.Assignments = append([]catalog.StateAssignment(nil), value.StateEffect.Assignments...)
+ for index := range value.StateEffect.Assignments {
+ if literal := value.StateEffect.Assignments[index].Value; literal != nil {
+ copy := *literal
+ value.StateEffect.Assignments[index].Value = ©
+ }
+ }
value.LocalEffects = append([]catalog.EffectID(nil), value.LocalEffects...)
value.ExternalEffects = append([]catalog.EffectID(nil), value.ExternalEffects...)
value.Parameters = append([]catalog.ParameterSpec(nil), value.Parameters...)
diff --git a/boatstack/delivery/control_test.go b/boatstack/delivery/control_test.go
index 3ec27ba..b14b6d2 100644
--- a/boatstack/delivery/control_test.go
+++ b/boatstack/delivery/control_test.go
@@ -104,6 +104,8 @@ func TestProgramFingerprintBindsCompositionAndPolicyInputs(t *testing.T) {
recoveryTransition.SelectionClass = delivery.SelectionExtensionRecovery
recoveryTransition.SourcePhases = []delivery.ProtocolPhase{delivery.PhaseRecovery}
recoveryTransition.TargetPhases = []delivery.ProtocolPhase{delivery.PhaseActive}
+ activePhase := string(delivery.PhaseActive)
+ recoveryTransition.StateEffect.Assignments = []delivery.StateAssignment{{Facet: "phase", Value: &activePhase}}
recoveryTransition.Effect = "boatstack.release-note.recover-effect"
recoveryTransition.LocalEffects = []delivery.EffectID{recoveryTransition.Effect}
recoveryTransition.Verifier = "boatstack.release-note.recover-verifier"
diff --git a/boatstack/delivery/extension.go b/boatstack/delivery/extension.go
index d9b9742..a1024b3 100644
--- a/boatstack/delivery/extension.go
+++ b/boatstack/delivery/extension.go
@@ -7,7 +7,7 @@ import (
"time"
)
-const ExtensionProtocolVersion = 2
+const ExtensionProtocolVersion = 3
type ExtensionOperation string
diff --git a/boatstack/delivery/program_manifest.go b/boatstack/delivery/program_manifest.go
index 0ae51a5..6bf3449 100644
--- a/boatstack/delivery/program_manifest.go
+++ b/boatstack/delivery/program_manifest.go
@@ -58,6 +58,8 @@ type ProgramTransition struct {
RequiredCapabilities []Capability `json:"required_capabilities"`
RequiredEvidence []string `json:"required_evidence"`
OwnedResources []string `json:"owned_resources,omitempty"`
+ OwnedFacets []StateFacet `json:"owned_facets"`
+ StateEffect StateEffect `json:"state_effect"`
Effect EffectID `json:"effect,omitempty"`
LocalEffects []EffectID `json:"local_effects,omitempty"`
ExternalEffects []EffectID `json:"external_effects,omitempty"`
@@ -337,6 +339,7 @@ func (value ProgramTransition) runtimeTransition() Transition {
RequiredIdentity: value.RequiredIdentity, Authority: value.Authority, AuthorityAll: value.AuthorityAll,
RequiredCapabilities: value.RequiredCapabilities,
RequiredEvidence: value.RequiredEvidence, OwnedResources: value.OwnedResources, Effect: value.Effect,
+ OwnedFacets: value.OwnedFacets, StateEffect: value.StateEffect,
LocalEffects: value.LocalEffects, ExternalEffects: value.ExternalEffects, Idempotent: value.Idempotent,
Parameters: value.Parameters, Prescription: value.Prescription, SourcePredicate: value.SourcePredicate,
SourceConditions: value.SourceConditions, AdmissionPredicate: value.AdmissionPredicate,
@@ -469,6 +472,24 @@ func normalizeProgramTransition(value Transition) (Transition, error) {
if err != nil {
return Transition{}, err
}
+ value.OwnedFacets, err = uniqueSorted(value.OwnedFacets, func(v StateFacet) string { return string(v) })
+ if err != nil {
+ return Transition{}, err
+ }
+ for index := range value.StateEffect.Preconditions {
+ value.StateEffect.Preconditions[index].Values, err = uniqueSorted(value.StateEffect.Preconditions[index].Values, func(v string) string { return v })
+ if err != nil {
+ return Transition{}, err
+ }
+ }
+ value.StateEffect.Preconditions, err = uniqueSorted(value.StateEffect.Preconditions, func(v StatePrecondition) string { return v.Facet })
+ if err != nil {
+ return Transition{}, err
+ }
+ value.StateEffect.Assignments, err = uniqueSorted(value.StateEffect.Assignments, func(v StateAssignment) string { return v.Facet })
+ if err != nil {
+ return Transition{}, err
+ }
value.LocalEffects, err = uniqueSorted(value.LocalEffects, func(v EffectID) string { return string(v) })
if err != nil {
return Transition{}, err
diff --git a/boatstack/delivery/program_manifest_test.go b/boatstack/delivery/program_manifest_test.go
index 40f5e40..56fab49 100644
--- a/boatstack/delivery/program_manifest_test.go
+++ b/boatstack/delivery/program_manifest_test.go
@@ -66,6 +66,8 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) {
},
"target-phase": func(value *delivery.ProgramManifest) {
value.Transitions[0].TargetPhases = []delivery.ProtocolPhase{delivery.PhaseFrontier}
+ phase := string(delivery.PhaseFrontier)
+ value.Transitions[0].StateEffect.Assignments[0].Value = &phase
},
"authority": func(value *delivery.ProgramManifest) {
value.Transitions[0].Authority = []delivery.AuthorityClass{delivery.AuthorityHuman}
@@ -83,12 +85,23 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) {
value.Transitions[0].Verifier = "alternate.verifier"
},
"postcondition": func(value *delivery.ProgramManifest) {
- value.Transitions[0].TargetConditions[0].Values = []string{"alternate"}
+ value.Transitions[0].TargetConditions[0].Values = []string{"published"}
+ published := "published"
+ value.Transitions[0].StateEffect.Assignments[1].Value = &published
},
"recovery": func(value *delivery.ProgramManifest) {
value.Transitions[0].Interruption.Recovery = "alternate.recover"
},
"priority": func(value *delivery.ProgramManifest) { value.Transitions[0].Priority++ },
+ "owned-facets": func(value *delivery.ProgramManifest) {
+ value.Transitions[0].OwnedFacets = []delivery.StateFacet{delivery.StateFacetControl}
+ value.Transitions[0].StateEffect.Assignments = value.Transitions[0].StateEffect.Assignments[:1]
+ value.Transitions[0].TargetConditions = []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetProgram, "current")}
+ },
+ "state-effect": func(value *delivery.ProgramManifest) {
+ nonterminal := "nonterminal"
+ value.Transitions[0].StateEffect.Assignments = append(value.Transitions[0].StateEffect.Assignments, delivery.StateAssignment{Facet: "terminal", Value: &nonterminal})
+ },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
@@ -108,6 +121,37 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) {
}
}
+func TestProgramManifestCanonicalizesStateEffectSets(t *testing.T) {
+ fixture := func() delivery.ProgramManifest {
+ manifest := programFixture()
+ transition := &manifest.Transitions[0]
+ transition.SourcePhases = []delivery.ProtocolPhase{delivery.PhaseActive, delivery.PhaseObserved}
+ transition.SourceConditions = append(transition.SourceConditions, delivery.KnownCondition(delivery.FacetTerminal, "nonterminal"))
+ transition.TargetConditions = append(transition.TargetConditions, delivery.KnownCondition(delivery.FacetTerminal, "established"))
+ phase, terminal, deliveryState := string(delivery.PhaseTerminal), "established", "terminal"
+ transition.StateEffect = delivery.StateEffect{
+ Kind: delivery.StateEffectAssignments,
+ Preconditions: []delivery.StatePrecondition{
+ {Facet: "phase", Values: []string{string(delivery.PhaseActive), string(delivery.PhaseObserved)}},
+ {Facet: "terminal", Values: []string{"nonterminal"}},
+ },
+ Assignments: []delivery.StateAssignment{{Facet: "phase", Value: &phase}, {Facet: "terminal", Value: &terminal}, {Facet: "delivery", Value: &deliveryState}},
+ }
+ return manifest
+ }
+
+ oneManifest := fixture()
+ one := loadManifest(t, oneManifest)
+ twoManifest := fixture()
+ reverse(twoManifest.Transitions[0].StateEffect.Preconditions)
+ reverse(twoManifest.Transitions[0].StateEffect.Preconditions[1].Values)
+ reverse(twoManifest.Transitions[0].StateEffect.Assignments)
+ two := loadManifest(t, twoManifest)
+ if one.Fingerprint() != two.Fingerprint() {
+ t.Fatalf("state-effect set ordering changed executable identity: %s != %s", one.Fingerprint(), two.Fingerprint())
+ }
+}
+
func TestProgramManifestNamespaceAndCompatibilityBoundary(t *testing.T) {
// control-law: only-compatible-validated-programs-reach-the-runtime-registry
first := programFixture()
@@ -152,6 +196,22 @@ func TestProgramManifestNamespaceAndCompatibilityBoundary(t *testing.T) {
{"under-declared-kernel-effect", func(value *delivery.ProgramManifest) {
value.Capabilities.CapabilitySurface = []delivery.Capability{delivery.CapabilityRepositoryWrite}
}, runtimeFixture(), delivery.ProgramInvalid},
+ {"host-native-state-handler", func(value *delivery.ProgramManifest) {
+ value.Transitions[0].StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "abandon-delivery"}
+ }, runtimeFixture(), delivery.ProgramInvalid},
+ {"product-only-owned-facets", func(value *delivery.ProgramManifest) {
+ value.Transitions[0].OwnedFacets = []delivery.StateFacet{delivery.StateFacetProduct}
+ }, runtimeFixture(), delivery.ProgramInvalid},
+ {"unclosed-verified-configuration", func(value *delivery.ProgramManifest) {
+ verified := "verified"
+ value.Transitions[0].SourceConditions = append(value.Transitions[0].SourceConditions, delivery.KnownCondition(delivery.FacetConfiguration, "unsupported"))
+ value.Transitions[0].TargetConditions = append(value.Transitions[0].TargetConditions, delivery.KnownCondition(delivery.FacetConfiguration, verified))
+ value.Transitions[0].StateEffect.Assignments = []delivery.StateAssignment{{Facet: "configuration", Value: &verified}}
+ }, runtimeFixture(), delivery.ProgramInvalid},
+ {"optional-assignment-parameter", func(value *delivery.ProgramManifest) {
+ value.Transitions[0].Parameters = append(value.Transitions[0].Parameters, delivery.ParameterSpec{Name: "optional_state"})
+ value.Transitions[0].StateEffect.Assignments = []delivery.StateAssignment{{Facet: "phase", ValueFrom: delivery.StateValueReference{Parameter: "optional_state"}}}
+ }, runtimeFixture(), delivery.ProgramInvalid},
{"duplicate-condition", func(value *delivery.ProgramManifest) {
value.Transitions[0].SourceConditions = append(value.Transitions[0].SourceConditions, value.Transitions[0].SourceConditions[0])
}, runtimeFixture(), delivery.ProgramInvalid},
@@ -234,18 +294,21 @@ func TestValidatedProgramIsTheKernelRegistry(t *testing.T) {
}
func programFixture() delivery.ProgramManifest {
+ activePhase := string(delivery.PhaseActive)
recovery := delivery.ProgramTransition{
ID: "recover", Version: 1, SelectionClass: delivery.SelectionProgramRecovery, Class: delivery.EventRecovery,
SourcePhases: []delivery.ProtocolPhase{delivery.PhaseRecovery}, TargetPhases: []delivery.ProtocolPhase{delivery.PhaseActive},
RequiredIdentity: []string{"repository-id"}, Authority: []delivery.AuthorityClass{delivery.AuthorityRepository}, RequiredCapabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite}, RequiredEvidence: []string{"snapshot"},
- OwnedResources: []string{"program.state"}, Effect: "program.recover", LocalEffects: []delivery.EffectID{"program.recover"}, Idempotent: true,
+ OwnedResources: []string{"program.state"}, OwnedFacets: []delivery.StateFacet{delivery.StateFacetControl}, StateEffect: delivery.StateEffect{Kind: delivery.StateEffectAssignments, Assignments: []delivery.StateAssignment{{Facet: "phase", Value: &activePhase}}},
+ Effect: "program.recover", LocalEffects: []delivery.EffectID{"program.recover"}, Idempotent: true,
Prescription: delivery.Prescription{Operation: "recover", ExpectedPostcondition: "active"}, SourcePredicate: "recovery-required",
- SourceConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetRecovery, "required")}, AdmissionPredicate: "exact-admission",
+ SourceConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetRecovery, "required"), delivery.KnownCondition(delivery.FacetProgram, "current")}, AdmissionPredicate: "exact-admission",
TargetPredicate: "active", TargetConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetProgram, "current")}, Verifier: "program.current",
Interruption: interruption("recover"), Reversibility: delivery.Reversible, TerminalEffect: "none",
PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "local", Policy: delivery.PolicyContract{ObjectiveScope: delivery.ObjectiveScopeOptionalPreserve}, Priority: 1,
}
advance := recovery
+ terminalPhase, terminalDelivery := string(delivery.PhaseTerminal), "terminal"
advance.ID = "advance"
advance.SelectionClass = delivery.SelectionProgramProgress
advance.Class = delivery.EventOwnedLocal
@@ -260,6 +323,8 @@ func programFixture() delivery.ProgramManifest {
advance.SourceConditions = []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetProgram, "current"), delivery.KnownCondition(delivery.FacetDelivery, "active")}
advance.TargetPredicate = "terminal"
advance.TargetConditions = []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetDelivery, "terminal")}
+ advance.OwnedFacets = []delivery.StateFacet{delivery.StateFacetControl, delivery.StateFacetProduct}
+ advance.StateEffect.Assignments = []delivery.StateAssignment{{Facet: "phase", Value: &terminalPhase}, {Facet: "delivery", Value: &terminalDelivery}}
advance.Verifier = "program.terminal"
advance.Policy.ObjectiveScope = delivery.ObjectiveScopeBoundExact
return delivery.ProgramManifest{
diff --git a/boatstack/delivery/program_runtime.go b/boatstack/delivery/program_runtime.go
index 6898497..d1d46f6 100644
--- a/boatstack/delivery/program_runtime.go
+++ b/boatstack/delivery/program_runtime.go
@@ -6,7 +6,7 @@ import (
"fmt"
)
-const ProgramRuntimeProtocolVersion = 2
+const ProgramRuntimeProtocolVersion = 3
type ProgramRuntimeMode string
diff --git a/boatstack/extension/releasenote/releasenote.go b/boatstack/extension/releasenote/releasenote.go
index 308c443..081d8d9 100644
--- a/boatstack/extension/releasenote/releasenote.go
+++ b/boatstack/extension/releasenote/releasenote.go
@@ -42,6 +42,7 @@ func (Extension) ExtensionManifest(context.Context) (delivery.ExtensionManifest,
ObjectiveKinds: []model.ObjectiveKind{model.ObjectiveOpenPR, model.ObjectiveMerged}, RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id", "controller-id", "topology", "host", "correlation-id"},
Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot-fingerprint", "objective", "facet:" + FactID},
OwnedResources: []string{Resource}, Effect: Effect, LocalEffects: []catalog.EffectID{Effect}, Idempotent: true,
+ OwnedFacets: []model.StateFacet{model.StateFacetControl}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectAssignments},
Prescription: catalog.Prescription{Operation: Transition, ExpectedPostcondition: "release-note evidence is verified"},
SourcePredicate: "reference-release-note-missing", AdmissionPredicate: "exact-extension-admission", TargetPredicate: "reference-release-note-verified", Verifier: Verifier,
SourceConditions: []catalog.FacetCondition{
diff --git a/boatstack/extension/subprocess/testdata/reference_extension.py b/boatstack/extension/subprocess/testdata/reference_extension.py
index dc37558..01882dd 100755
--- a/boatstack/extension/subprocess/testdata/reference_extension.py
+++ b/boatstack/extension/subprocess/testdata/reference_extension.py
@@ -6,7 +6,7 @@
request = json.load(sys.stdin)
operation = request["operation"]
response = {
- "protocol_version": 2,
+ "protocol_version": 3,
"operation": operation,
"extension_id": request["extension_id"],
"extension_version": request["extension_version"],
@@ -17,7 +17,7 @@
response["manifest"] = {
"id": "fixture.echo",
"version": "1.0.0",
- "protocol_version": 2,
+ "protocol_version": 3,
"settings_schema": {"type": "object"},
"facts": ["fixture.echo.present"],
"capabilities": ["command.execute"],
diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go
index 7ede78d..323405a 100644
--- a/boatstack/flow/standard/completeness_test.go
+++ b/boatstack/flow/standard/completeness_test.go
@@ -173,46 +173,149 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t
}
func TestEveryControllableRuntimeEventHasAnExecutableStateReducer(t *testing.T) {
- // control-law: registry-entry-cannot-exist-without-runtime-effect-reduction
- path := filepath.Join(sourceRoot(t), "internal", "softwaredelivery", "effects", "state_reducer.go")
- parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- covered := map[catalog.TransitionID]bool{}
+ // control-law: registry-entry-cannot-exist-without-declared-runtime-effect-reduction
registry := testprogram.StandardRegistry()
- for _, declaration := range parsed.Decls {
- function, ok := declaration.(*ast.FuncDecl)
- if !ok || function.Name.Name != "applyStateTransition" {
+ for _, transition := range registry.All() {
+ if !transition.Controllable() {
+ if transition.StateEffect.Kind != "" || len(transition.OwnedFacets) != 0 {
+ t.Errorf("observed transition %s owns a durable state effect", transition.ID)
+ }
continue
}
- ast.Inspect(function.Body, func(node ast.Node) bool {
- clause, ok := node.(*ast.CaseClause)
- if !ok {
- return true
+ if len(transition.OwnedFacets) == 0 {
+ t.Errorf("controllable transition %s has no declared durable state facets", transition.ID)
+ }
+ if transition.StateEffect.Kind != catalog.StateEffectAssignments && transition.StateEffect.Kind != catalog.StateEffectNative {
+ t.Errorf("controllable transition %s has no executable declared state effect", transition.ID)
+ }
+ }
+}
+
+func TestMalformedDeclaredStateEffectsFailClosedAtCatalogBoundary(t *testing.T) {
+ // control-law: malformed-state-declarations-never-reach-effect-preparation
+ cases := []struct {
+ name string
+ mutate func(*catalog.Transition)
+ }{
+ {"unknown-field", func(value *catalog.Transition) { value.StateEffect.Assignments[0].Facet = "not_a_state_field" }},
+ {"unowned-field", func(value *catalog.Transition) { value.OwnedFacets = []model.StateFacet{model.StateFacetControl} }},
+ {"undeclared-parameter", func(value *catalog.Transition) {
+ value.StateEffect.Assignments[0].Value = nil
+ value.StateEffect.Assignments[0].ValueFrom.Parameter = "not_declared"
+ }},
+ {"optional-assignment-parameter", func(value *catalog.Transition) {
+ value.Parameters = append(value.Parameters, catalog.ParameterSpec{Name: "optional_state", Required: false})
+ value.StateEffect.Assignments[0].Value = nil
+ value.StateEffect.Assignments[0].ValueFrom.Parameter = "optional_state"
+ }},
+ {"unknown-admission-source", func(value *catalog.Transition) {
+ value.StateEffect.Assignments[0].Value = nil
+ value.StateEffect.Assignments[0].ValueFrom.Admission = "not_admitted"
+ }},
+ {"invalid-state-literal", func(value *catalog.Transition) { *value.StateEffect.Assignments[0].Value = "NOT_A_PHASE" }},
+ {"target-mismatched-state-literal", func(value *catalog.Transition) { *value.StateEffect.Assignments[0].Value = string(model.PhaseDormant) }},
+ {"unmodeled-apply-precondition", func(value *catalog.Transition) {
+ value.StateEffect.Preconditions = []catalog.StatePrecondition{{Facet: "phase", Values: []string{string(model.PhaseDormant)}}}
+ }},
+ }
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ transitions := testprogram.StandardRegistry().All()
+ for index := range transitions {
+ if transitions[index].ID == "plan.create" {
+ test.mutate(&transitions[index])
+ break
+ }
+ }
+ if _, err := catalog.New(transitions); err == nil {
+ t.Fatal("malformed declared state effect reached the runtime registry")
}
- for _, expression := range clause.List {
- literal, ok := expression.(*ast.BasicLit)
- if !ok || literal.Kind != token.STRING {
- continue
+ })
+ }
+}
+
+func TestNativeStateHandlersAreBoundToAuthorizedSemantics(t *testing.T) {
+ // control-law: a named native handler cannot grant semantics beyond its component, effect, facets, or objective policy
+ cases := []struct {
+ name string
+ transition catalog.TransitionID
+ mutate func(*catalog.Transition)
+ }{
+ {"unknown-handler", "plan.approve", func(value *catalog.Transition) { value.StateEffect.NativeHandler = "unknown-handler" }},
+ {"untrusted-component", "plan.approve", func(value *catalog.Transition) { value.Origin.ID = "repository-program" }},
+ {"mismatched-effect", "plan.approve", func(value *catalog.Transition) { value.Effect = "plan.create" }},
+ {"mismatched-facets", "plan.approve", func(value *catalog.Transition) { value.OwnedFacets = []model.StateFacet{model.StateFacetControl} }},
+ {"objective-bind-policy", "objective.bind", func(value *catalog.Transition) {
+ value.Policy.BindsRequestedObjective = false
+ value.Policy.ObjectiveScope = catalog.ObjectiveScopeBoundExact
+ }},
+ }
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ transitions := testprogram.StandardRegistry().All()
+ for index := range transitions {
+ if transitions[index].ID == test.transition {
+ test.mutate(&transitions[index])
+ break
}
- value, unquoteErr := strconv.Unquote(literal.Value)
- if unquoteErr != nil {
- t.Fatal(unquoteErr)
+ }
+ if _, err := catalog.New(transitions); err == nil {
+ t.Fatal("invalid native handler contract reached the runtime registry")
+ }
+ })
+ }
+}
+
+func TestDeclarativeAssignmentsCloseDurableStateInvariants(t *testing.T) {
+ // control-law: every accepted assignment set preserves durable-state validity for every resolver-matching source
+ cases := []struct {
+ name string
+ transition catalog.TransitionID
+ mutate func(*catalog.Transition)
+ }{
+ {"verified-runtime-without-source", "installation.update", func(value *catalog.Transition) {
+ value.StateEffect.Assignments = removeAssignment(value.StateEffect.Assignments, "runtime_source")
+ }},
+ {"managed-workspace-without-source-identity", "workspace.cut", func(value *catalog.Transition) {
+ value.StateEffect.Assignments = removeAssignment(value.StateEffect.Assignments, "workspace_source_ref")
+ }},
+ {"recovery-without-cause", "recovery.escalate", func(value *catalog.Transition) {
+ value.StateEffect.Assignments = removeAssignment(value.StateEffect.Assignments, "recovery_cause")
+ }},
+ {"terminal-with-active-phase", "workspace.abandon", func(value *catalog.Transition) {
+ active := string(model.PhaseActive)
+ value.TargetPhases = []model.ProtocolPhase{model.PhaseActive}
+ for index := range value.StateEffect.Assignments {
+ if value.StateEffect.Assignments[index].Facet == "phase" {
+ value.StateEffect.Assignments[index].Value = &active
}
- id := catalog.TransitionID(value)
- if transition, exists := registry.Lookup(id); exists && transition.Controllable() {
- covered[id] = true
+ }
+ }},
+ }
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ transitions := testprogram.StandardRegistry().All()
+ for index := range transitions {
+ if transitions[index].ID == test.transition {
+ test.mutate(&transitions[index])
+ break
}
}
- return true
+ if _, err := catalog.New(transitions); err == nil {
+ t.Fatal("durably incomplete assignment set reached the runtime registry")
+ }
})
}
- for _, transition := range registry.All() {
- if transition.Controllable() && !covered[transition.ID] {
- t.Errorf("controllable transition %s has no applyStateTransition reducer case", transition.ID)
+}
+
+func removeAssignment(values []catalog.StateAssignment, facet string) []catalog.StateAssignment {
+ result := make([]catalog.StateAssignment, 0, len(values))
+ for _, value := range values {
+ if value.Facet != facet {
+ result = append(result, value)
}
}
+ return result
}
func TestPackageImportsPreserveControlProgramDependencyDirection(t *testing.T) {
diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json
index a184380..dc6d3ba 100644
--- a/boatstack/flow/standard/transitions.json
+++ b/boatstack/flow/standard/transitions.json
@@ -210,7 +210,32 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 35
+ "priority": 35,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "plan",
+ "value": "draft"
+ },
+ {
+ "facet": "delivery",
+ "value": "planning"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ },
+ {
+ "facet": "terminal",
+ "value": "nonterminal"
+ }
+ ]
+ }
},
{
"id": "plan.validate",
@@ -409,7 +434,28 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 40
+ "priority": 40,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "plan",
+ "value": "valid"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ },
+ {
+ "facet": "terminal",
+ "value": "nonterminal"
+ }
+ ]
+ }
},
{
"id": "plan.approve",
@@ -628,7 +674,15 @@
"authority_rule": "plan-approval",
"objective_scope": "bound-exact"
},
- "priority": 45
+ "priority": 45,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "plan-approve"
+ }
},
{
"id": "plan.activate",
@@ -824,7 +878,28 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 50
+ "priority": 50,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "plan",
+ "value": "locked"
+ },
+ {
+ "facet": "delivery",
+ "value": "active"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "plan.amend",
@@ -1038,7 +1113,32 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 42
+ "priority": 42,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "plan",
+ "value": "amendment-required"
+ },
+ {
+ "facet": "delivery",
+ "value": "amendment"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ },
+ {
+ "facet": "terminal",
+ "value": "nonterminal"
+ }
+ ]
+ }
},
{
"id": "plan.approve-amendment",
@@ -1256,7 +1356,28 @@
"authority_rule": "plan-approval",
"objective_scope": "bound-exact"
},
- "priority": 46
+ "priority": 46,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "plan",
+ "value": "approved"
+ },
+ {
+ "facet": "delivery",
+ "value": "approved"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "plan.invalidate",
@@ -1438,7 +1559,32 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 41
+ "priority": 41,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "plan",
+ "value": "invalid"
+ },
+ {
+ "facet": "delivery",
+ "value": "invalid"
+ },
+ {
+ "facet": "phase",
+ "value": "FRONTIER"
+ },
+ {
+ "facet": "terminal",
+ "value": "nonterminal"
+ }
+ ]
+ }
},
{
"id": "plan.abandon",
@@ -1618,7 +1764,15 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 90
+ "priority": 90,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "abandon-delivery"
+ }
},
{
"id": "workspace.cut",
@@ -1834,7 +1988,60 @@
"objective_scope": "bound-exact"
},
"priority": 52,
- "allows_worktree_transfer": true
+ "allows_worktree_transfer": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "workspace",
+ "value": "cut"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ },
+ {
+ "facet": "workspace_branch",
+ "value_from": {
+ "parameter": "branch"
+ }
+ },
+ {
+ "facet": "workspace_base_ref",
+ "value_from": {
+ "parameter": "base_ref"
+ }
+ },
+ {
+ "facet": "workspace_path",
+ "value_from": {
+ "parameter": "destination"
+ }
+ },
+ {
+ "facet": "workspace_source_path",
+ "value_from": {
+ "invocation": "invoking_path"
+ }
+ },
+ {
+ "facet": "workspace_source_id",
+ "value_from": {
+ "invocation": "worktree_id"
+ }
+ },
+ {
+ "facet": "workspace_source_ref",
+ "value_from": {
+ "invocation": "ref"
+ }
+ }
+ ]
+ }
},
{
"id": "workspace.sync",
@@ -2040,7 +2247,24 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 58
+ "priority": 58,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "workspace",
+ "value": "active"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "workspace.activate",
@@ -2245,7 +2469,24 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 53
+ "priority": 53,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "workspace",
+ "value": "active"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "workspace.publish",
@@ -2445,7 +2686,24 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 75
+ "priority": 75,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "workspace",
+ "value": "published"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "workspace.cleanup",
@@ -2660,7 +2918,15 @@
"objective_scope": "bound-exact"
},
"priority": 92,
- "allows_worktree_transfer": true
+ "allows_worktree_transfer": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "workspace-cleanup"
+ }
},
{
"id": "workspace.reap",
@@ -2870,7 +3136,15 @@
"objective_scope": "bound-exact"
},
"priority": 98,
- "allows_worktree_transfer": true
+ "allows_worktree_transfer": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "workspace-reap"
+ }
},
{
"id": "workspace.abandon",
@@ -3051,7 +3325,64 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 91
+ "priority": 91,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "delivery",
+ "value": "discarded"
+ },
+ {
+ "facet": "workspace",
+ "value": "abandoned"
+ },
+ {
+ "facet": "recovery",
+ "value": "none"
+ },
+ {
+ "facet": "transaction",
+ "value": "none"
+ },
+ {
+ "facet": "transaction_id",
+ "value": ""
+ },
+ {
+ "facet": "transaction_transition",
+ "value": ""
+ },
+ {
+ "facet": "recovery_cause",
+ "value": ""
+ },
+ {
+ "facet": "recovery_source_phase",
+ "value": ""
+ },
+ {
+ "facet": "recovery_resumption",
+ "value": ""
+ },
+ {
+ "facet": "recovery_budget",
+ "value": "0"
+ },
+ {
+ "facet": "terminal",
+ "value": "established"
+ },
+ {
+ "facet": "phase",
+ "value": "ABANDONED"
+ }
+ ]
+ }
},
{
"id": "workspace.reconcile",
@@ -3218,7 +3549,15 @@
"objective_scope": "optional-preserve"
},
"priority": 2,
- "allows_worktree_transfer": true
+ "allows_worktree_transfer": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "workspace-reconcile"
+ }
},
{
"id": "gate.build.record",
@@ -3445,7 +3784,15 @@
"objective_scope": "bound-exact"
},
"priority": 61,
- "binds_source_revision": true
+ "binds_source_revision": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "gate-build-record"
+ }
},
{
"id": "gate.test.record",
@@ -3673,7 +4020,15 @@
"objective_scope": "bound-exact"
},
"priority": 62,
- "binds_source_revision": true
+ "binds_source_revision": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "gate-test-record"
+ }
},
{
"id": "gate.review.record",
@@ -3910,7 +4265,15 @@
"objective_scope": "bound-exact"
},
"priority": 63,
- "binds_source_revision": true
+ "binds_source_revision": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "gate-review-record"
+ }
},
{
"id": "gate.change.record",
@@ -4137,7 +4500,15 @@
"objective_scope": "bound-exact"
},
"priority": 64,
- "binds_source_revision": true
+ "binds_source_revision": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "gate-change-record"
+ }
},
{
"id": "gate.journey.record",
@@ -4364,7 +4735,15 @@
"objective_scope": "bound-exact"
},
"priority": 64,
- "binds_source_revision": true
+ "binds_source_revision": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "gate-journey-record"
+ }
},
{
"id": "evidence.visual.attach",
@@ -4590,7 +4969,15 @@
"objective_scope": "bound-exact"
},
"priority": 66,
- "binds_source_revision": true
+ "binds_source_revision": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "visual-evidence-attach"
+ }
},
{
"id": "evidence.approval.revoke",
@@ -4768,7 +5155,28 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 44
+ "priority": 44,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "plan",
+ "value": "valid"
+ },
+ {
+ "facet": "phase",
+ "value": "FRONTIER"
+ },
+ {
+ "facet": "terminal",
+ "value": "nonterminal"
+ }
+ ]
+ }
},
{
"id": "delivery.slice.advance",
@@ -4979,7 +5387,36 @@
"objective_scope": "bound-exact"
},
"priority": 68,
- "binds_source_revision": true
+ "binds_source_revision": true,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "delivery",
+ "value": "active"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ },
+ {
+ "facet": "source_revision",
+ "value_from": {
+ "admission": "source_revision"
+ }
+ },
+ {
+ "facet": "worktree_fingerprint",
+ "value_from": {
+ "admission": "worktree_fingerprint"
+ }
+ }
+ ]
+ }
},
{
"id": "publication.preview",
@@ -5210,7 +5647,24 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 72
+ "priority": 72,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "publication",
+ "value": "candidate"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "publication.execute",
@@ -5440,7 +5894,32 @@
"objective_scope": "bound-exact"
},
"priority": 76,
- "authority_fingerprint_parameter": "preview_fingerprint"
+ "authority_fingerprint_parameter": "preview_fingerprint",
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "publication",
+ "value": "published-not-landed"
+ },
+ {
+ "facet": "workspace",
+ "value": "published"
+ },
+ {
+ "facet": "delivery",
+ "value": "published"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "publication.observe",
@@ -5656,7 +6135,15 @@
"rechecks_external_state": true,
"objective_scope": "bound-exact"
},
- "priority": 77
+ "priority": 77,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "publication-observe"
+ }
},
{
"id": "publication.reconcile",
@@ -5854,7 +6341,15 @@
"objective_scope": "optional-preserve"
},
"priority": 1,
- "authority_fingerprint_parameter": "publication_id"
+ "authority_fingerprint_parameter": "publication_id",
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "publication-observe"
+ }
},
{
"id": "publication.correct",
@@ -6088,7 +6583,32 @@
"objective_scope": "bound-exact"
},
"priority": 80,
- "authority_fingerprint_parameter": "body_sha256"
+ "authority_fingerprint_parameter": "body_sha256",
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "assignments",
+ "assignments": [
+ {
+ "facet": "publication",
+ "value": "published-not-landed"
+ },
+ {
+ "facet": "delivery",
+ "value": "published"
+ },
+ {
+ "facet": "terminal",
+ "value": "nonterminal"
+ },
+ {
+ "facet": "phase",
+ "value": "ACTIVE"
+ }
+ ]
+ }
},
{
"id": "publication.abandon",
@@ -6267,6 +6787,14 @@
"policy": {
"objective_scope": "bound-exact"
},
- "priority": 93
+ "priority": 93,
+ "owned_facets": [
+ "control",
+ "product"
+ ],
+ "state_effect": {
+ "kind": "native",
+ "native_handler": "abandon-delivery"
+ }
}
]
diff --git a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go
new file mode 100644
index 0000000..a3c723d
--- /dev/null
+++ b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go
@@ -0,0 +1,85 @@
+package catalog
+
+import (
+ "fmt"
+ "slices"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
+)
+
+type nativeStateHandlerContract struct {
+ componentIDs []string
+ effects []EffectID
+ ownedFacets []model.StateFacet
+ objectiveScopes []ObjectiveScope
+ bindsRequestedObjective bool
+}
+
+var nativeStateHandlerContracts = map[string]nativeStateHandlerContract{
+ "runtime-verified-settled": coreNative([]EffectID{"runtime.hydrate", "runtime.replace"}, []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, ObjectiveScopeOptionalPreserve),
+ "runtime-reconcile": coreNative([]EffectID{"runtime.reconcile"}, []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, ObjectiveScopeOptionalPreserve),
+ "configuration-verified-settled": coreNative([]EffectID{"configuration.initialize", "configuration.mutate"}, []model.StateFacet{model.StateFacetControl}, ObjectiveScopeOptionalPreserve),
+ "configuration-reconcile": coreNative([]EffectID{"configuration.reconcile"}, []model.StateFacet{model.StateFacetControl}, ObjectiveScopeOptionalPreserve),
+ "installation-initialize": coreNative([]EffectID{"installation.initialize"}, []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation, model.StateFacetProgram}, ObjectiveScopeOptionalPreserve),
+ "installation-reconcile-update": coreNative([]EffectID{"installation.reconcile-update"}, []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation, model.StateFacetProgram}, ObjectiveScopeOptionalPreserve),
+ "catalog-reconcile": coreNative([]EffectID{"catalog.reconcile"}, []model.StateFacet{model.StateFacetControl, model.StateFacetProgram}, ObjectiveScopeOptionalPreserve),
+ "objective-bind": {
+ componentIDs: []string{"boatstack.core"}, effects: []EffectID{"objective.bind"},
+ ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeNone},
+ bindsRequestedObjective: true,
+ },
+ "plan-approve": standardNative([]EffectID{"plan.approve"}, ObjectiveScopeBoundExact),
+ "abandon-delivery": standardNative([]EffectID{"plan.abandon", "publication.abandon"}, ObjectiveScopeBoundExact),
+ "workspace-cleanup": standardNative([]EffectID{"workspace.cleanup"}, ObjectiveScopeBoundExact),
+ "workspace-reap": standardNative([]EffectID{"workspace.reap"}, ObjectiveScopeBoundExact),
+ "workspace-reconcile": standardNative([]EffectID{"workspace.reconcile"}, ObjectiveScopeOptionalPreserve),
+ "gate-build-record": standardNative([]EffectID{"gate.build.record"}, ObjectiveScopeBoundExact),
+ "gate-test-record": standardNative([]EffectID{"gate.test.record"}, ObjectiveScopeBoundExact),
+ "gate-review-record": standardNative([]EffectID{"gate.review.record"}, ObjectiveScopeBoundExact),
+ "gate-change-record": standardNative([]EffectID{"gate.change.record"}, ObjectiveScopeBoundExact),
+ "gate-journey-record": standardNative([]EffectID{"gate.journey.record"}, ObjectiveScopeBoundExact),
+ "visual-evidence-attach": standardNative([]EffectID{"evidence.visual.attach"}, ObjectiveScopeBoundExact),
+ "publication-observe": {
+ componentIDs: []string{"boatstack.standard"}, effects: []EffectID{"publication.observe", "publication.reconcile"},
+ ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeBoundExact, ObjectiveScopeOptionalPreserve},
+ },
+}
+
+func coreNative(effects []EffectID, facets []model.StateFacet, scope ObjectiveScope) nativeStateHandlerContract {
+ return nativeStateHandlerContract{componentIDs: []string{"boatstack.core"}, effects: effects, ownedFacets: facets, objectiveScopes: []ObjectiveScope{scope}}
+}
+
+func standardNative(effects []EffectID, scope ObjectiveScope) nativeStateHandlerContract {
+ return nativeStateHandlerContract{
+ componentIDs: []string{"boatstack.standard"}, effects: effects,
+ ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{scope},
+ }
+}
+
+func validateNativeStateHandler(t Transition) error {
+ contract, ok := nativeStateHandlerContracts[t.StateEffect.NativeHandler]
+ if !ok {
+ return fmt.Errorf("%s: native state handler %q is not registered", t.ID, t.StateEffect.NativeHandler)
+ }
+ if !slices.Contains(contract.componentIDs, t.Origin.ID) {
+ return fmt.Errorf("%s: component %q cannot invoke native state handler %q", t.ID, t.Origin.ID, t.StateEffect.NativeHandler)
+ }
+ if !slices.Contains(contract.effects, t.Effect) {
+ return fmt.Errorf("%s: native state handler %q is incompatible with effect %q", t.ID, t.StateEffect.NativeHandler, t.Effect)
+ }
+ writes, err := model.NormalizeStateFacets(string(t.ID)+".owned_facets", t.OwnedFacets)
+ if err != nil {
+ return err
+ }
+ expected, err := model.NormalizeStateFacets(t.StateEffect.NativeHandler+".owned_facets", contract.ownedFacets)
+ if err != nil {
+ return err
+ }
+ if !slices.Equal(writes, expected) {
+ return fmt.Errorf("%s: native state handler %q requires owned facets %v", t.ID, t.StateEffect.NativeHandler, expected)
+ }
+ if !slices.Contains(contract.objectiveScopes, t.Policy.ObjectiveScope) || t.Policy.BindsRequestedObjective != contract.bindsRequestedObjective {
+ return fmt.Errorf("%s: native state handler %q has incompatible objective policy", t.ID, t.StateEffect.NativeHandler)
+ }
+ return nil
+}
diff --git a/boatstack/internal/softwaredelivery/catalog/state_effect_closure.go b/boatstack/internal/softwaredelivery/catalog/state_effect_closure.go
new file mode 100644
index 0000000..1368468
--- /dev/null
+++ b/boatstack/internal/softwaredelivery/catalog/state_effect_closure.go
@@ -0,0 +1,203 @@
+package catalog
+
+import (
+ "fmt"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
+)
+
+func validateDeclarativeStateClosure(t Transition, assigned map[string]bool) error {
+ if t.StateEffect.Kind != StateEffectAssignments {
+ return nil
+ }
+ if journalDerivedCoreRecovery(t) {
+ return nil
+ }
+ if !preservedPhaseIsDeclared(t) {
+ return fmt.Errorf("%s: declarative state effect can preserve a source phase outside its target phases", t.ID)
+ }
+ configurationVerified := resultGuaranteesValues(t, "configuration", string(model.ConfigurationVerified))
+ sourceConfigurationVerified := sourceGuaranteesValues(t, "configuration", string(model.ConfigurationVerified))
+ if configurationVerified && !sourceConfigurationVerified {
+ return fmt.Errorf("%s: declarative verified configuration requires an already verified source; initialization requires a native handler", t.ID)
+ }
+ if configurationVerified && !assignmentsPreserveOrProduceNonEmpty(t, sourceConfigurationVerified, "config_fingerprint") {
+ return fmt.Errorf("%s: declarative verified configuration cannot clear its fingerprint", t.ID)
+ }
+ runtimeVerified := resultGuaranteesValues(t, "runtime", string(model.RuntimeVerified))
+ sourceRuntimeVerified := sourceGuaranteesValues(t, "runtime", string(model.RuntimeVerified))
+ if runtimeVerified && !assignmentsPreserveOrProduceNonEmpty(t, sourceRuntimeVerified, "runtime_version", "runtime_fingerprint", "runtime_source") {
+ return fmt.Errorf("%s: declarative verified runtime requires version, fingerprint, and source assignments", t.ID)
+ }
+ managedWorkspace := []string{string(model.WorkspaceCut), string(model.WorkspaceActive), string(model.WorkspacePublished), string(model.WorkspaceLanded), string(model.WorkspaceAttentionRequired), string(model.WorkspaceAbandoned)}
+ resultWorkspaceManaged := resultGuaranteesValues(t, "workspace", managedWorkspace...)
+ sourceWorkspaceManaged := sourceGuaranteesValues(t, "workspace", managedWorkspace...)
+ if resultWorkspaceManaged && !assignmentsPreserveOrProduceNonEmpty(t, sourceWorkspaceManaged, "workspace_path", "workspace_branch", "workspace_source_path", "workspace_source_id", "workspace_source_ref") {
+ return fmt.Errorf("%s: declarative managed workspace requires complete destination and source identity", t.ID)
+ }
+ resultRecoveryNonEmpty := resultGuaranteesValues(t, "recovery", nonEmptyRecoveryValues()...)
+ sourceRecoveryNonEmpty := sourceGuaranteesValues(t, "recovery", nonEmptyRecoveryValues()...)
+ if resultRecoveryNonEmpty &&
+ (!assignmentsPreserveOrProduceNonEmpty(t, sourceRecoveryNonEmpty, "transaction_id", "recovery_cause", "recovery_source_phase", "recovery_resumption") || (!sourceRecoveryNonEmpty && !assigned["recovery_budget"])) {
+ return fmt.Errorf("%s: declarative recovery state requires complete recovery context", t.ID)
+ }
+ resultTransactionNonEmpty := resultGuaranteesValues(t, "transaction", nonEmptyTransactionValues()...)
+ sourceTransactionNonEmpty := sourceGuaranteesValues(t, "transaction", nonEmptyTransactionValues()...)
+ if resultTransactionNonEmpty && !assignmentsPreserveOrProduceNonEmpty(t, sourceTransactionNonEmpty, "transaction_id", "transaction_transition") {
+ return fmt.Errorf("%s: declarative transaction state requires complete transaction context", t.ID)
+ }
+ if resultGuaranteesValues(t, "terminal", string(model.TerminalEstablished)) && !resultGuaranteesPhase(t, model.PhaseTerminal, model.PhaseAbandoned) {
+ return fmt.Errorf("%s: declarative established terminal state requires a terminal or abandoned target phase", t.ID)
+ }
+ if resultGuaranteesPhase(t, model.PhaseRecovery) && !resultRecoveryNonEmpty {
+ return fmt.Errorf("%s: declarative recovery phase requires a non-empty recovery classification", t.ID)
+ }
+ return nil
+}
+
+func validateDeterministicAssignment(t Transition, assignment StateAssignment) error {
+ switch assignment.Facet {
+ case "recovery_source_phase", "recovery_resumption", "recovery_budget":
+ if assignment.Value == nil {
+ return fmt.Errorf("%s: state-effect assignment %q requires a literal constrained value", t.ID, assignment.Facet)
+ }
+ case "program_fingerprint":
+ if assignment.Value != nil {
+ if *assignment.Value != "" && len(*assignment.Value) != 64 {
+ return fmt.Errorf("%s: state-effect assignment %q requires an empty or 64-character fingerprint", t.ID, assignment.Facet)
+ }
+ return nil
+ }
+ if assignment.ValueFrom.Admission != "expected_program_fingerprint" {
+ return fmt.Errorf("%s: state-effect assignment %q requires the admitted program fingerprint", t.ID, assignment.Facet)
+ }
+ }
+ return nil
+}
+
+func preservedPhaseIsDeclared(t Transition) bool {
+ if _, assigned := assignmentLiteral(t, "phase"); assigned {
+ return true
+ }
+ for _, source := range t.SourcePhases {
+ if !t.DeclaresTargetPhase(source) {
+ return false
+ }
+ }
+ return len(t.SourcePhases) > 0
+}
+
+func journalDerivedCoreRecovery(t Transition) bool {
+ // Core recovery replay restores the complete state from the bound journal
+ // at the dedicated recovery boundary rather than through assignments.
+ return t.Class == EventRecovery && !t.RuntimeExecution && len(t.StateEffect.Assignments) == 0 && containsString(t.OwnedResources, "recovery-journal")
+}
+
+func nonEmptyRecoveryValues() []string {
+ return []string{
+ string(model.RecoveryResumable), string(model.RecoveryRollback), string(model.RecoveryCompensation),
+ string(model.RecoveryReconcile), string(model.RecoveryEscalated),
+ }
+}
+
+func nonEmptyTransactionValues() []string {
+ return []string{
+ string(model.TransactionStaged), string(model.TransactionLocalApplied), string(model.TransactionExternalUncertain),
+ string(model.TransactionVerifying), string(model.TransactionCommitted), string(model.TransactionCompensating),
+ }
+}
+
+func assignmentLiteral(t Transition, facet string) (string, bool) {
+ for _, assignment := range t.StateEffect.Assignments {
+ if assignment.Facet == facet && assignment.Value != nil {
+ return *assignment.Value, true
+ }
+ }
+ return "", false
+}
+
+func resultGuaranteesValues(t Transition, facet string, values ...string) bool {
+ if value, assigned := assignmentLiteral(t, facet); assigned {
+ return containsString(values, value)
+ }
+ return sourceGuaranteesValues(t, facet, values...)
+}
+
+func assignmentsPreserveOrProduceNonEmpty(t Transition, sourceCompositeValid bool, facets ...string) bool {
+ for _, facet := range facets {
+ assigned := false
+ for _, assignment := range t.StateEffect.Assignments {
+ if assignment.Facet != facet {
+ continue
+ }
+ assigned = true
+ if assignment.Value != nil && *assignment.Value == "" {
+ return false
+ }
+ break
+ }
+ if !assigned && !sourceCompositeValid {
+ return false
+ }
+ }
+ return true
+}
+
+func resultGuaranteesPhase(t Transition, phases ...model.ProtocolPhase) bool {
+ if value, assigned := assignmentLiteral(t, "phase"); assigned {
+ for _, phase := range phases {
+ if value == string(phase) {
+ return true
+ }
+ }
+ return false
+ }
+ if len(t.SourcePhases) == 0 {
+ return false
+ }
+ for _, source := range t.SourcePhases {
+ found := false
+ for _, phase := range phases {
+ if source == phase {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+}
+
+func containsString(values []string, wanted string) bool {
+ for _, value := range values {
+ if value == wanted {
+ return true
+ }
+ }
+ return false
+}
+
+func sourceGuaranteesValues(t Transition, field string, values ...string) bool {
+ facet, ok := DeclaredStateResolverFacet(field)
+ if !ok {
+ return false
+ }
+ allowed := make(map[string]bool, len(values))
+ for _, value := range values {
+ allowed[value] = true
+ }
+ for _, condition := range t.SourceConditions {
+ if condition.Facet != facet || len(condition.Statuses) != 1 || condition.Statuses[0] != model.FactKnown || len(condition.Values) == 0 {
+ continue
+ }
+ for _, value := range condition.Values {
+ if !allowed[value] {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
diff --git a/boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go b/boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go
new file mode 100644
index 0000000..c6267e7
--- /dev/null
+++ b/boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go
@@ -0,0 +1,154 @@
+package catalog
+
+import (
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
+)
+
+func TestDeclarativeClosureRejectsClearedCompositeEvidence(t *testing.T) {
+ tests := []struct {
+ name string
+ transition Transition
+ }{
+ {
+ name: "verified runtime source with cleared source identity",
+ transition: closureTransition(
+ knownSource(model.FacetRuntime, string(model.RuntimeVerified)),
+ literalAssignment("runtime", string(model.RuntimeVerified)),
+ literalAssignment("runtime_source", ""),
+ ),
+ },
+ {
+ name: "verified configuration source with cleared fingerprint",
+ transition: closureTransition(
+ knownSource(model.FacetConfiguration, string(model.ConfigurationVerified)),
+ literalAssignment("configuration", string(model.ConfigurationVerified)),
+ literalAssignment("config_fingerprint", ""),
+ ),
+ },
+ {
+ name: "managed workspace source with cleared source reference",
+ transition: closureTransition(
+ knownSource(model.FacetWorkspace, string(model.WorkspaceActive)),
+ literalAssignment("workspace", string(model.WorkspaceActive)),
+ literalAssignment("workspace_source_ref", ""),
+ ),
+ },
+ {
+ name: "recovery source with cleared cause",
+ transition: closureTransition(
+ knownSource(model.FacetRecovery, string(model.RecoveryResumable)),
+ literalAssignment("recovery", string(model.RecoveryEscalated)),
+ literalAssignment("recovery_cause", ""),
+ ),
+ },
+ {
+ name: "transaction source with cleared identity",
+ transition: closureTransition(
+ knownSource(model.FacetTransaction, string(model.TransactionStaged)),
+ literalAssignment("transaction", string(model.TransactionStaged)),
+ literalAssignment("transaction_id", ""),
+ ),
+ },
+ {
+ name: "established terminal with active resulting phase",
+ transition: closureTransition(
+ FacetCondition{},
+ literalAssignment("terminal", string(model.TerminalEstablished)),
+ literalAssignment("phase", string(model.PhaseActive)),
+ ),
+ },
+ {
+ name: "recovery phase with cleared recovery classification",
+ transition: closureTransition(
+ knownSource(model.FacetRecovery, string(model.RecoveryResumable)),
+ literalAssignment("phase", string(model.PhaseRecovery)),
+ literalAssignment("recovery", string(model.RecoveryNone)),
+ ),
+ },
+ {
+ name: "preserved phase outside declared targets",
+ transition: func() Transition {
+ value := closureTransition(FacetCondition{})
+ value.SourcePhases = []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}
+ value.TargetPhases = []model.ProtocolPhase{model.PhaseActive}
+ return value
+ }(),
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if err := validateDeclarativeStateClosure(test.transition, assignedFields(test.transition)); err == nil {
+ t.Fatal("composite durable evidence could be cleared by an admitted assignment")
+ }
+ })
+ }
+}
+
+func TestDeclarativeClosurePreservesValidCompositeEvidence(t *testing.T) {
+ tests := []Transition{
+ closureTransition(knownSource(model.FacetRuntime, string(model.RuntimeVerified)), literalAssignment("runtime", string(model.RuntimeVerified))),
+ closureTransition(knownSource(model.FacetConfiguration, string(model.ConfigurationVerified)), literalAssignment("configuration", string(model.ConfigurationVerified))),
+ closureTransition(knownSource(model.FacetWorkspace, string(model.WorkspaceActive)), literalAssignment("workspace", string(model.WorkspaceActive))),
+ closureTransition(knownSource(model.FacetRecovery, string(model.RecoveryResumable)), literalAssignment("recovery", string(model.RecoveryEscalated))),
+ closureTransition(knownSource(model.FacetTransaction, string(model.TransactionStaged)), literalAssignment("transaction", string(model.TransactionStaged))),
+ closureTransition(knownSource(model.FacetTerminal, string(model.TerminalEstablished)), literalAssignment("terminal", string(model.TerminalEstablished))),
+ }
+ tests[len(tests)-1].SourcePhases = []model.ProtocolPhase{model.PhaseTerminal}
+ tests[len(tests)-1].TargetPhases = []model.ProtocolPhase{model.PhaseTerminal}
+ for index, transition := range tests {
+ if err := validateDeclarativeStateClosure(transition, assignedFields(transition)); err != nil {
+ t.Fatalf("valid preserved composite evidence %d was rejected: %v", index, err)
+ }
+ }
+}
+
+func TestDeclarativeAssignmentsRejectApplyTimeOnlyValueConstraints(t *testing.T) {
+ tests := []StateAssignment{
+ {Facet: "recovery_source_phase", ValueFrom: StateValueReference{Parameter: "phase"}},
+ {Facet: "recovery_resumption", ValueFrom: StateValueReference{Parameter: "phase"}},
+ {Facet: "recovery_budget", ValueFrom: StateValueReference{Parameter: "budget"}},
+ literalAssignment("program_fingerprint", "short"),
+ {Facet: "program_fingerprint", ValueFrom: StateValueReference{Parameter: "fingerprint"}},
+ }
+ for _, assignment := range tests {
+ transition := Transition{ID: "test.transition"}
+ if err := validateDeterministicAssignment(transition, assignment); err == nil {
+ t.Fatalf("apply-time-only constraint for %q reached execution", assignment.Facet)
+ }
+ }
+
+ transition := Transition{ID: "test.transition"}
+ assignment := StateAssignment{Facet: "program_fingerprint", ValueFrom: StateValueReference{Admission: "expected_program_fingerprint"}}
+ if err := validateDeterministicAssignment(transition, assignment); err != nil {
+ t.Fatalf("admission-bound program fingerprint was rejected: %v", err)
+ }
+}
+
+func closureTransition(source FacetCondition, assignments ...StateAssignment) Transition {
+ transition := Transition{
+ ID: "test.transition", SourcePhases: []model.ProtocolPhase{model.PhaseActive}, TargetPhases: []model.ProtocolPhase{model.PhaseActive},
+ StateEffect: StateEffect{Kind: StateEffectAssignments, Assignments: assignments},
+ }
+ if source.Facet != "" {
+ transition.SourceConditions = []FacetCondition{source}
+ }
+ return transition
+}
+
+func knownSource(facet model.FacetName, values ...string) FacetCondition {
+ return FacetCondition{Facet: facet, Statuses: []model.FactStatus{model.FactKnown}, Values: values}
+}
+
+func literalAssignment(facet, value string) StateAssignment {
+ return StateAssignment{Facet: facet, Value: &value}
+}
+
+func assignedFields(transition Transition) map[string]bool {
+ result := make(map[string]bool, len(transition.StateEffect.Assignments))
+ for _, assignment := range transition.StateEffect.Assignments {
+ result[assignment.Facet] = true
+ }
+ return result
+}
diff --git a/boatstack/internal/softwaredelivery/catalog/state_facet.go b/boatstack/internal/softwaredelivery/catalog/state_facet.go
index 1399e23..3044c52 100644
--- a/boatstack/internal/softwaredelivery/catalog/state_facet.go
+++ b/boatstack/internal/softwaredelivery/catalog/state_facet.go
@@ -18,69 +18,150 @@ var allStateFacets = []model.StateFacet{
model.StateFacetProduct,
}
-var controlStateFacets = []model.StateFacet{model.StateFacetControl}
-var productStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}
-var installationStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}
-var installationProgramStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation, model.StateFacetProgram}
-var programStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetProgram}
+var declaredStateFieldFacets = map[string]model.StateFacet{
+ "program_fingerprint": model.StateFacetProgram,
+ "phase": model.StateFacetControl,
+ "engagement": model.StateFacetProduct,
+ "delivery": model.StateFacetProduct,
+ "workspace": model.StateFacetProduct,
+ "plan": model.StateFacetProduct,
+ "configuration": model.StateFacetControl,
+ "runtime": model.StateFacetInstallation,
+ "publication": model.StateFacetProduct,
+ "verification": model.StateFacetProduct,
+ "recovery": model.StateFacetControl,
+ "transaction": model.StateFacetControl,
+ "terminal": model.StateFacetProduct,
+ "source_revision": model.StateFacetProduct,
+ "worktree_fingerprint": model.StateFacetProduct,
+ "config_fingerprint": model.StateFacetControl,
+ "runtime_version": model.StateFacetInstallation,
+ "runtime_fingerprint": model.StateFacetInstallation,
+ "runtime_source": model.StateFacetInstallation,
+ "workspace_branch": model.StateFacetProduct,
+ "workspace_path": model.StateFacetProduct,
+ "workspace_base_ref": model.StateFacetProduct,
+ "workspace_source_path": model.StateFacetProduct,
+ "workspace_source_id": model.StateFacetProduct,
+ "workspace_source_ref": model.StateFacetProduct,
+ "transaction_id": model.StateFacetControl,
+ "transaction_transition": model.StateFacetControl,
+ "recovery_cause": model.StateFacetControl,
+ "recovery_source_phase": model.StateFacetControl,
+ "recovery_resumption": model.StateFacetControl,
+ "recovery_budget": model.StateFacetControl,
+}
+
+func DeclaredStateFieldFacet(field string) (model.StateFacet, bool) {
+ facet, ok := declaredStateFieldFacets[field]
+ return facet, ok
+}
+
+func ValidDeclaredStateLiteral(field, value string) bool {
+ switch field {
+ case "phase":
+ return model.ProtocolPhase(value).Valid()
+ case "engagement":
+ return model.EngagementState(value).Valid()
+ case "delivery":
+ return model.DeliveryState(value).Valid()
+ case "workspace":
+ return model.WorkspaceState(value).Valid()
+ case "plan":
+ return model.PlanState(value).Valid()
+ case "configuration":
+ return model.ConfigurationState(value).Valid()
+ case "runtime":
+ return model.RuntimeState(value).Valid()
+ case "publication":
+ return model.PublicationState(value).Valid()
+ case "verification":
+ return model.VerificationState(value).Valid()
+ case "recovery":
+ return model.RecoveryState(value).Valid()
+ case "transaction":
+ return model.TransactionState(value).Valid()
+ case "terminal":
+ return model.TerminalStatus(value).Valid()
+ case "recovery_source_phase", "recovery_resumption":
+ return value == "" || model.ProtocolPhase(value).Valid()
+ case "recovery_budget":
+ return value == "0"
+ default:
+ return true
+ }
+}
+
+// DeclaredStateResolverFacet projects a durable assignment field onto the
+// canonical resolver facet that can verify its postcondition.
+func DeclaredStateResolverFacet(field string) (model.FacetName, bool) {
+ switch field {
+ case "engagement":
+ return model.FacetEngagement, true
+ case "delivery":
+ return model.FacetDelivery, true
+ case "workspace":
+ return model.FacetWorkspace, true
+ case "plan":
+ return model.FacetPlan, true
+ case "configuration":
+ return model.FacetConfiguration, true
+ case "runtime":
+ return model.FacetRuntime, true
+ case "publication":
+ return model.FacetPublication, true
+ case "verification":
+ return model.FacetVerification, true
+ case "recovery":
+ return model.FacetRecovery, true
+ case "transaction":
+ return model.FacetTransaction, true
+ case "terminal":
+ return model.FacetTerminal, true
+ default:
+ return "", false
+ }
+}
-// DurableStateFacetPolicy is kernel-owned. A repository program's manifest
-// cannot grant itself access to installation or program durable state.
+// DurableStateFacetPolicy projects the domain declaration into the kernel's
+// OwnedFacets law. Repository-authored programs may own control and product
+// state, but cannot grant themselves installation or program state.
func DurableStateFacetPolicy(transition Transition) (StateFacetPolicy, error) {
if transition.Class == EventObservedExternal {
return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...)}, nil
}
- writes, known := durableStateWritesForID(transition.ID)
- if known {
- return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: writes}, nil
+ writes, err := model.NormalizeStateFacets(string(transition.ID)+".owned_facets", transition.OwnedFacets)
+ if err != nil || len(writes) == 0 {
+ return StateFacetPolicy{}, fmt.Errorf("%s: controllable transition requires valid owned facets: %v", transition.ID, err)
+ }
+ if !containsStateFacet(writes, model.StateFacetControl) {
+ return StateFacetPolicy{}, fmt.Errorf("%s: controllable transition must own %q durable state", transition.ID, model.StateFacetControl)
}
switch transition.Origin.Kind {
case OriginControlProgram:
- if transition.RuntimeExecution {
- return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: append([]model.StateFacet(nil), controlStateFacets...)}, nil
+ for _, facet := range writes {
+ if facet == model.StateFacetInstallation || facet == model.StateFacetProgram {
+ return StateFacetPolicy{}, fmt.Errorf("%s: control program cannot own %q durable state", transition.ID, facet)
+ }
}
- return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: append([]model.StateFacet(nil), productStateFacets...)}, nil
case OriginExtension:
- return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: append([]model.StateFacet(nil), controlStateFacets...)}, nil
+ for _, facet := range writes {
+ if facet != model.StateFacetControl {
+ return StateFacetPolicy{}, fmt.Errorf("%s: extension cannot own %q durable state", transition.ID, facet)
+ }
+ }
case OriginCoreSystem:
- return StateFacetPolicy{}, fmt.Errorf("core transition %q has no durable state facet policy", transition.ID)
default:
return StateFacetPolicy{}, fmt.Errorf("transition %q has no valid durable state facet policy origin", transition.ID)
}
+ return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: writes}, nil
}
-// DurableStateWritesForRecovery returns the write envelope for a transition
-// recorded in an interrupted journal. Unknown repository-defined transitions
-// fail closed to control bookkeeping only.
-func DurableStateWritesForRecovery(id TransitionID) []model.StateFacet {
- if writes, ok := durableStateWritesForID(id); ok {
- return writes
- }
- return append([]model.StateFacet(nil), controlStateFacets...)
-}
-
-func durableStateWritesForID(id TransitionID) ([]model.StateFacet, bool) {
- switch id {
- case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.update":
- return append([]model.StateFacet(nil), installationStateFacets...), true
- case "installation.initialize", "installation.reconcile-update":
- return append([]model.StateFacet(nil), installationProgramStateFacets...), true
- case "repository.attach", "catalog.reconcile":
- return append([]model.StateFacet(nil), programStateFacets...), true
- case "invocation.rebind", "configuration.initialize", "configuration.mutate", "configuration.reconcile", "recovery.escalate":
- return append([]model.StateFacet(nil), controlStateFacets...), true
- case "engagement.begin", "engagement.renew", "engagement.release", "repository.detach", "objective.bind",
- "plan.create", "plan.validate", "plan.approve", "plan.activate", "plan.amend", "plan.approve-amendment", "plan.invalidate", "plan.abandon",
- "workspace.cut", "workspace.sync", "workspace.activate", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon", "workspace.reconcile",
- "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record",
- "evidence.visual.attach", "evidence.approval.revoke", "delivery.slice.advance",
- "publication.preview", "publication.execute", "publication.observe", "publication.reconcile", "publication.correct", "publication.abandon":
- return append([]model.StateFacet(nil), productStateFacets...), true
- case "recovery.resume", "recovery.rollback":
- return append([]model.StateFacet(nil), controlStateFacets...), true
- case "external.files-changed", "external.head-changed", "external.branch-changed", "external.runtime-disappeared", "external.configuration-drifted", "external.lease-expired", "external.host-interrupted", "external.ci-completed", "external.pr-opened", "external.pr-updated", "external.pr-closed", "external.pr-merged", "external.provider-unavailable":
- return nil, true
- default:
- return nil, false
+func containsStateFacet(values []model.StateFacet, wanted model.StateFacet) bool {
+ for _, value := range values {
+ if value == wanted {
+ return true
+ }
}
+ return false
}
diff --git a/boatstack/internal/softwaredelivery/catalog/state_facet_test.go b/boatstack/internal/softwaredelivery/catalog/state_facet_test.go
index abb62b1..427d866 100644
--- a/boatstack/internal/softwaredelivery/catalog/state_facet_test.go
+++ b/boatstack/internal/softwaredelivery/catalog/state_facet_test.go
@@ -19,7 +19,7 @@ func TestKernelOwnsDurableStateFacetPolicies(t *testing.T) {
{"recovery.escalate", []model.StateFacet{model.StateFacetControl}},
}
for _, fixture := range fixtures {
- policy, err := DurableStateFacetPolicy(Transition{ID: fixture.id, Class: EventOwnedLocal, Origin: TransitionOrigin{Kind: OriginCoreSystem}})
+ policy, err := DurableStateFacetPolicy(Transition{ID: fixture.id, Class: EventOwnedLocal, Origin: TransitionOrigin{Kind: OriginCoreSystem}, OwnedFacets: fixture.writes})
if err != nil {
t.Fatalf("%s: %v", fixture.id, err)
}
@@ -35,13 +35,21 @@ func TestKernelOwnsDurableStateFacetPolicies(t *testing.T) {
func TestRepositoryProgramCannotSelfGrantInstallationFacet(t *testing.T) {
transition := Transition{
ID: "repository-program/advance", Class: EventOwnedLocal, RuntimeExecution: true,
- Origin: TransitionOrigin{Kind: OriginControlProgram, ID: "repository-program", Version: "1", ManifestFingerprint: "manifest"},
+ Origin: TransitionOrigin{Kind: OriginControlProgram, ID: "repository-program", Version: "1", ManifestFingerprint: "manifest"},
+ OwnedFacets: []model.StateFacet{model.StateFacetInstallation},
}
- policy, err := DurableStateFacetPolicy(transition)
- if err != nil {
- t.Fatal(err)
+ if policy, err := DurableStateFacetPolicy(transition); err == nil || slices.Contains(policy.Writes, model.StateFacetInstallation) {
+ t.Fatalf("repository program received installation ownership: %v / %v", policy.Writes, err)
}
- if !slices.Equal(policy.Writes, []model.StateFacet{model.StateFacetControl}) || slices.Contains(policy.Writes, model.StateFacetInstallation) {
- t.Fatalf("repository program received installation ownership: %v", policy.Writes)
+}
+
+func TestControllableTransitionCannotOmitControlFacet(t *testing.T) {
+ transition := Transition{
+ ID: "repository-program/advance", Class: EventOwnedLocal,
+ Origin: TransitionOrigin{Kind: OriginControlProgram, ID: "repository-program", Version: "1", ManifestFingerprint: "manifest"},
+ OwnedFacets: []model.StateFacet{model.StateFacetProduct},
+ }
+ if policy, err := DurableStateFacetPolicy(transition); err == nil || len(policy.Writes) != 0 {
+ t.Fatalf("product-only transition received a writable state envelope: %v / %v", policy.Writes, err)
}
}
diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go
index e7fe05e..507b538 100644
--- a/boatstack/internal/softwaredelivery/catalog/transition.go
+++ b/boatstack/internal/softwaredelivery/catalog/transition.go
@@ -165,6 +165,49 @@ type ParameterSpec struct {
Secret bool `json:"secret"`
}
+// StateEffectKind selects the software-delivery domain's durable-state
+// interpreter. It is domain ABI data; the general kernel continues to own
+// transition selection, objective scope, capabilities, and facet ownership.
+type StateEffectKind string
+
+const (
+ StateEffectAssignments StateEffectKind = "assignments"
+ StateEffectNative StateEffectKind = "native"
+)
+
+// StateValueReference binds an assignment to one admitted input. Exactly one
+// reference field or Assignment.Value must be present.
+type StateValueReference struct {
+ Parameter string `json:"parameter,omitempty"`
+ Admission string `json:"admission,omitempty"`
+ Invocation string `json:"invocation,omitempty"`
+}
+
+// StateAssignment assigns one closed durable-state field from a literal or
+// an admitted input. Value is a pointer so the empty string remains an
+// explicit literal rather than an omitted field.
+type StateAssignment struct {
+ Facet string `json:"facet"`
+ Value *string `json:"value,omitempty"`
+ ValueFrom StateValueReference `json:"value_from,omitempty"`
+}
+
+// StatePrecondition is an ANDed guard over the current durable snapshot. The
+// listed values are ORed.
+type StatePrecondition struct {
+ Facet string `json:"facet"`
+ Values []string `json:"values"`
+}
+
+// StateEffect is the domain-owned executable state declaration. Generic
+// assignments and native handlers are mutually exclusive.
+type StateEffect struct {
+ Kind StateEffectKind `json:"kind"`
+ Preconditions []StatePrecondition `json:"preconditions,omitempty"`
+ Assignments []StateAssignment `json:"assignments,omitempty"`
+ NativeHandler string `json:"native_handler,omitempty"`
+}
+
type InterruptionContract struct {
Points []string `json:"points"`
PartialState []string `json:"partial_state"`
@@ -253,6 +296,8 @@ type Transition struct {
RuntimeExecution bool `json:"-"`
RequiredEvidence []string `json:"required_evidence"`
OwnedResources []string `json:"owned_resources,omitempty"`
+ OwnedFacets []model.StateFacet `json:"owned_facets,omitempty"`
+ StateEffect StateEffect `json:"state_effect,omitempty"`
Effect EffectID `json:"effect,omitempty"`
LocalEffects []EffectID `json:"local_effects,omitempty"`
ExternalEffects []EffectID `json:"external_effects,omitempty"`
@@ -410,6 +455,9 @@ func validateTransition(t Transition) error {
if _, err := DurableStateFacetPolicy(t); err != nil {
return err
}
+ if err := validateStateEffect(t); err != nil {
+ return err
+ }
if !t.SelectionClass.Valid() {
return fmt.Errorf("%s: invalid selection class %q", t.ID, t.SelectionClass)
}
@@ -636,6 +684,18 @@ func cloneTransition(value Transition) Transition {
value.DeclaredCapabilities = append([]Capability(nil), value.DeclaredCapabilities...)
value.RequiredEvidence = append([]string(nil), value.RequiredEvidence...)
value.OwnedResources = append([]string(nil), value.OwnedResources...)
+ value.OwnedFacets = append([]model.StateFacet(nil), value.OwnedFacets...)
+ value.StateEffect.Preconditions = append([]StatePrecondition(nil), value.StateEffect.Preconditions...)
+ for index := range value.StateEffect.Preconditions {
+ value.StateEffect.Preconditions[index].Values = append([]string(nil), value.StateEffect.Preconditions[index].Values...)
+ }
+ value.StateEffect.Assignments = append([]StateAssignment(nil), value.StateEffect.Assignments...)
+ for index := range value.StateEffect.Assignments {
+ if literal := value.StateEffect.Assignments[index].Value; literal != nil {
+ copy := *literal
+ value.StateEffect.Assignments[index].Value = ©
+ }
+ }
value.LocalEffects = append([]EffectID(nil), value.LocalEffects...)
value.ExternalEffects = append([]EffectID(nil), value.ExternalEffects...)
value.Parameters = append([]ParameterSpec(nil), value.Parameters...)
@@ -648,6 +708,172 @@ func cloneTransition(value Transition) Transition {
return value
}
+func validateStateEffect(t Transition) error {
+ if !t.Controllable() {
+ if t.StateEffect.Kind != "" || len(t.OwnedFacets) != 0 {
+ return fmt.Errorf("%s: observed external transition cannot declare a durable state effect", t.ID)
+ }
+ return nil
+ }
+ switch t.StateEffect.Kind {
+ case StateEffectAssignments:
+ if t.StateEffect.NativeHandler != "" {
+ return fmt.Errorf("%s: assignment state effect cannot name a native handler", t.ID)
+ }
+ case StateEffectNative:
+ if t.StateEffect.NativeHandler == "" || !semanticID.MatchString(t.StateEffect.NativeHandler) || len(t.StateEffect.Assignments) != 0 {
+ return fmt.Errorf("%s: native state effect requires one semantic handler and no assignments", t.ID)
+ }
+ if t.RuntimeExecution {
+ return fmt.Errorf("%s: component runtime transition cannot invoke a host-native state handler", t.ID)
+ }
+ if err := validateNativeStateHandler(t); err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("%s: controllable transition requires a declared state effect", t.ID)
+ }
+ owned := map[model.StateFacet]bool{}
+ for _, facet := range t.OwnedFacets {
+ owned[facet] = true
+ }
+ parameters := map[string]ParameterSpec{}
+ for _, parameter := range t.Parameters {
+ parameters[parameter.Name] = parameter
+ }
+ preconditions := map[string]bool{}
+ for _, condition := range t.StateEffect.Preconditions {
+ _, supported := DeclaredStateFieldFacet(condition.Facet)
+ if !supported || len(condition.Values) == 0 || preconditions[condition.Facet] {
+ return fmt.Errorf("%s: state-effect preconditions require unique facets and values", t.ID)
+ }
+ preconditions[condition.Facet] = true
+ values := map[string]bool{}
+ for _, value := range condition.Values {
+ if values[value] || !ValidDeclaredStateLiteral(condition.Facet, value) {
+ return fmt.Errorf("%s: state-effect precondition %q has invalid or duplicate value %q", t.ID, condition.Facet, value)
+ }
+ values[value] = true
+ }
+ if !statePreconditionImpliedBySource(t, condition) {
+ return fmt.Errorf("%s: state-effect precondition %q is not implied by resolver source conditions", t.ID, condition.Facet)
+ }
+ }
+ assignments := map[string]bool{}
+ for _, assignment := range t.StateEffect.Assignments {
+ facet, supported := DeclaredStateFieldFacet(assignment.Facet)
+ if !supported || assignments[assignment.Facet] {
+ return fmt.Errorf("%s: state-effect assignments require unique facets", t.ID)
+ }
+ if !owned[facet] {
+ return fmt.Errorf("%s: state-effect assignment %q exceeds owned facet %q", t.ID, assignment.Facet, facet)
+ }
+ assignments[assignment.Facet] = true
+ sources := 0
+ if assignment.Value != nil {
+ sources++
+ }
+ for _, source := range []string{assignment.ValueFrom.Parameter, assignment.ValueFrom.Admission, assignment.ValueFrom.Invocation} {
+ if source != "" {
+ sources++
+ }
+ }
+ if sources != 1 {
+ return fmt.Errorf("%s: state-effect assignment %q requires exactly one value source", t.ID, assignment.Facet)
+ }
+ if assignment.Value != nil && !ValidDeclaredStateLiteral(assignment.Facet, *assignment.Value) {
+ return fmt.Errorf("%s: state-effect assignment %q has invalid literal %q", t.ID, assignment.Facet, *assignment.Value)
+ }
+ if err := validateDeterministicAssignment(t, assignment); err != nil {
+ return err
+ }
+ if !stateAssignmentMatchesTarget(t, assignment) {
+ return fmt.Errorf("%s: state-effect assignment %q is not compatible with resolver target conditions", t.ID, assignment.Facet)
+ }
+ if parameter := assignment.ValueFrom.Parameter; parameter != "" {
+ spec, declared := parameters[parameter]
+ if !declared {
+ return fmt.Errorf("%s: state-effect assignment %q references undeclared parameter %q", t.ID, assignment.Facet, parameter)
+ }
+ if !spec.Required {
+ return fmt.Errorf("%s: state-effect assignment %q references optional parameter %q", t.ID, assignment.Facet, parameter)
+ }
+ }
+ if source := assignment.ValueFrom.Admission; source != "" && source != "source_revision" && source != "worktree_fingerprint" && source != "expected_program_fingerprint" {
+ return fmt.Errorf("%s: state-effect assignment %q references unknown admission value %q", t.ID, assignment.Facet, source)
+ }
+ if source := assignment.ValueFrom.Invocation; source != "" && source != "invoking_path" && source != "worktree_id" && source != "ref" {
+ return fmt.Errorf("%s: state-effect assignment %q references unknown invocation value %q", t.ID, assignment.Facet, source)
+ }
+ }
+ if err := validateDeclarativeStateClosure(t, assignments); err != nil {
+ return err
+ }
+ return nil
+}
+
+func statePreconditionImpliedBySource(t Transition, condition StatePrecondition) bool {
+ allowed := make(map[string]bool, len(condition.Values))
+ for _, value := range condition.Values {
+ allowed[value] = true
+ }
+ if condition.Facet == "phase" {
+ for _, phase := range t.SourcePhases {
+ if !allowed[string(phase)] {
+ return false
+ }
+ }
+ return true
+ }
+ facet, ok := DeclaredStateResolverFacet(condition.Facet)
+ if !ok {
+ return false
+ }
+ for _, source := range t.SourceConditions {
+ if source.Facet != facet || len(source.Statuses) != 1 || source.Statuses[0] != model.FactKnown || len(source.Values) == 0 {
+ continue
+ }
+ for _, value := range source.Values {
+ if !allowed[value] {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
+func stateAssignmentMatchesTarget(t Transition, assignment StateAssignment) bool {
+ if assignment.Facet == "phase" {
+ return assignment.Value != nil && t.DeclaresTargetPhase(model.ProtocolPhase(*assignment.Value))
+ }
+ facet, stateFacet := DeclaredStateResolverFacet(assignment.Facet)
+ if !stateFacet {
+ return true
+ }
+ if assignment.Value == nil {
+ return false
+ }
+ for _, target := range t.TargetConditions {
+ if target.Facet != facet {
+ continue
+ }
+ if len(target.Statuses) != 1 || target.Statuses[0] != model.FactKnown {
+ return false
+ }
+ if len(target.Values) == 0 {
+ return true
+ }
+ for _, value := range target.Values {
+ if value == *assignment.Value {
+ return true
+ }
+ }
+ return false
+ }
+ return true
+}
+
func cloneConditions(values []FacetCondition) []FacetCondition {
result := make([]FacetCondition, len(values))
for index, value := range values {
diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go
index bc821e0..95e4368 100644
--- a/boatstack/internal/softwaredelivery/effects/driver.go
+++ b/boatstack/internal/softwaredelivery/effects/driver.go
@@ -106,6 +106,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans
if err := verifyWorkspaceBranchParameter(state, admission, transition.ID); err != nil {
return nil, err
}
+ if err := d.verifyClearedWorkspaceDestination(ctx, state, admission, transition); err != nil {
+ return nil, err
+ }
if err := verifyRuntimeParameters(admission, transition); err != nil {
return nil, err
}
@@ -330,6 +333,34 @@ func prepareConfigurationAuthorityTransfer(layout ports.ControllerLayout, admiss
return []ports.ResourceMutation{mutation}, nil
}
+func (d Driver) verifyClearedWorkspaceDestination(ctx context.Context, state durable.State, admission protocol.Admission, transition catalog.Transition) error {
+ clearsWorkspace := false
+ for _, condition := range transition.TargetConditions {
+ if condition.Facet != model.FacetWorkspace {
+ continue
+ }
+ for _, value := range condition.Values {
+ if value == string(model.WorkspaceAbsent) {
+ clearsWorkspace = true
+ break
+ }
+ }
+ }
+ if !clearsWorkspace {
+ return nil
+ }
+ destination, err := d.resolver.ResolveInvocation(ctx, state.WorkspacePath, admission.Invocation.Host, admission.Invocation.Correlation)
+ if err != nil {
+ return fmt.Errorf("resolve workspace destination before clearing it: %w", err)
+ }
+ if destination.RepositoryID != admission.Invocation.RepositoryID || destination.GitCommonID != admission.Invocation.GitCommonID ||
+ destination.WorktreeID != admission.Invocation.WorktreeID || destination.Ref != admission.Invocation.Ref ||
+ destination.ControllerID != admission.Invocation.ControllerID || destination.Topology != admission.Invocation.Topology {
+ return fmt.Errorf("workspace destination identity changed; refusing workspace removal")
+ }
+ return nil
+}
+
func verifyWorkspaceBranchParameter(state durable.State, admission protocol.Admission, id catalog.TransitionID) error {
switch id {
case "workspace.sync", "workspace.activate", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon":
diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go
index 44e0731..1c677fa 100644
--- a/boatstack/internal/softwaredelivery/effects/integration_test.go
+++ b/boatstack/internal/softwaredelivery/effects/integration_test.go
@@ -191,6 +191,82 @@ func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) {
}
}
+func TestDeclaredStateEffectAppliesAndReceiptsWithoutTransitionDispatch(t *testing.T) {
+ // control-law: a valid repository-authored state declaration does not require a Go reducer case
+ ctx := context.Background()
+ repository := testRepository(t)
+ clock := fixedClock{value: time.Unix(1100, 0).UTC()}
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "declared-state")
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := testprogram.StandardRegistry()
+ transitions := base.All()
+ synthetic, declared := base.Lookup("fixture.declared-state")
+ if !declared {
+ var ok bool
+ synthetic, ok = base.Lookup("invocation.rebind")
+ if !ok {
+ t.Fatal("missing invocation.rebind template")
+ }
+ synthetic.ID = "fixture.declared-state"
+ synthetic.Effect = catalog.EffectID(synthetic.ID)
+ synthetic.LocalEffects = []catalog.EffectID{synthetic.Effect}
+ synthetic.Prescription.Operation = string(synthetic.ID)
+ synthetic.Authority = []catalog.AuthorityClass{catalog.AuthorityHuman}
+ synthetic.StateEffect = catalog.StateEffect{Kind: catalog.StateEffectAssignments, Assignments: []catalog.StateAssignment{
+ {Facet: "phase", Value: pointer(string(model.PhaseObserved))},
+ }}
+ transitions = append(transitions, synthetic)
+ }
+ registry, err := catalog.New(transitions)
+ if err != nil {
+ t.Fatal(err)
+ }
+ observer, _ := plant.NewObserver(resolver, clock)
+ locker, _ := effects.NewLocker(resolver)
+ journal, _ := effects.NewJournal(resolver, clock)
+ receipts, _ := effects.NewReceiptStore(resolver, clock)
+ driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary())
+ kernel, err := engine.New(registry, testObjectiveContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ objective := model.Objective{ID: "declared-state-objective", Kind: model.ObjectiveVerified, DeliveryID: "declared-state-delivery"}
+ human := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{
+ ID: "declared-state-human", Class: catalog.AuthorityHuman, Subject: invocation.RepositoryID, Fingerprint: "explicit-human",
+ IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour),
+ }}}
+ attach := engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: objective, Authority: human, Requested: "repository.attach"},
+ FlowID: "declared-state-flow", Parameters: protocol.Parameters{{Name: "topology", Value: string(model.TopologyDetached)}, {Name: "config_authority", Value: "repository"}}, AdmissionLifetime: time.Minute,
+ }
+ if _, err := kernel.Apply(ctx, prescribeEngine(t, ctx, kernel, attach)); err != nil {
+ t.Fatal(err)
+ }
+ invocation, err = resolver.ResolveInvocation(ctx, repository, "cli", "declared-state-apply")
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: objective, Authority: human, Requested: synthetic.ID},
+ FlowID: "declared-state-flow", AdmissionLifetime: time.Minute,
+ }
+ result, err := kernel.Apply(ctx, prescribeEngine(t, ctx, kernel, request))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Receipt.TransitionID != synthetic.ID || result.Target.Phase.Value != model.PhaseObserved {
+ t.Fatalf("declared transition result = %#v", result)
+ }
+}
+
+func pointer(value string) *string { return &value }
+
func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing.T) {
// control-law: detached-config-authority-selects-the-real-reader-and-writer
ctx := context.Background()
diff --git a/boatstack/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go
index 4c32560..f64d8cf 100644
--- a/boatstack/internal/softwaredelivery/effects/journal.go
+++ b/boatstack/internal/softwaredelivery/effects/journal.go
@@ -33,18 +33,19 @@ func NewJournal(resolver ports.InvocationResolver, clock ports.Clock) (*Journal,
}
type journalRecord struct {
- SchemaVersion int `json:"schema_version"`
- Admission protocol.Admission `json:"admission"`
- TransitionID catalog.TransitionID `json:"transition_id"`
- TransitionClass catalog.EventClass `json:"transition_class"`
- ReconcilesProgram bool `json:"reconciles_program,omitempty"`
- Status string `json:"status"`
- Mutations []ports.ResourceMutation `json:"mutations,omitempty"`
- Reason string `json:"reason,omitempty"`
- ReceiptID string `json:"receipt_id,omitempty"`
- Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ SchemaVersion int `json:"schema_version"`
+ Admission protocol.Admission `json:"admission"`
+ TransitionID catalog.TransitionID `json:"transition_id"`
+ TransitionClass catalog.EventClass `json:"transition_class"`
+ AllowedStateFacets []model.StateFacet `json:"allowed_state_facets"`
+ ReconcilesProgram bool `json:"reconciles_program,omitempty"`
+ Status string `json:"status"`
+ Mutations []ports.ResourceMutation `json:"mutations,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ ReceiptID string `json:"receipt_id,omitempty"`
+ Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
func journalName(id, suffix string) (string, error) {
@@ -77,7 +78,11 @@ func (j *Journal) Begin(ctx context.Context, admission protocol.Admission, trans
return statErr
}
now := j.clock.Now().UTC()
- record := journalRecord{SchemaVersion: protocol.JournalSchemaVersion, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, ReconcilesProgram: transition.Policy.ReconcilesProgram, Status: "begun", CreatedAt: now, UpdatedAt: now}
+ policy, err := catalog.DurableStateFacetPolicy(transition)
+ if err != nil {
+ return err
+ }
+ record := journalRecord{SchemaVersion: protocol.JournalSchemaVersion, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, AllowedStateFacets: policy.Writes, ReconcilesProgram: transition.Policy.ReconcilesProgram, Status: "begun", CreatedAt: now, UpdatedAt: now}
raw, err := encodeJSON(record)
if err != nil {
return err
@@ -114,6 +119,10 @@ func readJournal(path string) (journalRecord, error) {
if err := record.Admission.ValidateIdentity(); err != nil || record.Admission.TransitionID != record.TransitionID {
return journalRecord{}, fmt.Errorf("invalid transaction admission in %s: %v", path, err)
}
+ allowed, err := model.NormalizeStateFacets("journal allowed state facets", record.AllowedStateFacets)
+ if err != nil || len(allowed) == 0 || !slices.Equal(allowed, record.AllowedStateFacets) {
+ return journalRecord{}, fmt.Errorf("invalid allowed state facets in %s: %v", path, err)
+ }
for _, mutation := range record.Mutations {
facets, err := model.NormalizeStateFacets("journal mutation state facets", mutation.StateFacets)
if err != nil || !slices.Equal(facets, mutation.StateFacets) {
diff --git a/boatstack/internal/softwaredelivery/effects/journal_schema_test.go b/boatstack/internal/softwaredelivery/effects/journal_schema_test.go
new file mode 100644
index 0000000..0b86489
--- /dev/null
+++ b/boatstack/internal/softwaredelivery/effects/journal_schema_test.go
@@ -0,0 +1,27 @@
+package effects
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestPriorJournalSchemaRequiresExplicitReset(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "adm-prior.pending")
+ raw := []byte(`{"schema_version":7,"admission":{"id":"adm-prior"},"transition_id":"plan.create","transition_class":"owned-local","status":"begun"}`)
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := readJournal(path); err == nil || !strings.Contains(err.Error(), "invalid transaction journal") {
+ t.Fatalf("read prior journal schema error = %v, want explicit invalid journal refusal", err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(raw) {
+ t.Fatalf("prior journal changed during refusal:\n got %s\nwant %s", got, raw)
+ }
+}
diff --git a/boatstack/internal/softwaredelivery/effects/recovery.go b/boatstack/internal/softwaredelivery/effects/recovery.go
index da6357c..f8f5074 100644
--- a/boatstack/internal/softwaredelivery/effects/recovery.go
+++ b/boatstack/internal/softwaredelivery/effects/recovery.go
@@ -147,7 +147,7 @@ func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID, in
if err != nil {
return nil, err
}
- allowed := model.UnionStateFacets(catalog.DurableStateWritesForRecovery(record.TransitionID), []model.StateFacet{model.StateFacetControl})
+ allowed := model.UnionStateFacets(record.AllowedStateFacets, []model.StateFacet{model.StateFacetControl})
if _, err := validateAllowedStateFacets(recovery, staged, allowed); err != nil {
return nil, err
}
diff --git a/boatstack/internal/softwaredelivery/effects/revision.go b/boatstack/internal/softwaredelivery/effects/revision.go
index dfc3e57..d5b2408 100644
--- a/boatstack/internal/softwaredelivery/effects/revision.go
+++ b/boatstack/internal/softwaredelivery/effects/revision.go
@@ -45,14 +45,16 @@ func BindStateRevision(ctx context.Context, prepared ports.PreparedEffect, resol
return nil, fmt.Errorf("compiled control program changed before revision binding")
}
before := state
+ if state.ProgramFingerprint == "" {
+ state.ProgramFingerprint = admission.ExpectedProgramFingerprint
+ }
+ if err := applyStateTransition(&state, admission, transition); err != nil {
+ return nil, err
+ }
state.Revision, err = durable.NextRevision(state.Revision)
if err != nil {
return nil, err
}
- if state.ProgramFingerprint == "" {
- state.ProgramFingerprint = admission.ExpectedProgramFingerprint
- }
- state.LastTransition = transition.ID
state.UpdatedAt = clock.Now().UTC()
raw, err := durable.EncodeState(state)
if err != nil {
diff --git a/boatstack/internal/softwaredelivery/effects/state_facet_test.go b/boatstack/internal/softwaredelivery/effects/state_facet_test.go
index 2767cbf..df563e7 100644
--- a/boatstack/internal/softwaredelivery/effects/state_facet_test.go
+++ b/boatstack/internal/softwaredelivery/effects/state_facet_test.go
@@ -20,7 +20,20 @@ func ownershipState() durable.State {
}
func transitionFixture(id catalog.TransitionID, origin catalog.OriginKind, runtime bool) catalog.Transition {
- return catalog.Transition{ID: id, Class: catalog.EventOwnedLocal, RuntimeExecution: runtime, Origin: catalog.TransitionOrigin{Kind: origin, ID: "fixture", Version: "1", ManifestFingerprint: "manifest"}}
+ owned := []model.StateFacet{model.StateFacetControl}
+ switch id {
+ case "installation.update":
+ owned = append(owned, model.StateFacetInstallation)
+ case "catalog.reconcile":
+ owned = append(owned, model.StateFacetProgram)
+ case "objective.bind":
+ owned = append(owned, model.StateFacetProduct)
+ }
+ return catalog.Transition{
+ ID: id, Class: catalog.EventOwnedLocal, RuntimeExecution: runtime,
+ Origin: catalog.TransitionOrigin{Kind: origin, ID: "fixture", Version: "1", ManifestFingerprint: "manifest"},
+ OwnedFacets: owned, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectAssignments},
+ }
}
func requireOwnedChange(t *testing.T, transition catalog.Transition, before, after durable.State, wantError bool) {
@@ -93,7 +106,7 @@ func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) {
after.Objective = model.Objective{ID: "invented", Kind: model.ObjectiveApprovedPlan, DeliveryID: "invented"}
prior, _ := durable.EncodeState(before)
target, _ := durable.EncodeState(after)
- record := journalRecord{TransitionID: "installation.update", Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target, StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}}}}
+ record := journalRecord{TransitionID: "installation.update", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target, StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}}}}
if _, err := recoveryStateFacets(record, "recovery.resume", model.InvocationContext{}, nil); err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION") {
t.Fatalf("recovery accepted product contamination: %v", err)
}
@@ -102,7 +115,7 @@ func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) {
func TestRecoveryRefusesUnclassifiedDurableMutation(t *testing.T) {
state := ownershipState()
raw, _ := durable.EncodeState(state)
- record := journalRecord{TransitionID: "installation.update", Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: raw, Target: raw}}}
+ record := journalRecord{TransitionID: "installation.update", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: raw, Target: raw}}}
if _, err := recoveryStateFacets(record, "recovery.resume", model.InvocationContext{}, nil); err == nil || !strings.Contains(err.Error(), "STATE_FACET_UNCLASSIFIED") {
t.Fatalf("recovery accepted an unclassified state mutation: %v", err)
}
@@ -118,7 +131,7 @@ func TestRecoveryReportsActualStateDeltaInsteadOfInterruptedEnvelope(t *testing.
staged.UpdatedAt = time.Unix(101, 0).UTC()
prior, _ := durable.EncodeState(before)
target, _ := durable.EncodeState(staged)
- record := journalRecord{TransitionID: "plan.create", Mutations: []ports.ResourceMutation{{
+ record := journalRecord{TransitionID: "plan.create", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, Mutations: []ports.ResourceMutation{{
Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target,
StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct},
}}}
diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go
index d96075e..2cd6a09 100644
--- a/boatstack/internal/softwaredelivery/effects/state_reducer.go
+++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go
@@ -18,148 +18,391 @@ func applyStateTransition(state *durable.State, admission protocol.Admission, tr
if !configured && admission.Objective.Validate() == nil {
return fmt.Errorf("transition %q cannot create product intent from verified absence", transition.ID)
}
- } else if transition.ID == "objective.bind" {
- state.Objective = admission.Objective
- configured = true
} else {
- if configured && state.Objective != admission.Objective {
+ if configured && state.Objective != admission.Objective && transition.StateEffect.NativeHandler != "objective-bind" {
return fmt.Errorf("transition %q cannot replace configured objective; use objective.bind", transition.ID)
}
}
state.LastTransition = transition.ID
- if transition.ID == "objective.bind" {
- state.Terminal = model.TerminalNonterminal
- }
- switch transition.ID {
- case "engagement.begin":
- state.Phase, state.Engagement = model.PhaseObserved, model.EngagementCommand
- case "engagement.renew":
- state.Phase, state.Engagement = model.PhaseActive, model.EngagementActive
- case "engagement.release":
- state.Phase, state.Engagement = model.PhaseDormant, model.EngagementDormant
- case "invocation.rebind", "repository.attach":
- state.Phase = model.PhaseObserved
- case "repository.detach":
- state.Phase, state.Engagement = model.PhaseDormant, model.EngagementDormant
- case "runtime.hydrate", "runtime.replace":
- state.Runtime = model.RuntimeVerified
- state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
- state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
- state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
- state.Phase = settledPhase(*state)
- case "installation.update":
- state.Runtime = model.RuntimeVerified
- state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
- state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
- state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
- case "installation.reconcile-update":
- accepted, _ := admission.Parameters.Get("accept_obligation_change")
- if accepted != "true" || admission.PriorProgramFingerprint == "" || admission.ProgramDeltaFingerprint == "" || state.ProgramFingerprint != admission.PriorProgramFingerprint {
- return fmt.Errorf("reconciled installation update must bind and explicitly accept the exact prior-to-candidate program delta")
+ if err := applyDeclaredStateEffect(state, admission, transition); err != nil {
+ return err
+ }
+ if !transition.DeclaresTargetPhase(state.Phase) {
+ return fmt.Errorf("transition %q reducer produced undeclared target phase %s", transition.ID, state.Phase)
+ }
+ return nil
+}
+
+func applyDeclaredStateEffect(state *durable.State, admission protocol.Admission, transition catalog.Transition) error {
+ for _, condition := range transition.StateEffect.Preconditions {
+ current, err := stateFacetValue(*state, condition.Facet)
+ if err != nil {
+ return fmt.Errorf("transition %q state precondition: %w", transition.ID, err)
}
- state.ProgramFingerprint = admission.ExpectedProgramFingerprint
- state.Runtime = model.RuntimeVerified
- state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
- state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
- state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
- case "runtime.reconcile":
- state.Runtime, state.Recovery, state.Transaction = model.RuntimeVerified, model.RecoveryNone, model.TransactionNone
- state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
- state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
- state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
- clearRecoveryContext(state)
- state.Phase = settledPhase(*state)
- case "configuration.initialize", "configuration.mutate":
- state.Configuration = model.ConfigurationVerified
- state.ConfigFingerprint, _ = admission.Parameters.Get("config_sha256")
- state.Phase = settledPhase(*state)
- case "configuration.reconcile":
- state.Configuration, state.Recovery, state.Transaction = model.ConfigurationVerified, model.RecoveryNone, model.TransactionNone
- clearRecoveryContext(state)
- state.Phase = settledPhase(*state)
- case "catalog.reconcile":
- prior, _ := admission.Parameters.Get("prior_program_fingerprint")
- accepted, _ := admission.Parameters.Get("accept_obligation_change")
- if prior == "" || prior != state.ProgramFingerprint || accepted != "true" {
- return fmt.Errorf("catalog reconciliation must bind the prior program and explicitly accept obligation changes")
+ matched := false
+ for _, value := range condition.Values {
+ if current == value {
+ matched = true
+ break
+ }
}
- state.ProgramFingerprint = admission.ExpectedProgramFingerprint
- case "installation.initialize":
- state.Runtime, state.Configuration = model.RuntimeVerified, model.ConfigurationVerified
- state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
- state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
- state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
- state.ConfigFingerprint, _ = admission.Parameters.Get("config_sha256")
- state.Phase = model.PhaseObserved
- case "objective.bind":
- wasActive := state.Phase == model.PhaseActive
- kind, _ := admission.Parameters.Get("objective_kind")
- delivery, _ := admission.Parameters.Get("delivery_id")
- if kind != string(admission.Objective.Kind) || delivery != admission.Objective.DeliveryID {
- return fmt.Errorf("objective parameters do not match admitted objective")
+ if !matched {
+ return fmt.Errorf("transition %q state precondition %q rejected value %q", transition.ID, condition.Facet, current)
}
- state.Phase = model.PhaseObserved
- if state.Recovery == model.RecoveryEscalated {
- state.Phase = model.PhaseFrontier
- } else if wasActive {
- state.Phase = model.PhaseActive
+ }
+ switch transition.StateEffect.Kind {
+ case catalog.StateEffectAssignments:
+ for _, assignment := range transition.StateEffect.Assignments {
+ value, err := assignmentValue(admission, assignment)
+ if err != nil {
+ return fmt.Errorf("transition %q state assignment %q: %w", transition.ID, assignment.Facet, err)
+ }
+ if err := assignStateFacet(state, assignment.Facet, value); err != nil {
+ return fmt.Errorf("transition %q state assignment: %w", transition.ID, err)
+ }
}
- case "plan.create":
- state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanDraft, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal
- case "plan.validate":
- state.Plan, state.Phase, state.Terminal = model.PlanValid, model.PhaseActive, model.TerminalNonterminal
- case "plan.approve":
- state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive
- if admission.Objective.Kind == model.ObjectiveApprovedPlan {
- establishTerminal(state, model.PhaseTerminal)
+ return nil
+ case catalog.StateEffectNative:
+ handler, ok := nativeStateHandlers[transition.StateEffect.NativeHandler]
+ if !ok {
+ return fmt.Errorf("transition %q declares unknown native state handler %q", transition.ID, transition.StateEffect.NativeHandler)
}
- case "plan.activate":
- state.Plan, state.Delivery, state.Phase = model.PlanLocked, model.DeliveryActive, model.PhaseActive
- case "plan.amend":
- state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanAmendmentRequired, model.DeliveryAmendment, model.PhaseActive, model.TerminalNonterminal
- case "plan.approve-amendment":
- state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive
- case "plan.invalidate":
- state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanInvalid, model.DeliveryInvalid, model.PhaseFrontier, model.TerminalNonterminal
- case "plan.abandon", "publication.abandon":
- state.Delivery = model.DeliveryDiscarded
- if state.Workspace != model.WorkspaceAbsent {
- state.Workspace = model.WorkspaceAbandoned
+ return handler(state, admission, transition)
+ default:
+ return fmt.Errorf("transition %q has no valid declared state effect", transition.ID)
+ }
+}
+
+type nativeStateHandler func(*durable.State, protocol.Admission, catalog.Transition) error
+
+var nativeStateHandlers = map[string]nativeStateHandler{
+ "runtime-verified-settled": applyRuntimeVerifiedSettled,
+ "runtime-reconcile": applyRuntimeReconcile,
+ "configuration-verified-settled": applyConfigurationVerifiedSettled,
+ "configuration-reconcile": applyConfigurationReconcile,
+ "installation-initialize": applyInstallationInitialize,
+ "installation-reconcile-update": applyInstallationReconcileUpdate,
+ "catalog-reconcile": applyCatalogReconcile,
+ "objective-bind": applyObjectiveBind,
+ "plan-approve": applyPlanApprove,
+ "abandon-delivery": applyAbandonDelivery,
+ "workspace-cleanup": applyWorkspaceCleanup,
+ "workspace-reap": applyWorkspaceReap,
+ "workspace-reconcile": applyWorkspaceReconcile,
+ "gate-build-record": gateStateHandler("build"),
+ "gate-test-record": gateStateHandler("test"),
+ "gate-review-record": gateStateHandler("review"),
+ "gate-change-record": gateStateHandler("change"),
+ "gate-journey-record": gateStateHandler("journey"),
+ "visual-evidence-attach": applyVisualEvidence,
+ "publication-observe": applyPublicationObservation,
+}
+
+func assignmentValue(admission protocol.Admission, assignment catalog.StateAssignment) (string, error) {
+ if assignment.Value != nil {
+ return *assignment.Value, nil
+ }
+ if assignment.ValueFrom.Parameter != "" {
+ value, ok := admission.Parameters.Get(assignment.ValueFrom.Parameter)
+ if !ok {
+ return "", fmt.Errorf("parameter %q is absent", assignment.ValueFrom.Parameter)
}
- state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
- clearRecoveryContext(state)
- establishTerminal(state, model.PhaseAbandoned)
- case "workspace.abandon":
- state.Delivery, state.Workspace = model.DeliveryDiscarded, model.WorkspaceAbandoned
- state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
- clearRecoveryContext(state)
- establishTerminal(state, model.PhaseAbandoned)
- case "workspace.cut":
- state.Workspace, state.Phase = model.WorkspaceCut, model.PhaseActive
- state.WorkspaceBranch, _ = admission.Parameters.Get("branch")
- state.WorkspaceBaseRef, _ = admission.Parameters.Get("base_ref")
- state.WorkspacePath, _ = admission.Parameters.Get("destination")
- state.WorkspaceSourcePath = admission.Invocation.InvokingPath
- state.WorkspaceSourceID = admission.Invocation.WorktreeID
- state.WorkspaceSourceRef = admission.Invocation.Ref
- case "workspace.sync", "workspace.activate":
- state.Workspace, state.Phase = model.WorkspaceActive, model.PhaseActive
- case "workspace.publish":
- state.Workspace, state.Phase = model.WorkspacePublished, model.PhaseActive
- case "workspace.cleanup":
- if state.Workspace != model.WorkspaceLanded && state.Workspace != model.WorkspaceAbandoned {
- return fmt.Errorf("workspace cleanup requires landed or explicitly abandoned state")
+ return value, nil
+ }
+ switch assignment.ValueFrom.Admission {
+ case "source_revision":
+ return admission.SourceRevision, nil
+ case "worktree_fingerprint":
+ return admission.WorktreeFingerprint, nil
+ case "expected_program_fingerprint":
+ return admission.ExpectedProgramFingerprint, nil
+ case "":
+ default:
+ return "", fmt.Errorf("admission value %q is unknown", assignment.ValueFrom.Admission)
+ }
+ switch assignment.ValueFrom.Invocation {
+ case "invoking_path":
+ return admission.Invocation.InvokingPath, nil
+ case "worktree_id":
+ return admission.Invocation.WorktreeID, nil
+ case "ref":
+ return admission.Invocation.Ref, nil
+ case "":
+ default:
+ return "", fmt.Errorf("invocation value %q is unknown", assignment.ValueFrom.Invocation)
+ }
+ return "", fmt.Errorf("value source is absent")
+}
+
+func stateFacetValue(state durable.State, facet string) (string, error) {
+ switch facet {
+ case "program_fingerprint":
+ return state.ProgramFingerprint, nil
+ case "phase":
+ return string(state.Phase), nil
+ case "engagement":
+ return string(state.Engagement), nil
+ case "delivery":
+ return string(state.Delivery), nil
+ case "workspace":
+ return string(state.Workspace), nil
+ case "plan":
+ return string(state.Plan), nil
+ case "configuration":
+ return string(state.Configuration), nil
+ case "runtime":
+ return string(state.Runtime), nil
+ case "publication":
+ return string(state.Publication), nil
+ case "verification":
+ return string(state.Verification), nil
+ case "recovery":
+ return string(state.Recovery), nil
+ case "transaction":
+ return string(state.Transaction), nil
+ case "terminal":
+ return string(state.Terminal), nil
+ case "source_revision":
+ return state.SourceRevision, nil
+ case "worktree_fingerprint":
+ return state.WorktreeFingerprint, nil
+ case "config_fingerprint":
+ return state.ConfigFingerprint, nil
+ case "runtime_version":
+ return state.RuntimeVersion, nil
+ case "runtime_fingerprint":
+ return state.RuntimeFingerprint, nil
+ case "runtime_source":
+ return state.RuntimeSource, nil
+ case "workspace_branch":
+ return state.WorkspaceBranch, nil
+ case "workspace_path":
+ return state.WorkspacePath, nil
+ case "workspace_base_ref":
+ return state.WorkspaceBaseRef, nil
+ case "workspace_source_path":
+ return state.WorkspaceSourcePath, nil
+ case "workspace_source_id":
+ return state.WorkspaceSourceID, nil
+ case "workspace_source_ref":
+ return state.WorkspaceSourceRef, nil
+ case "transaction_id":
+ return state.TransactionID, nil
+ case "transaction_transition":
+ return state.TransactionTransition, nil
+ case "recovery_cause":
+ return state.RecoveryCause, nil
+ case "recovery_source_phase":
+ return string(state.RecoverySourcePhase), nil
+ case "recovery_resumption":
+ return string(state.RecoveryResumption), nil
+ case "recovery_budget":
+ return fmt.Sprintf("%d", state.RecoveryBudget), nil
+ default:
+ return "", fmt.Errorf("durable state facet %q is unknown", facet)
+ }
+}
+
+func assignStateFacet(state *durable.State, facet, value string) error {
+ switch facet {
+ case "program_fingerprint":
+ state.ProgramFingerprint = value
+ case "phase":
+ state.Phase = model.ProtocolPhase(value)
+ case "engagement":
+ state.Engagement = model.EngagementState(value)
+ case "delivery":
+ state.Delivery = model.DeliveryState(value)
+ case "workspace":
+ state.Workspace = model.WorkspaceState(value)
+ case "plan":
+ state.Plan = model.PlanState(value)
+ case "configuration":
+ state.Configuration = model.ConfigurationState(value)
+ case "runtime":
+ state.Runtime = model.RuntimeState(value)
+ case "publication":
+ state.Publication = model.PublicationState(value)
+ case "verification":
+ state.Verification = model.VerificationState(value)
+ case "recovery":
+ state.Recovery = model.RecoveryState(value)
+ case "transaction":
+ state.Transaction = model.TransactionState(value)
+ case "terminal":
+ state.Terminal = model.TerminalStatus(value)
+ case "source_revision":
+ state.SourceRevision = value
+ case "worktree_fingerprint":
+ state.WorktreeFingerprint = value
+ case "config_fingerprint":
+ state.ConfigFingerprint = value
+ case "runtime_version":
+ state.RuntimeVersion = value
+ case "runtime_fingerprint":
+ state.RuntimeFingerprint = value
+ case "runtime_source":
+ state.RuntimeSource = value
+ case "workspace_branch":
+ state.WorkspaceBranch = value
+ case "workspace_path":
+ state.WorkspacePath = value
+ case "workspace_base_ref":
+ state.WorkspaceBaseRef = value
+ case "workspace_source_path":
+ state.WorkspaceSourcePath = value
+ case "workspace_source_id":
+ state.WorkspaceSourceID = value
+ case "workspace_source_ref":
+ state.WorkspaceSourceRef = value
+ case "transaction_id":
+ state.TransactionID = value
+ case "transaction_transition":
+ state.TransactionTransition = value
+ case "recovery_cause":
+ state.RecoveryCause = value
+ case "recovery_source_phase":
+ state.RecoverySourcePhase = model.ProtocolPhase(value)
+ case "recovery_resumption":
+ state.RecoveryResumption = model.ProtocolPhase(value)
+ case "recovery_budget":
+ if value != "0" {
+ return fmt.Errorf("recovery_budget currently accepts only the fail-closed zero literal")
}
- state.Workspace = model.WorkspaceAbsent
- state.Phase = terminalPhase(*state)
- case "workspace.reap":
- state.Workspace = model.WorkspaceAbsent
- state.Phase = terminalPhase(*state)
- case "workspace.reconcile":
- state.Recovery, state.Transaction, state.Phase = model.RecoveryNone, model.TransactionNone, engagedPhase(*state)
- clearRecoveryContext(state)
- case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record":
- gate, _ := standardGateName(transition.ID)
+ state.RecoveryBudget = 0
+ default:
+ return fmt.Errorf("durable state facet %q is unknown", facet)
+ }
+ return nil
+}
+
+func applyRuntimeVerifiedSettled(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ state.Runtime = model.RuntimeVerified
+ state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
+ state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
+ state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
+ state.Phase = settledPhase(*state)
+ return nil
+}
+
+func applyRuntimeReconcile(state *durable.State, admission protocol.Admission, transition catalog.Transition) error {
+ if err := applyRuntimeVerifiedSettled(state, admission, transition); err != nil {
+ return err
+ }
+ state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
+ clearRecoveryContext(state)
+ state.Phase = settledPhase(*state)
+ return nil
+}
+
+func applyConfigurationVerifiedSettled(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ state.Configuration = model.ConfigurationVerified
+ state.ConfigFingerprint, _ = admission.Parameters.Get("config_sha256")
+ state.Phase = settledPhase(*state)
+ return nil
+}
+
+func applyConfigurationReconcile(state *durable.State, _ protocol.Admission, _ catalog.Transition) error {
+ state.Configuration, state.Recovery, state.Transaction = model.ConfigurationVerified, model.RecoveryNone, model.TransactionNone
+ clearRecoveryContext(state)
+ state.Phase = settledPhase(*state)
+ return nil
+}
+
+func applyInstallationInitialize(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ state.Runtime = model.RuntimeVerified
+ state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
+ state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
+ state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
+ state.Configuration = model.ConfigurationVerified
+ state.ConfigFingerprint, _ = admission.Parameters.Get("config_sha256")
+ state.Phase = model.PhaseObserved
+ return nil
+}
+
+func applyInstallationReconcileUpdate(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ accepted, _ := admission.Parameters.Get("accept_obligation_change")
+ if accepted != "true" || admission.PriorProgramFingerprint == "" || admission.ProgramDeltaFingerprint == "" || state.ProgramFingerprint != admission.PriorProgramFingerprint {
+ return fmt.Errorf("reconciled installation update must bind and explicitly accept the exact prior-to-candidate program delta")
+ }
+ state.ProgramFingerprint = admission.ExpectedProgramFingerprint
+ state.Runtime = model.RuntimeVerified
+ state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version")
+ state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
+ state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
+ return nil
+}
+
+func applyCatalogReconcile(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ prior, _ := admission.Parameters.Get("prior_program_fingerprint")
+ accepted, _ := admission.Parameters.Get("accept_obligation_change")
+ if prior == "" || prior != state.ProgramFingerprint || accepted != "true" {
+ return fmt.Errorf("catalog reconciliation must bind the prior program and explicitly accept obligation changes")
+ }
+ state.ProgramFingerprint = admission.ExpectedProgramFingerprint
+ return nil
+}
+
+func applyObjectiveBind(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ wasActive := state.Phase == model.PhaseActive
+ kind, _ := admission.Parameters.Get("objective_kind")
+ delivery, _ := admission.Parameters.Get("delivery_id")
+ if kind != string(admission.Objective.Kind) || delivery != admission.Objective.DeliveryID {
+ return fmt.Errorf("objective parameters do not match admitted objective")
+ }
+ state.Objective = admission.Objective
+ state.Terminal = model.TerminalNonterminal
+ state.Phase = model.PhaseObserved
+ if state.Recovery == model.RecoveryEscalated {
+ state.Phase = model.PhaseFrontier
+ } else if wasActive {
+ state.Phase = model.PhaseActive
+ }
+ return nil
+}
+
+func applyPlanApprove(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive
+ if admission.Objective.Kind == model.ObjectiveApprovedPlan {
+ establishTerminal(state, model.PhaseTerminal)
+ }
+ return nil
+}
+
+func applyAbandonDelivery(state *durable.State, _ protocol.Admission, _ catalog.Transition) error {
+ state.Delivery = model.DeliveryDiscarded
+ if state.Workspace != model.WorkspaceAbsent {
+ state.Workspace = model.WorkspaceAbandoned
+ }
+ state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
+ clearRecoveryContext(state)
+ establishTerminal(state, model.PhaseAbandoned)
+ return nil
+}
+
+func applyWorkspaceCleanup(state *durable.State, _ protocol.Admission, _ catalog.Transition) error {
+ if state.Workspace != model.WorkspaceLanded && state.Workspace != model.WorkspaceAbandoned {
+ return fmt.Errorf("workspace cleanup requires landed or explicitly abandoned state")
+ }
+ state.Workspace = model.WorkspaceAbsent
+ state.Phase = terminalPhase(*state)
+ return nil
+}
+
+func applyWorkspaceReap(state *durable.State, _ protocol.Admission, _ catalog.Transition) error {
+ state.Workspace = model.WorkspaceAbsent
+ state.Phase = terminalPhase(*state)
+ return nil
+}
+
+func applyWorkspaceReconcile(state *durable.State, _ protocol.Admission, _ catalog.Transition) error {
+ state.Recovery, state.Transaction, state.Phase = model.RecoveryNone, model.TransactionNone, engagedPhase(*state)
+ clearRecoveryContext(state)
+ return nil
+}
+
+func gateStateHandler(gate string) nativeStateHandler {
+ return func(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
revision, _ := admission.Parameters.Get("source_revision")
fingerprint, _ := admission.Parameters.Get("evidence_fingerprint")
upsertGate(state, durable.GateEvidence{Gate: gate, Revision: revision, Fingerprint: fingerprint})
@@ -170,67 +413,41 @@ func applyStateTransition(state *durable.State, admission protocol.Admission, tr
state.Delivery = model.DeliveryGatesPassed
establishTerminal(state, model.PhaseTerminal)
}
- case "evidence.visual.attach":
- revision, _ := admission.Parameters.Get("source_revision")
- fingerprint, _ := admission.Parameters.Get("privacy_receipt")
- upsertGate(state, durable.GateEvidence{Gate: "visual", Revision: revision, Fingerprint: fingerprint})
- state.SourceRevision, state.WorktreeFingerprint = admission.SourceRevision, admission.WorktreeFingerprint
- state.Terminal = model.TerminalNonterminal
- if admission.Objective.Kind == model.ObjectiveVerified {
- state.Delivery = model.DeliveryActive
- }
- state.Verification, state.Phase = model.VerificationCurrent, model.PhaseActive
- if verifiedObjectiveSatisfied(*state, admission.Objective) {
- state.Delivery = model.DeliveryGatesPassed
- establishTerminal(state, model.PhaseTerminal)
- }
- case "evidence.approval.revoke":
- state.Plan, state.Phase, state.Terminal = model.PlanValid, model.PhaseFrontier, model.TerminalNonterminal
- case "delivery.slice.advance":
- state.Delivery, state.Phase = model.DeliveryActive, model.PhaseActive
- state.SourceRevision, state.WorktreeFingerprint = admission.SourceRevision, admission.WorktreeFingerprint
- case "publication.preview":
- state.Publication, state.Phase = model.PublicationCandidate, model.PhaseActive
- case "publication.execute":
- state.Publication, state.Workspace, state.Delivery, state.Phase = model.PublicationPublishedNotLanded, model.WorkspacePublished, model.DeliveryPublished, model.PhaseActive
- case "publication.observe", "publication.reconcile":
- state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
- clearRecoveryContext(state)
- if state.Publication == model.PublicationUnavailable || state.Publication == model.PublicationConflicting {
- state.Phase = model.PhaseUnresolved
- } else if state.Publication == model.PublicationClosedUnmerged {
- state.Phase = model.PhaseFrontier
- } else if admission.Objective.Kind == model.ObjectiveOpenPR && state.Publication == model.PublicationOpen {
- establishTerminal(state, model.PhaseTerminal)
- } else if admission.Objective.Kind == model.ObjectiveMerged && state.Publication == model.PublicationMerged {
- state.Workspace, state.Delivery = model.WorkspaceLanded, model.DeliveryTerminal
- establishTerminal(state, model.PhaseTerminal)
- } else {
- state.Phase = model.PhaseActive
- }
- case "publication.correct":
- state.Publication, state.Delivery = model.PublicationPublishedNotLanded, model.DeliveryPublished
- state.Terminal, state.Phase = model.TerminalNonterminal, model.PhaseActive
- case "recovery.resume":
- state.Recovery, state.Transaction, state.Delivery, state.Phase = model.RecoveryNone, model.TransactionNone, model.DeliveryActive, model.PhaseActive
- clearRecoveryContext(state)
- case "recovery.rollback":
- state.Recovery, state.Transaction, state.Phase = model.RecoveryNone, model.TransactionNone, model.PhaseObserved
- clearRecoveryContext(state)
- case "recovery.escalate":
- state.Recovery, state.Phase = model.RecoveryEscalated, model.PhaseFrontier
- state.Transaction = model.TransactionNone
- state.TransactionID, _ = admission.Parameters.Get("transaction_id")
- state.TransactionTransition = "recovery.escalate"
- state.RecoveryCause = "recovery requires explicit external resolution"
- state.RecoverySourcePhase = model.PhaseRecovery
- state.RecoveryResumption = model.PhaseFrontier
- state.RecoveryBudget = 0
- default:
- return fmt.Errorf("transition %q has no V2 state reducer", transition.ID)
+ return nil
}
- if !transition.DeclaresTargetPhase(state.Phase) {
- return fmt.Errorf("transition %q reducer produced undeclared target phase %s", transition.ID, state.Phase)
+}
+
+func applyVisualEvidence(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ revision, _ := admission.Parameters.Get("source_revision")
+ fingerprint, _ := admission.Parameters.Get("privacy_receipt")
+ upsertGate(state, durable.GateEvidence{Gate: "visual", Revision: revision, Fingerprint: fingerprint})
+ state.SourceRevision, state.WorktreeFingerprint = admission.SourceRevision, admission.WorktreeFingerprint
+ state.Terminal = model.TerminalNonterminal
+ if admission.Objective.Kind == model.ObjectiveVerified {
+ state.Delivery = model.DeliveryActive
+ }
+ state.Verification, state.Phase = model.VerificationCurrent, model.PhaseActive
+ if verifiedObjectiveSatisfied(*state, admission.Objective) {
+ state.Delivery = model.DeliveryGatesPassed
+ establishTerminal(state, model.PhaseTerminal)
+ }
+ return nil
+}
+
+func applyPublicationObservation(state *durable.State, admission protocol.Admission, _ catalog.Transition) error {
+ state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
+ clearRecoveryContext(state)
+ if state.Publication == model.PublicationUnavailable || state.Publication == model.PublicationConflicting {
+ state.Phase = model.PhaseUnresolved
+ } else if state.Publication == model.PublicationClosedUnmerged {
+ state.Phase = model.PhaseFrontier
+ } else if admission.Objective.Kind == model.ObjectiveOpenPR && state.Publication == model.PublicationOpen {
+ establishTerminal(state, model.PhaseTerminal)
+ } else if admission.Objective.Kind == model.ObjectiveMerged && state.Publication == model.PublicationMerged {
+ state.Workspace, state.Delivery = model.WorkspaceLanded, model.DeliveryTerminal
+ establishTerminal(state, model.PhaseTerminal)
+ } else {
+ state.Phase = model.PhaseActive
}
return nil
}
diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go
index 4fab182..f14d603 100644
--- a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go
+++ b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go
@@ -1,7 +1,9 @@
package effects
import (
+ "strings"
"testing"
+ "time"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable"
@@ -130,3 +132,48 @@ func TestEscalatedRecoveryCanOnlyBeReconfiguredTowardExplicitAbandonment(t *test
t.Fatalf("explicit abandonment did not close recovery: %#v", state)
}
}
+
+func TestDeclaredAssignmentReducesUnknownTransitionWithoutGoDispatch(t *testing.T) {
+ state := durable.Default(model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree"}, testTime())
+ transition := catalog.Transition{
+ ID: "fixture.declared-state", TargetPhases: []model.ProtocolPhase{model.PhaseObserved},
+ Policy: catalog.PolicyContract{ObjectiveScope: catalog.ObjectiveScopeOptionalPreserve},
+ StateEffect: catalog.StateEffect{Kind: catalog.StateEffectAssignments, Assignments: []catalog.StateAssignment{
+ {Facet: "phase", Value: stringPointer(string(model.PhaseObserved))},
+ }},
+ }
+ if err := applyStateTransition(&state, protocol.Admission{}, transition); err != nil {
+ t.Fatal(err)
+ }
+ if state.Phase != model.PhaseObserved || state.LastTransition != transition.ID {
+ t.Fatalf("declared state effect did not run: %#v", state)
+ }
+}
+
+func TestDeclaredAssignmentRefusesMissingAdmittedParameter(t *testing.T) {
+ // control-law: assignment-parameter-sources-are-total-before-effect-preparation
+ state := ownershipState()
+ transition := transitionFixture("fixture.parameter-assignment", catalog.OriginControlProgram, true)
+ transition.TargetPhases = []model.ProtocolPhase{model.PhaseActive}
+ transition.StateEffect = catalog.StateEffect{Kind: catalog.StateEffectAssignments, Assignments: []catalog.StateAssignment{
+ {Facet: "phase", ValueFrom: catalog.StateValueReference{Parameter: "required_phase"}},
+ }}
+ if err := applyStateTransition(&state, protocol.Admission{Objective: state.Objective}, transition); err == nil || !strings.Contains(err.Error(), "parameter \"required_phase\" is absent") {
+ t.Fatalf("missing assignment parameter was not refused: %v", err)
+ }
+}
+
+func TestStandardNativeStateHandlersAreRegistered(t *testing.T) {
+ for _, transition := range testprogram.StandardRegistry().All() {
+ if transition.StateEffect.Kind != catalog.StateEffectNative {
+ continue
+ }
+ if _, ok := nativeStateHandlers[transition.StateEffect.NativeHandler]; !ok {
+ t.Errorf("transition %s names unregistered native state handler %q", transition.ID, transition.StateEffect.NativeHandler)
+ }
+ }
+}
+
+func stringPointer(value string) *string { return &value }
+
+func testTime() time.Time { return time.Unix(100, 0).UTC() }
diff --git a/boatstack/internal/softwaredelivery/effects/workspace_identity_test.go b/boatstack/internal/softwaredelivery/effects/workspace_identity_test.go
new file mode 100644
index 0000000..22c37d6
--- /dev/null
+++ b/boatstack/internal/softwaredelivery/effects/workspace_identity_test.go
@@ -0,0 +1,101 @@
+package effects
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
+ "github.com/operatorstack/boatstack/boatstack/internal/testprogram"
+)
+
+type workspaceIdentityBoundary struct{ calls int }
+
+func (b *workspaceIdentityBoundary) PrepareObservation(context.Context, protocol.Admission, catalog.Transition, ports.ControllerLayout, *durable.State) error {
+ b.calls++
+ return nil
+}
+
+func (b *workspaceIdentityBoundary) Execute(context.Context, protocol.Admission, catalog.Transition, ports.ControllerLayout, durable.State) (ports.EffectResult, error) {
+ b.calls++
+ return ports.EffectResult{Settlement: ports.EffectSettled}, nil
+}
+
+func TestWorkspaceRemovalTransitionsRefuseForgedDurableDestinationBeforeEffect(t *testing.T) {
+ // control-law: destructive workspace effects rebind durable paths to the admitted worktree identity
+ ctx := context.Background()
+ repository := recoveryRepository(t)
+ other := filepath.Join(t.TempDir(), "other-worktree")
+ command := exec.Command("git", "worktree", "add", "-q", "-b", "other-worktree", other)
+ command.Dir = repository
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("create other worktree: %v: %s", err, output)
+ }
+
+ clock := recoveryClock{value: time.Unix(4000, 0).UTC()}
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "forged-workspace-destination")
+ if err != nil {
+ t.Fatal(err)
+ }
+ layout, _, err := resolver.ResolveLayout(ctx, invocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ branch := strings.TrimPrefix(invocation.Ref, "refs/heads/")
+ state := durable.Default(invocation, clock.Now())
+ state.ProgramFingerprint = testProgramFingerprint
+ state.Phase, state.Engagement, state.Delivery = model.PhaseAbandoned, model.EngagementActive, model.DeliveryDiscarded
+ state.Workspace, state.Terminal = model.WorkspaceAbandoned, model.TerminalEstablished
+ state.WorkspacePath, state.WorkspaceBranch = other, branch
+ state.WorkspaceSourcePath, state.WorkspaceSourceID, state.WorkspaceSourceRef = repository, invocation.WorktreeID, invocation.Ref
+ raw, err := durable.EncodeState(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, transitionID := range []catalog.TransitionID{"workspace.cleanup", "workspace.reap"} {
+ t.Run(string(transitionID), func(t *testing.T) {
+ transition, ok := testprogram.StandardRegistry().Lookup(transitionID)
+ if !ok {
+ t.Fatalf("%s is absent", transitionID)
+ }
+ boundary := &workspaceIdentityBoundary{}
+ driver, err := NewDriver(resolver, clock, boundary)
+ if err != nil {
+ t.Fatal(err)
+ }
+ admission := protocol.Admission{
+ TransitionID: transition.ID, ExpectedStateRevision: state.Revision, ExpectedProgramFingerprint: testProgramFingerprint,
+ Invocation: invocation, Parameters: protocol.Parameters{{Name: "branch", Value: branch}}, EffectiveCapabilities: catalog.RequiredCapabilities(transition),
+ }
+ if _, err := driver.Prepare(ctx, admission, transition); err == nil || !strings.Contains(err.Error(), "workspace destination identity changed") {
+ t.Fatalf("forged workspace destination error = %v", err)
+ }
+ if boundary.calls != 0 {
+ t.Fatalf("forged workspace destination reached effect boundary %d times", boundary.calls)
+ }
+ })
+ }
+ if _, err := os.Stat(other); err != nil {
+ t.Fatalf("other worktree was removed: %v", err)
+ }
+}
diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go
index c4349ba..d11370c 100644
--- a/boatstack/internal/softwaredelivery/engine/engine_test.go
+++ b/boatstack/internal/softwaredelivery/engine/engine_test.go
@@ -234,11 +234,14 @@ func testRegistryWithAdvanceClass(t *testing.T, class catalog.EventClass) catalo
localEffects = nil
externalEffects = []catalog.EffectID{"test.advance"}
}
+ activePhase := string(model.PhaseActive)
+ frontierPhase := string(model.PhaseFrontier)
+ escalatedRecovery := string(model.RecoveryEscalated)
r, err := catalog.New([]catalog.Transition{{
ID: "test.advance", Version: 1, Class: class,
Origin: catalog.TransitionOrigin{Kind: catalog.OriginControlProgram, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: syntheticProgramFingerprint}, Owner: "test.synthetic", SelectionClass: catalog.SelectionProgramProgress,
SourcePhases: []model.ProtocolPhase{model.PhaseObserved}, TargetPhases: []model.ProtocolPhase{model.PhaseActive},
- RequiredIdentity: identity, Authority: authority, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.advance", LocalEffects: localEffects, ExternalEffects: externalEffects, Idempotent: true,
+ RequiredIdentity: identity, Authority: authority, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, OwnedFacets: []model.StateFacet{model.StateFacetControl}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectAssignments, Assignments: []catalog.StateAssignment{{Facet: "phase", Value: &activePhase}}}, Effect: "test.advance", LocalEffects: localEffects, ExternalEffects: externalEffects, Idempotent: true,
Prescription: catalog.Prescription{Operation: "test.advance", ExpectedPostcondition: "active"}, SourcePredicate: "observed", AdmissionPredicate: "exact-admission", TargetPredicate: "active", Verifier: "fresh-active",
SourceConditions: []catalog.FacetCondition{{Facet: model.FacetName("test.synthetic.stage"), Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"start"}}},
TargetConditions: []catalog.FacetCondition{{Facet: model.FacetName("test.synthetic.stage"), Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"terminal"}}},
@@ -248,7 +251,7 @@ func testRegistryWithAdvanceClass(t *testing.T, class catalog.EventClass) catalo
ID: "test.recover", Version: 1, Class: catalog.EventRecovery,
Origin: catalog.TransitionOrigin{Kind: catalog.OriginControlProgram, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: syntheticProgramFingerprint}, Owner: "test.synthetic", SelectionClass: catalog.SelectionProgramRecovery,
SourcePhases: []model.ProtocolPhase{model.PhaseRecovery}, TargetPhases: []model.ProtocolPhase{model.PhaseFrontier},
- RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.recover", LocalEffects: []catalog.EffectID{"test.recover"}, Idempotent: true,
+ RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, OwnedFacets: []model.StateFacet{model.StateFacetControl}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectAssignments, Assignments: []catalog.StateAssignment{{Facet: "phase", Value: &frontierPhase}, {Facet: "recovery", Value: &escalatedRecovery}}}, Effect: "test.recover", LocalEffects: []catalog.EffectID{"test.recover"}, Idempotent: true,
Prescription: catalog.Prescription{Operation: "test.recover", ExpectedPostcondition: "frontier"}, SourcePredicate: "recovery", AdmissionPredicate: "exact-recovery-admission", TargetPredicate: "frontier", Verifier: "fresh-frontier",
SourceConditions: []catalog.FacetCondition{{Facet: model.FacetRecovery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.RecoveryReconcile)}}},
TargetConditions: []catalog.FacetCondition{{Facet: model.FacetRecovery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.RecoveryEscalated)}}},
diff --git a/boatstack/internal/softwaredelivery/protocol/journal.go b/boatstack/internal/softwaredelivery/protocol/journal.go
index 4931b64..261a231 100644
--- a/boatstack/internal/softwaredelivery/protocol/journal.go
+++ b/boatstack/internal/softwaredelivery/protocol/journal.go
@@ -2,4 +2,4 @@ package protocol
// JournalSchemaVersion identifies the transaction record that embeds an exact
// prescription-bound admission.
-const JournalSchemaVersion = 7
+const JournalSchemaVersion = 8
diff --git a/boatstack/internal/softwaredelivery/supervisor/guard_test.go b/boatstack/internal/softwaredelivery/supervisor/guard_test.go
index 15b7fb8..d6eef9c 100644
--- a/boatstack/internal/softwaredelivery/supervisor/guard_test.go
+++ b/boatstack/internal/softwaredelivery/supervisor/guard_test.go
@@ -46,7 +46,8 @@ func syntheticManagedTransition(id catalog.TransitionID, class catalog.EventClas
ObjectiveKinds: []model.ObjectiveKind{model.ObjectiveVerified}, RequiredIdentity: []string{"repository-id"},
Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite},
DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, RequiredEvidence: []string{"snapshot"},
- OwnedResources: []string{"test.synthetic.state"}, Effect: catalog.EffectID(id), LocalEffects: []catalog.EffectID{catalog.EffectID(id)}, Idempotent: true,
+ OwnedResources: []string{"test.synthetic.state"}, OwnedFacets: []model.StateFacet{model.StateFacetControl}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectAssignments},
+ Effect: catalog.EffectID(id), LocalEffects: []catalog.EffectID{catalog.EffectID(id)}, Idempotent: true,
Prescription: catalog.Prescription{Operation: string(id), ExpectedPostcondition: "synthetic-target"},
SourcePredicate: "synthetic-source", SourceConditions: []catalog.FacetCondition{{Facet: model.FacetProgram, Statuses: []model.FactStatus{model.FactKnown}}},
AdmissionPredicate: "exact-admission", TargetPredicate: "synthetic-target",
diff --git a/boatstack/program_effects_test.go b/boatstack/program_effects_test.go
new file mode 100644
index 0000000..3a0efbc
--- /dev/null
+++ b/boatstack/program_effects_test.go
@@ -0,0 +1,254 @@
+package boatstack
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/core"
+ "github.com/operatorstack/boatstack/boatstack/delivery"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/engine"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
+ "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor"
+)
+
+type protocolStateRuntime struct{}
+
+func (protocolStateRuntime) ProgramRuntime() delivery.ProgramRuntime { return protocolStateRuntime{} }
+
+func (protocolStateRuntime) InvokeProgram(_ context.Context, request delivery.ProgramRuntimeRequest) (delivery.ProgramRuntimeResponse, error) {
+ response := delivery.ProgramRuntimeResponse{
+ ProtocolVersion: delivery.ProgramRuntimeProtocolVersion,
+ Operation: request.Operation,
+ ProgramID: request.ProgramID,
+ ProgramVersion: request.ProgramVersion,
+ CorrelationID: request.CorrelationID,
+ }
+ if request.Operation == delivery.ProgramPlanLocalEffectOperation || request.Operation == delivery.ProgramRecoverOperation {
+ content := []byte("protocol-state\n")
+ digest := sha256.Sum256(content)
+ response.Writes = []delivery.ResourceWrite{{
+ Resource: "fixture.state.resource",
+ Path: filepath.Join(request.RepositoryRoot, ".boatstack", "flows", request.ProgramID, "state.json"),
+ Content: content,
+ SHA256: hex.EncodeToString(digest[:]),
+ }}
+ }
+ return response, nil
+}
+
+func (protocolStateRuntime) RuntimeManifest(context.Context) (delivery.ProgramRuntimeManifest, error) {
+ const (
+ programID = "fixture.state"
+ resource = "fixture.state.resource"
+ )
+ published := string(model.DeliveryPublished)
+ noneRecovery, noneTransaction, active := string(model.RecoveryNone), string(model.TransactionNone), string(model.PhaseActive)
+ interruption := func(recovery delivery.TransitionID) delivery.InterruptionContract {
+ return delivery.InterruptionContract{
+ Points: []string{"after-effect"}, PartialState: []string{"declared-state"}, Detection: "fresh-observation",
+ ResumeContract: "resume", RollbackContract: "rollback", CompensationContract: "not-required",
+ Recovery: recovery, RecoveryAuthority: "repository-policy", ResumptionPredicate: "fresh-state",
+ }
+ }
+ recoverID := delivery.TransitionID(programID + ".recover")
+ publishID := delivery.TransitionID(programID + ".publish")
+ recoverEffect, publishEffect := delivery.EffectID(recoverID), delivery.EffectID(publishID)
+ recover := delivery.Transition{
+ ID: recoverID, Version: 1, SelectionClass: delivery.SelectionProgramRecovery, Class: delivery.EventRecovery,
+ SourcePhases: []delivery.ProtocolPhase{delivery.PhaseRecovery}, TargetPhases: []delivery.ProtocolPhase{delivery.PhaseActive},
+ RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id"}, Authority: []delivery.AuthorityClass{delivery.AuthorityRepository},
+ RequiredCapabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot"},
+ OwnedResources: []string{resource}, OwnedFacets: []delivery.StateFacet{delivery.StateFacetControl},
+ StateEffect: delivery.StateEffect{Kind: delivery.StateEffectAssignments, Assignments: []delivery.StateAssignment{
+ {Facet: "phase", Value: &active}, {Facet: "recovery", Value: &noneRecovery}, {Facet: "transaction", Value: &noneTransaction},
+ }},
+ Effect: recoverEffect, LocalEffects: []delivery.EffectID{recoverEffect}, Idempotent: true,
+ Prescription: delivery.Prescription{Operation: string(recoverID), ExpectedPostcondition: "active"},
+ SourcePredicate: "recovery-required", SourceConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetRecovery, string(model.RecoveryReconcile))},
+ AdmissionPredicate: "exact-admission", TargetPredicate: "active", TargetConditions: []delivery.FacetCondition{
+ delivery.KnownCondition(delivery.FacetRecovery, string(model.RecoveryNone)), delivery.KnownCondition(delivery.FacetTransaction, string(model.TransactionNone)),
+ },
+ Verifier: "fixture.state.recovered", Interruption: interruption(recoverID), Reversibility: delivery.Reversible, TerminalEffect: "none",
+ PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "local",
+ Policy: delivery.PolicyContract{ObjectiveScope: delivery.ObjectiveScopeOptionalPreserve}, Priority: 1,
+ }
+ publish := delivery.Transition{
+ ID: publishID, Version: 1, SelectionClass: delivery.SelectionProgramProgress, Class: delivery.EventOwnedLocal,
+ SourcePhases: []delivery.ProtocolPhase{delivery.PhaseActive}, TargetPhases: []delivery.ProtocolPhase{delivery.PhaseActive},
+ ObjectiveKinds: []delivery.ObjectiveKind{delivery.ObjectiveVerified}, RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id"},
+ Authority: []delivery.AuthorityClass{delivery.AuthorityHuman, delivery.AuthorityRepository},
+ RequiredCapabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot", "objective"},
+ OwnedResources: []string{resource}, OwnedFacets: []delivery.StateFacet{delivery.StateFacetControl, delivery.StateFacetProduct},
+ StateEffect: delivery.StateEffect{Kind: delivery.StateEffectAssignments, Assignments: []delivery.StateAssignment{{Facet: "delivery", Value: &published}}},
+ Effect: publishEffect, LocalEffects: []delivery.EffectID{publishEffect}, Idempotent: true,
+ Prescription: delivery.Prescription{Operation: string(publishID), ExpectedPostcondition: "published"},
+ SourcePredicate: "active", SourceConditions: []delivery.FacetCondition{
+ delivery.KnownCondition(delivery.FacetProgram, string(model.ProgramCurrent)), delivery.KnownCondition(delivery.FacetDelivery, string(model.DeliveryActive)),
+ },
+ AdmissionPredicate: "exact-admission", TargetPredicate: "published", TargetConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetDelivery, string(model.DeliveryPublished))},
+ Verifier: "fixture.state.published", Interruption: interruption(recoverID), Reversibility: delivery.Reversible, TerminalEffect: "none",
+ PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "local",
+ Policy: delivery.PolicyContract{ObjectiveScope: delivery.ObjectiveScopeBoundExact}, Priority: 2,
+ }
+ return delivery.ProgramRuntimeManifest{
+ ID: programID, Version: "1.0.0", ProtocolVersion: delivery.ProgramRuntimeProtocolVersion, RuntimeMode: delivery.ProgramRuntimeProtocol,
+ SupportedObjectives: []delivery.ObjectiveKind{delivery.ObjectiveVerified},
+ ObjectiveContracts: []delivery.ObjectiveContract{{ObjectiveKind: delivery.ObjectiveVerified, Conditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetDelivery, string(model.DeliveryPublished))}}},
+ Transitions: []delivery.Transition{publish, recover}, OwnedResources: []string{resource},
+ Effects: []string{string(publishEffect), string(recoverEffect)}, Verifiers: []string{"fixture.state.published", "fixture.state.recovered"},
+ Capabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute},
+ ConfigurationSchema: json.RawMessage(`{"type":"object"}`), PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt",
+ }, nil
+}
+
+type protocolStateClock struct{ now time.Time }
+
+func (c protocolStateClock) Now() time.Time { return c.now }
+
+type protocolStateObserver struct {
+ path string
+ invocation model.InvocationContext
+ program string
+ now time.Time
+ configProof model.Evidence
+}
+
+func (o protocolStateObserver) Observe(context.Context, ports.ObservationRequest) (model.Observation, error) {
+ raw, err := os.ReadFile(o.path)
+ if err != nil {
+ return model.Observation{}, err
+ }
+ state, err := durable.DecodeState(raw)
+ if err != nil {
+ return model.Observation{}, err
+ }
+ evidence := model.Evidence{Source: "fixture", Fingerprint: "fixture-state", ObservedAt: o.now}
+ return model.Observation{
+ SchemaVersion: model.SnapshotSchemaVersion, StateRevision: state.Revision, RecordedProgramFingerprint: state.ProgramFingerprint,
+ Invocation: o.invocation, Phase: model.Known(state.Phase, evidence), Engagement: model.Known(state.Engagement, evidence),
+ Delivery: model.Known(state.Delivery, evidence), Workspace: model.Known(state.Workspace, evidence), Plan: model.Known(state.Plan, evidence),
+ Configuration: model.Known(state.Configuration, o.configProof), ConfigurationPolicy: model.Known(state.ConfigurationPolicy(), o.configProof),
+ Runtime: model.Known(state.Runtime, evidence), Publication: model.Known(state.Publication, evidence), Verification: model.Known(state.Verification, evidence),
+ Recovery: model.Known(state.Recovery, evidence), Transaction: model.Known(state.Transaction, evidence),
+ RecoveryInfo: model.Absent[model.RecoveryContext]("none", evidence), TransactionInfo: model.Absent[model.TransactionContext]("none", evidence),
+ Terminal: model.Known(state.Terminal, evidence), Objective: model.Known(state.Objective, evidence), ObservedAt: o.now,
+ }, nil
+}
+
+func TestProgramRuntimeProtocolCommitsDeclaredStateEffectBeforeReceipt(t *testing.T) {
+ // control-law: protocol-runtime-effects-use-the-same-declared-state-reducer-as-native-effects
+ ctx := context.Background()
+ program, err := delivery.Compile(ctx, delivery.CompileRequest{KernelVersion: Version, Core: core.System(), Runtime: protocolStateRuntime{}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ repository := t.TempDir()
+ runGit := func(arguments ...string) {
+ command := exec.Command("git", arguments...)
+ command.Dir = repository
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", arguments, err, output)
+ }
+ }
+ runGit("init", "-q")
+ runGit("config", "user.email", "boatstack@example.invalid")
+ runGit("config", "user.name", "Boatstack Test")
+ if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit("add", "README.md")
+ runGit("commit", "-q", "-m", "fixture")
+
+ now := time.Unix(1200, 0).UTC()
+ clock := protocolStateClock{now: now}
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "protocol-state-effect")
+ if err != nil {
+ t.Fatal(err)
+ }
+ layout, _, err := resolver.ResolveLayout(ctx, invocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ state := durable.Default(invocation, now)
+ state.ProgramFingerprint = program.Fingerprint()
+ state.Phase, state.Engagement, state.Delivery = model.PhaseActive, model.EngagementActive, model.DeliveryActive
+ state.Configuration, state.ConfigFingerprint = model.ConfigurationVerified, "configuration-fingerprint"
+ state.PlanApprovalPolicy, state.VisualEvidencePolicy, state.ExternalEffectPolicy, state.EnabledHosts = "human", "optional", "human-or-autonomy-plus-provider", []string{"cli"}
+ state.Objective = model.Objective{ID: "protocol-objective", Kind: model.ObjectiveVerified, DeliveryID: "protocol-delivery"}
+ encoded, err := durable.EncodeState(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(layout.StatePath, encoded, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ configurationEvidence := model.Evidence{Source: "configuration:fixture", Fingerprint: state.ConfigFingerprint, ObservedAt: now}
+ observer := protocolStateObserver{path: layout.StatePath, invocation: invocation, program: program.Fingerprint(), now: now, configProof: configurationEvidence}
+ locker, _ := effects.NewLocker(resolver)
+ journal, _ := effects.NewJournal(resolver, clock)
+ receipts, _ := effects.NewReceiptStore(resolver, clock)
+ base, err := effects.NewProgramDriver(resolver, clock, effects.NewNativeBoundary(), program.ResourceOwnership())
+ if err != nil {
+ t.Fatal(err)
+ }
+ driver := programEffectDriver{base: base, program: program, resolver: resolver, clock: clock}
+ summary := program.Summary()
+ kernel, err := engine.New(program.RuntimeRegistry(), program.RuntimeObjectiveContracts(), protocol.ProgramIdentity{ID: summary.ProgramID, Version: summary.ProgramVersion, Fingerprint: summary.ProgramFingerprint}, observer, clock, locker, journal, driver, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ authority := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{
+ {ID: "human", Class: catalog.AuthorityHuman, Subject: invocation.RepositoryID, Fingerprint: "human", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)},
+ {ID: "repository", Class: catalog.AuthorityRepository, Subject: configurationEvidence.Source, Fingerprint: configurationEvidence.Fingerprint, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)},
+ }}
+ request := engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: state.Objective, Authority: authority, Requested: "fixture.state.publish"},
+ FlowID: "protocol-state-flow", AdmissionLifetime: time.Minute,
+ }
+ resolution, err := kernel.Resolve(ctx, request.ResolveRequest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolution.Decision.Kind != supervisor.DecisionPrescribed {
+ t.Fatalf("resolution = %#v", resolution.Decision)
+ }
+ request.Prescription = resolution.Prescription
+ result, err := kernel.Apply(ctx, request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Receipt.ID == "" || result.Target.Delivery.Value != model.DeliveryPublished {
+ t.Fatalf("protocol result did not bind declared state and receipt: %#v", result)
+ }
+ afterRaw, err := os.ReadFile(layout.StatePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ after, err := durable.DecodeState(afterRaw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after.Delivery != model.DeliveryPublished || after.LastTransition != "fixture.state.publish" || after.Revision != state.Revision+1 {
+ t.Fatalf("durable protocol state = %#v", after)
+ }
+}
diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go
index f9590ed..3b9f36e 100644
--- a/boatstack/sdk/sdk_test.go
+++ b/boatstack/sdk/sdk_test.go
@@ -96,7 +96,8 @@ func (syntheticFlow) RuntimeManifest(context.Context) (delivery.ProgramRuntimeMa
SourcePhases: []delivery.ProtocolPhase{delivery.PhaseObserved, delivery.PhaseActive}, TargetPhases: []delivery.ProtocolPhase{delivery.PhaseObserved, delivery.PhaseActive},
ObjectiveKinds: []delivery.ObjectiveKind{delivery.ObjectiveVerified}, RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id"},
Authority: []delivery.AuthorityClass{delivery.AuthorityRepository}, RequiredCapabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot", "objective", "facet:" + fact},
- OwnedResources: []string{resource}, Effect: effect, LocalEffects: []delivery.EffectID{effect}, Idempotent: true,
+ OwnedResources: []string{resource}, OwnedFacets: []delivery.StateFacet{delivery.StateFacetControl}, StateEffect: delivery.StateEffect{Kind: delivery.StateEffectAssignments},
+ Effect: effect, LocalEffects: []delivery.EffectID{effect}, Idempotent: true,
Prescription: delivery.Prescription{Operation: string(id), ExpectedPostcondition: target},
SourcePredicate: "synthetic-source", AdmissionPredicate: "exact-admission", TargetPredicate: "synthetic-target",
SourceConditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetName(fact), source)},
diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md
index 1b5ed5b..92ae93f 100644
--- a/docs/architecture/boatstack-v2-transition-catalog.md
+++ b/docs/architecture/boatstack-v2-transition-catalog.md
@@ -7,68 +7,68 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w
| Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
-| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` |
-| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` |
-| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` |
-| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` |
-| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` |
-| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` |
-| `engagement.release` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` |
-| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` |
-| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` |
-| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` |
-| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` |
-| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` |
-| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` |
-| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` |
-| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` |
-| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` |
-| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` |
-| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` |
-| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` |
-| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` |
-| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` |
-| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` |
-| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` |
-| `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` |
-| `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` |
-| `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` |
-| `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` |
-| `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` |
-| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` |
-| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` |
-| `installation.update` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` |
-| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` |
-| `objective.bind` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `objective_kind*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` |
-| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` |
-| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` |
-| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` |
-| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` |
-| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` |
-| `plan.create` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` |
-| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` |
-| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` |
-| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` |
-| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` |
-| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` |
-| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` |
-| `publication.preview` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` |
-| `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` |
-| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` |
-| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` |
-| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` |
-| `repository.attach` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` |
-| `repository.detach` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` |
-| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` |
-| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` |
-| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`1b894955c60436ed379944c908114a42b1216e4e8bc3882772bd1527236889aa` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` |
-| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` |
-| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` |
-| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` |
-| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` |
-| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` |
-| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` |
-| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` |
-| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`f2ae5c5b91da3eb6a1687c5fc552a80921a755f0ba1110586aef4377fcf64c7e` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` |
+| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` |
+| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` |
+| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` |
+| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` |
+| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` |
+| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` |
+| `engagement.release` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` |
+| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` |
+| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` |
+| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` |
+| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` |
+| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` |
+| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` |
+| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` |
+| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` |
+| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` |
+| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` |
+| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` |
+| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` |
+| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` |
+| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` |
+| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` |
+| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` |
+| `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` |
+| `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` |
+| `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` |
+| `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` |
+| `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` |
+| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` |
+| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` |
+| `installation.update` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` |
+| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` |
+| `objective.bind` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `objective_kind*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` |
+| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` |
+| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` |
+| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` |
+| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` |
+| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` |
+| `plan.create` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` |
+| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` |
+| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` |
+| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` |
+| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` |
+| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` |
+| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` |
+| `publication.preview` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` |
+| `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` |
+| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` |
+| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` |
+| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` |
+| `repository.attach` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` |
+| `repository.detach` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` |
+| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` |
+| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` |
+| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` |
+| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` |
+| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` |
+| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` |
+| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` |
+| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` |
+| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` |
+| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` |
+| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` |
`*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`.
diff --git a/release-notes/2026-08-12-declarative-state-effects.md b/release-notes/2026-08-12-declarative-state-effects.md
new file mode 100644
index 0000000..11f7d87
--- /dev/null
+++ b/release-notes/2026-08-12-declarative-state-effects.md
@@ -0,0 +1,9 @@
+### Declare software-delivery state effects
+
+Software-delivery transitions now declare their durable state assignments and owned facets as data. Repository-authored control programs can add state-only transitions without adding a Go reducer case, while cross-field operations remain behind explicitly named native handlers.
+
+The control-program schema is now version 4, and the program-runtime and extension protocols are version 3. Manifests must declare each controllable transition's owned facets and state effect.
+
+Native handlers now compile only against their registered component, effect, facet, and objective-policy contract. Declarative assignments must close durable-state invariants, and ordering-only declaration changes preserve the same program fingerprint.
+
+This alpha release does not read pending journals from earlier schema versions. Reset those local development fixtures before using the new runtime.