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.