diff --git a/.github/reviews/objective-lifecycle-relations.receipt.json b/.github/reviews/objective-lifecycle-relations.receipt.json new file mode 100644 index 00000000..aaff95dd --- /dev/null +++ b/.github/reviews/objective-lifecycle-relations.receipt.json @@ -0,0 +1,4 @@ +{ + "reviewed_tree": "0b9386e235424a81b116adc466e70491bd333644", + "program_fingerprint": "3ca3397ff275d89bdb6d5c934b86b51d3cbdfab0ee628c47fe94d1d4f5767155" +} diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 1993e8d2..ce5fe8c6 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -728,8 +728,8 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> response = json.loads(self.run_helper("catalog").stdout) transitions = response["catalog"] - self.assertEqual(len(transitions), 63) - self.assertEqual(len({item["id"] for item in transitions}), 63) + self.assertEqual(len(transitions), 64) + self.assertEqual(len({item["id"] for item in transitions}), 64) self.assertEqual( {item["class"] for item in transitions}, {"authority", "owned-local", "owned-external", "recovery", "observed-external"}, @@ -765,7 +765,7 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> checked = (REPO / "docs" / "architecture" / name).read_text() self.assertEqual(rendered, checked) model = json.loads(checked) - self.assertEqual(len(model["events"]), 63) + self.assertEqual(len(model["events"]), 64) self.assertEqual( {event["id"] for event in model["events"]}, {item["id"] for item in transitions}, @@ -890,7 +890,7 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout ) self.assertTrue(doctor["doctor"]["healthy"]) - self.assertEqual(doctor["doctor"]["transition_count"], 63) + self.assertEqual(doctor["doctor"]["transition_count"], 64) self.assertEqual(doctor["snapshot"]["runtime"]["value"], "verified") objective = ( diff --git a/boatstack/core/system_test.go b/boatstack/core/system_test.go index 0e074418..397120a2 100644 --- a/boatstack/core/system_test.go +++ b/boatstack/core/system_test.go @@ -15,7 +15,7 @@ func TestManifestOwnsOnlyOperationalCapabilities(t *testing.T) { if err != nil { t.Fatal(err) } - if manifest.ID != core.ID || manifest.Version != core.Version || len(manifest.Transitions) != 33 { + if manifest.ID != core.ID || manifest.Version != core.Version || len(manifest.Transitions) != 34 { t.Fatalf("CoreSystem identity/count = %s@%s/%d", manifest.ID, manifest.Version, len(manifest.Transitions)) } for _, transition := range manifest.Transitions { diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index 60d29613..1ec5ce9a 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -3196,7 +3196,6 @@ { "facet": "objective", "statuses": [ - "known", "absent" ] }, @@ -3249,7 +3248,7 @@ "telemetry_classification": "transition-receipt", "cost_class": "declared-neutral", "policy": { - "binds_requested_objective": true, + "objective_mutation": "bind-initial", "objective_scope": "none" }, "priority": 30, @@ -3262,6 +3261,150 @@ "native_handler": "objective-bind" } }, + { + "id": "objective.replace", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBJECTIVE_REQUIRED", + "class": "authority", + "source_phases": [ + "OBSERVED", + "DORMANT", + "ACTIVE", + "FRONTIER", + "TERMINAL", + "ABANDONED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "FRONTIER" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-version", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "objective", + "facet:objective", + "facet:program" + ], + "owned_resources": [ + "objective" + ], + "effect": "objective.replace", + "local_effects": [ + "objective.replace" + ], + "idempotent": true, + "parameters": [ + { + "name": "target_id", + "required": true, + "secret": false + }, + { + "name": "delivery_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "objective.replace", + "expected_postcondition": "predicate:target-phase:objective.replace" + }, + "source_predicate": "predicate:source-phase:objective.replace", + "source_conditions": [ + { + "facet": "objective", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + } + ], + "admission_predicate": "predicate:exact-admission:objective.replace", + "target_predicate": "predicate:target-phase:objective.replace", + "target_conditions": [ + { + "facet": "objective", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:objective.replace", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:objective.replace" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "objective_mutation": "replace", + "objective_scope": "none" + }, + "priority": 31, + "owned_facets": [ + "control", + "product" + ], + "state_effect": { + "kind": "native", + "native_handler": "objective-replace" + } + }, { "id": "recovery.resume", "version": 1, diff --git a/boatstack/delivery/control.go b/boatstack/delivery/control.go index 5b3aca41..64f65a6d 100644 --- a/boatstack/delivery/control.go +++ b/boatstack/delivery/control.go @@ -29,6 +29,7 @@ type FacetCondition = catalog.FacetCondition type SelectionClass = catalog.SelectionClass type ObjectiveContract = catalog.ObjectiveContract type ObjectiveScope = catalog.ObjectiveScope +type ObjectiveMutation = catalog.ObjectiveMutation type EffectID = catalog.EffectID type Prescription = catalog.Prescription type ParameterSpec = catalog.ParameterSpec @@ -81,6 +82,10 @@ const ( ObjectiveScopeOptionalPreserve = catalog.ObjectiveScopeOptionalPreserve ObjectiveScopeBoundExact = catalog.ObjectiveScopeBoundExact + ObjectiveMutationPreserve = catalog.ObjectiveMutationPreserve + ObjectiveMutationBindInitial = catalog.ObjectiveMutationBindInitial + ObjectiveMutationReplace = catalog.ObjectiveMutationReplace + ObjectiveApprovedPlan = model.ObjectiveApprovedPlan ObjectiveVerified = model.ObjectiveVerified ObjectiveOpenPR = model.ObjectiveOpenPR @@ -605,9 +610,14 @@ func compileSupervisoryProgram(runtime ProgramRuntimeManifest, compatibility, do facets = append(facets, string(facet)) } mutation := general.PreserveObjective - if transition.Policy.BindsRequestedObjective { - mutation = general.BindObjectiveMutation - capabilities = append(capabilities, general.Capability("objective.bind")) + switch transition.Policy.Mutation() { + case catalog.ObjectiveMutationBindInitial: + mutation = general.BindInitialObjective + case catalog.ObjectiveMutationReplace: + mutation = general.ReplaceObjective + } + if capability, mutates := mutation.Capability(); mutates { + capabilities = append(capabilities, capability) facets = append(facets, "supervisor.objective") } selection := general.SelectionExplicitOnly diff --git a/boatstack/delivery/control_test.go b/boatstack/delivery/control_test.go index 6e772527..9040dbd1 100644 --- a/boatstack/delivery/control_test.go +++ b/boatstack/delivery/control_test.go @@ -49,7 +49,7 @@ func TestStandardProgramHasExplicitStableComposition(t *testing.T) { if len(supervisory.Transitions) != controllable { t.Fatalf("kernel program transition count = %d, want %d controllable software transitions", len(supervisory.Transitions), controllable) } - if summary.CoreTransitionCount != 33 || summary.RuntimeTransitionCount != 30 || summary.ExtensionTransitionCount != 0 || summary.TotalTransitionCount != 63 { + if summary.CoreTransitionCount != 34 || summary.RuntimeTransitionCount != 30 || summary.ExtensionTransitionCount != 0 || summary.TotalTransitionCount != 64 { t.Fatalf("compiled counts = %+v", summary) } counts := map[string]int{} @@ -59,7 +59,7 @@ func TestStandardProgramHasExplicitStableComposition(t *testing.T) { t.Fatalf("transition lost compiled ownership: %+v", transition) } } - if counts["core-system"] != 33 || counts["control-program"] != 30 || counts["extension"] != 0 { + if counts["core-system"] != 34 || counts["control-program"] != 30 || counts["extension"] != 0 { t.Fatalf("origin counts = %#v", counts) } } @@ -272,7 +272,10 @@ func TestExtensionCompilationRejectsBoundaryViolations(t *testing.T) { value.Transitions[0].Policy.ReconcilesProgram = true }, "requested-objective-binding-claim": func(value *delivery.ExtensionManifest) { - value.Transitions[0].Policy.BindsRequestedObjective = true + value.Transitions[0].Policy.ObjectiveMutation = delivery.ObjectiveMutationBindInitial + }, + "objective-replacement-claim": func(value *delivery.ExtensionManifest) { + value.Transitions[0].Policy.ObjectiveMutation = delivery.ObjectiveMutationReplace }, "foreign-target": func(value *delivery.ExtensionManifest) { value.Transitions[0].TargetConditions = []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetPlan, "approved")} diff --git a/boatstack/delivery/program_manifest.go b/boatstack/delivery/program_manifest.go index 140831bb..1fdbb4b2 100644 --- a/boatstack/delivery/program_manifest.go +++ b/boatstack/delivery/program_manifest.go @@ -233,7 +233,7 @@ func ValidateProgram(manifest ProgramManifest, runtime RuntimeCompatibility) (Co } source.Interruption.Recovery = TransitionID(manifest.ProgramID + "/" + string(source.Interruption.Recovery)) } - if source.Policy.BindsRequestedObjective || source.Policy.ReconcilesProgram { + if source.Policy.MutatesObjectiveBinding() || source.Policy.ReconcilesProgram { return ControlProgram{}, invalidProgram(fmt.Sprintf("transitions[%d].policy", index), "runtime-reserved program mutation policy is not repository-declarable") } if source.Controllable() && !containsDeclaration(effects, string(source.Effect)) { diff --git a/boatstack/examples/control_program_test.go b/boatstack/examples/control_program_test.go index 8eaa95ca..79151f3d 100644 --- a/boatstack/examples/control_program_test.go +++ b/boatstack/examples/control_program_test.go @@ -24,7 +24,7 @@ func Example_standardFlowWithReleaseNoteExtension() { summary := program.Summary() fmt.Printf("%s + %s + %s: %d transitions\n", summary.Core.ID, summary.Runtime.ID, summary.Extensions[0].ID, summary.TotalTransitionCount) // Output: - // boatstack.core + boatstack.standard + boatstack.release-note: 64 transitions + // boatstack.core + boatstack.standard + boatstack.release-note: 65 transitions } func Example_sdkCustomKernel() { diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 6f29c287..f5a470b3 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -55,7 +55,7 @@ func TestEveryControllingFacetAndEventIsClassifiedByTheRuntimeCatalog(t *testing t.Errorf("controlling facet %s is absent from executable predicates", facet) } } - want := map[string]int{"invocation-engagement": 6, "installation-runtime-configuration": 9, "catalog": 1, "objective-plan": 9, "workspace": 8, "gate-evidence-delivery": 8, "publication": 6, "recovery": 3, "external": 13} + want := map[string]int{"invocation-engagement": 6, "installation-runtime-configuration": 9, "catalog": 1, "objective-plan": 10, "workspace": 8, "gate-evidence-delivery": 8, "publication": 6, "recovery": 3, "external": 13} for family, count := range want { if families[family] != count { t.Errorf("family %s=%d, want %d", family, families[family], count) @@ -335,9 +335,12 @@ func TestNativeStateHandlersAreBoundToAuthorizedSemantics(t *testing.T) { {"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.ObjectiveMutation = catalog.ObjectiveMutationPreserve value.Policy.ObjectiveScope = catalog.ObjectiveScopeBoundExact }}, + {"objective-replace-policy", "objective.replace", func(value *catalog.Transition) { + value.Policy.ObjectiveMutation = catalog.ObjectiveMutationBindInitial + }}, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { diff --git a/boatstack/flow/standard/supervisor_parity_test.go b/boatstack/flow/standard/supervisor_parity_test.go index 09bd6401..ef4ff60c 100644 --- a/boatstack/flow/standard/supervisor_parity_test.go +++ b/boatstack/flow/standard/supervisor_parity_test.go @@ -138,8 +138,8 @@ func TestTerminalEvidenceForOldObjectiveDoesNotTerminateNewObjective(t *testing. snapshot = recanonicalize(t, snapshot) newObjective := model.Objective{ID: "next-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "next-delivery"} decision := s.Resolve(snapshot, newObjective, catalog.AuthoritySet{catalog.AuthorityHuman: true}, "") - if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.bind" { - t.Fatalf("untargeted terminal replacement decision=%#v, want exact new-objective configuration", decision) + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.replace" { + t.Fatalf("untargeted terminal replacement decision=%#v, want exact new-objective replacement", decision) } } @@ -150,8 +150,8 @@ func TestUntargetedResolutionReconfiguresDifferentObjectiveAndSkipsSatisfiedObje newObjective := model.Objective{ID: "new-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} decision := New(testprogram.StandardRegistry(), testObjectiveContracts()).Resolve(snapshot, newObjective, authority, "") - if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.bind" { - t.Fatalf("different-objective decision = %#v, want objective.bind", decision) + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.replace" { + t.Fatalf("different-objective decision = %#v, want objective.replace", decision) } snapshot.Plan = model.Known(model.PlanValid, snapshot.Plan.Evidence[0]) @@ -169,8 +169,8 @@ func TestDormantBootstrapObjectiveReconfiguresBeforeEngagement(t *testing.T) { authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} untargeted := New(testprogram.StandardRegistry(), testObjectiveContracts()).Resolve(snapshot, requested, authority, "") - if untargeted.Kind != DecisionPrescribed || untargeted.Transition == nil || untargeted.Transition.ID != "objective.bind" { - t.Fatalf("untargeted decision = %#v, want objective.bind", untargeted) + if untargeted.Kind != DecisionPrescribed || untargeted.Transition == nil || untargeted.Transition.ID != "objective.replace" { + t.Fatalf("untargeted decision = %#v, want objective.replace", untargeted) } targeted := New(testprogram.StandardRegistry(), testObjectiveContracts()).Resolve(snapshot, requested, authority, untargeted.Transition.ID) if targeted.Kind != DecisionPrescribed || targeted.Transition == nil || targeted.Transition.ID != untargeted.Transition.ID { @@ -178,7 +178,7 @@ func TestDormantBootstrapObjectiveReconfiguresBeforeEngagement(t *testing.T) { } engagement := New(testprogram.StandardRegistry(), testObjectiveContracts()).Resolve(snapshot, requested, authority, "engagement.begin") if engagement.Kind != DecisionRefused { - t.Fatalf("engagement decision = %#v, want refusal until objective.bind", engagement) + t.Fatalf("engagement decision = %#v, want refusal until the objective is reconfigured", engagement) } } diff --git a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go index 30c6baac..bfcf2f4f 100644 --- a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go +++ b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go @@ -8,11 +8,11 @@ import ( ) type nativeStateHandlerContract struct { - componentIDs []string - effects []EffectID - ownedFacets []model.StateFacet - objectiveScopes []ObjectiveScope - bindsRequestedObjective bool + componentIDs []string + effects []EffectID + ownedFacets []model.StateFacet + objectiveScopes []ObjectiveScope + objectiveMutation ObjectiveMutation } var nativeStateHandlerContracts = map[string]nativeStateHandlerContract{ @@ -26,7 +26,12 @@ var nativeStateHandlerContracts = map[string]nativeStateHandlerContract{ "objective-bind": { componentIDs: []string{"boatstack.core"}, effects: []EffectID{"objective.bind"}, ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeNone}, - bindsRequestedObjective: true, + objectiveMutation: ObjectiveMutationBindInitial, + }, + "objective-replace": { + componentIDs: []string{"boatstack.core"}, effects: []EffectID{"objective.replace"}, + ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeNone}, + objectiveMutation: ObjectiveMutationReplace, }, "plan-approve": standardNative([]EffectID{"plan.approve"}, ObjectiveScopeBoundExact), "work-package-admit": standardNative([]EffectID{"work.package.admit"}, ObjectiveScopeBoundExact), @@ -81,7 +86,11 @@ func validateNativeStateHandler(t Transition) error { 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 { + expectedMutation := contract.objectiveMutation + if expectedMutation == "" { + expectedMutation = ObjectiveMutationPreserve + } + if !slices.Contains(contract.objectiveScopes, t.Policy.ObjectiveScope) || t.Policy.Mutation() != expectedMutation { 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/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index 5ae5dd8b..2c267a69 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -264,16 +264,50 @@ const ( ObjectiveScopeBoundExact = general.ObjectiveBoundExact ) +// ObjectiveMutation is the software-delivery projection of the kernel's +// objective lifecycle relation. The catalog declares exactly which relation a +// transition performs; nothing is inferred from effects or handlers. +type ObjectiveMutation = general.ObjectiveMutation + +const ( + // ObjectiveMutationPreserve leaves the configured objective untouched. + ObjectiveMutationPreserve = general.PreserveObjective + // ObjectiveMutationBindInitial binds a first objective where none is + // configured. It is the initial-configuration relation only. + ObjectiveMutationBindInitial = general.BindInitialObjective + // ObjectiveMutationReplace reconfigures a different objective over an + // existing configuration and must be declared, never inferred from bind. + ObjectiveMutationReplace = general.ReplaceObjective +) + type PolicyContract struct { - RequiredWhen string `json:"required_when,omitempty"` - AuthorityRule string `json:"authority_rule,omitempty"` - AvailabilityRule string `json:"availability_rule,omitempty"` - CurrentEvidencePrefix string `json:"current_evidence_prefix,omitempty"` - ManagedOperations []string `json:"managed_operations,omitempty"` - BindsRequestedObjective bool `json:"binds_requested_objective,omitempty"` - ReconcilesProgram bool `json:"reconciles_program,omitempty"` - RechecksExternalState bool `json:"rechecks_external_state,omitempty"` - ObjectiveScope ObjectiveScope `json:"objective_scope,omitempty"` + RequiredWhen string `json:"required_when,omitempty"` + AuthorityRule string `json:"authority_rule,omitempty"` + AvailabilityRule string `json:"availability_rule,omitempty"` + CurrentEvidencePrefix string `json:"current_evidence_prefix,omitempty"` + ManagedOperations []string `json:"managed_operations,omitempty"` + // ObjectiveMutation is the exact declared lifecycle relation. An omitted + // value is the preserve relation: mutating the objective binding always + // requires an explicit declaration. + ObjectiveMutation ObjectiveMutation `json:"objective_mutation,omitempty"` + ReconcilesProgram bool `json:"reconciles_program,omitempty"` + RechecksExternalState bool `json:"rechecks_external_state,omitempty"` + ObjectiveScope ObjectiveScope `json:"objective_scope,omitempty"` +} + +// Mutation returns the declared objective lifecycle relation, with the zero +// value meaning preserve. +func (p PolicyContract) Mutation() ObjectiveMutation { + if p.ObjectiveMutation == "" { + return ObjectiveMutationPreserve + } + return p.ObjectiveMutation +} + +// MutatesObjectiveBinding reports whether the transition declares any +// objective binding mutation. +func (p PolicyContract) MutatesObjectiveBinding() bool { + return p.Mutation() != ObjectiveMutationPreserve } // FacetCondition is an executable, serializable predicate over one canonical @@ -407,6 +441,34 @@ func (t Transition) SupportsObjective(objective model.Objective) bool { } // ObjectiveEvaluation evaluates the transition's compiled objective scope. +// ObjectiveMutationEvaluation is the shared objective lifecycle law used by +// both resolution and admission. It evaluates the transition's declared +// relation against the configured objective fact and the admitted objective: +// preserve cannot change the configuration, bind-initial requires verified +// absence, and replace requires a different configured objective. +func (t Transition) ObjectiveMutationEvaluation(snapshot model.Snapshot, objective model.Objective) (bool, string) { + switch t.Policy.Mutation() { + case ObjectiveMutationBindInitial: + if snapshot.Objective.Status != model.FactAbsent { + return false, "initial objective binding requires a verified absent configured objective; reconfiguration must declare objective replacement" + } + return true, "initial objective binding is admissible over verified absence" + case ObjectiveMutationReplace: + if snapshot.Objective.Status != model.FactKnown { + return false, "objective replacement requires an existing configured objective" + } + if snapshot.Objective.Value == objective { + return false, "objective replacement requires a different objective" + } + return true, "declared replacement rebinds a different objective over the configured objective" + default: + if snapshot.Objective.Status == model.FactKnown && snapshot.Objective.Value != objective { + return false, "transition cannot replace the current objective binding" + } + return true, "objective binding is compatible" + } +} + func (t Transition) ObjectiveEvaluation(objective model.Objective) (bool, string) { if t.Policy.ObjectiveScope == ObjectiveScopeOptionalPreserve { return true, "transition preserves the current objective binding" @@ -656,11 +718,16 @@ func validateTransition(t Transition) error { if !t.Policy.ObjectiveScope.Valid() { return fmt.Errorf("%s: invalid objective scope %q", t.ID, t.Policy.ObjectiveScope) } - if t.Policy.ObjectiveScope == ObjectiveScopeOptionalPreserve && t.Policy.BindsRequestedObjective { - return fmt.Errorf("%s: optional-preserve maintenance cannot bind a requested product objective", t.ID) + switch t.Policy.Mutation() { + case ObjectiveMutationPreserve, ObjectiveMutationBindInitial, ObjectiveMutationReplace: + default: + return fmt.Errorf("%s: objective mutation %q has no software-delivery projection", t.ID, t.Policy.ObjectiveMutation) + } + if t.Policy.MutatesObjectiveBinding() && t.Policy.ObjectiveScope != ObjectiveScopeNone { + return fmt.Errorf("%s: objective mutation %q requires the none objective scope", t.ID, t.Policy.Mutation()) } - if t.Policy.BindsRequestedObjective && (t.Origin.Kind != OriginCoreSystem || !conditionNamesFacet(t.TargetConditions, model.FacetObjective)) { - return fmt.Errorf("%s: requested-objective binding requires a CoreSystem objective target", t.ID) + if t.Policy.MutatesObjectiveBinding() && (t.Origin.Kind != OriginCoreSystem || !conditionNamesFacet(t.TargetConditions, model.FacetObjective)) { + return fmt.Errorf("%s: objective mutation %q requires a CoreSystem objective target", t.ID, t.Policy.Mutation()) } if t.Policy.ReconcilesProgram && (t.Origin.Kind != OriginCoreSystem || !conditionNamesFacet(t.TargetConditions, model.FacetProgram)) { return fmt.Errorf("%s: program reconciliation requires a CoreSystem program target", t.ID) diff --git a/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go index a4e84490..9ebf1662 100644 --- a/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go +++ b/boatstack/internal/softwaredelivery/effects/history_compatibility_test.go @@ -97,14 +97,17 @@ func legacyCommittedRecord(t *testing.T, transitionID catalog.TransitionID, clas Observation: model.Observation{StateRevision: priorRevision + 1, Objective: model.Known(objective, model.Evidence{Source: "legacy", Fingerprint: "objective", ObservedAt: now})}, Fingerprint: strings.Repeat("f", 64), } + receiptAdmission := admission + receiptAdmission.ObjectiveMutation = catalog.ObjectiveMutationPreserve receipt, err := protocol.NewReceipt( "run-legacy", sequence, protocol.ProgramIdentity{ID: "product-delivery", Version: "1.0.0", Fingerprint: admission.ExpectedProgramFingerprint}, - admission, transition, target, mutation.StateFacets, effects, nil, nil, now, now.Add(time.Second), + receiptAdmission, transition, target, mutation.StateFacets, effects, nil, nil, now, now.Add(time.Second), ) if err != nil { t.Fatal(err) } receipt.SchemaVersion = protocol.PreviousReceiptSchemaVersion + receipt.ObjectiveMutation = "" receipt.ID = "" receipt.ID = legacyContentID(t, "trc-", receipt) if err := receipt.ValidateCommittedHistory(); err != nil { @@ -134,9 +137,9 @@ func writeLegacyJournal(t *testing.T, root string, record journalRecord, suffix } func TestPreviousCommittedHistoryRemainsReadableAndResumable(t *testing.T) { - // This fixture uses the exact admission-8/receipt-12 wire shape written by - // the immediately preceding release, including a publication commit with no - // effect_outputs field. + // This fixture uses the exact admission-9/receipt-13 wire shape written by + // the immediately preceding release, which declared no objective lifecycle + // relation on admissions or receipts. root := t.TempDir() invocation := model.InvocationContext{ RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/feature/legacy", @@ -152,7 +155,7 @@ func TestPreviousCommittedHistoryRemainsReadableAndResumable(t *testing.T) { if err != nil { t.Fatal(err) } - if bytes.Contains(legacyRaw, []byte(`"effect_outputs"`)) || bytes.Contains(legacyRaw, []byte(`"invocation_fingerprint"`)) { + if bytes.Contains(legacyRaw, []byte(`"objective_mutation"`)) { t.Fatalf("legacy wire fixture contains fields absent from the base encoding: %s", legacyRaw) } diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 429f9610..fad624e2 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -1032,7 +1032,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndObjectiveTerminals(t *tes } verifiedObjective := model.Objective{ID: "objective-verified", TargetID: model.ObjectiveVerified, DeliveryID: approvedObjective.DeliveryID} - apply(verifiedObjective, "objective.bind", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "target_id", Value: string(verifiedObjective.TargetID)}, {Name: "delivery_id", Value: verifiedObjective.DeliveryID}}) + apply(verifiedObjective, "objective.replace", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "target_id", Value: string(verifiedObjective.TargetID)}, {Name: "delivery_id", Value: verifiedObjective.DeliveryID}}) apply(verifiedObjective, "plan.activate", authority(catalog.AuthorityHuman), nil) head := strings.TrimSpace(commandOutput(t, repository, "git", "rev-parse", "HEAD")) gateParameters := func(name string) protocol.Parameters { @@ -1205,7 +1205,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) t.Fatal(err) } objective = model.Objective{ID: "objective-workspace-abandon", TargetID: model.ObjectiveAbandoned, DeliveryID: "delivery-workspace"} - apply(destinationInvocation, "objective.bind", human, protocol.Parameters{{Name: "target_id", Value: string(objective.TargetID)}, {Name: "delivery_id", Value: objective.DeliveryID}}) + apply(destinationInvocation, "objective.replace", human, protocol.Parameters{{Name: "target_id", Value: string(objective.TargetID)}, {Name: "delivery_id", Value: objective.DeliveryID}}) abandoned := apply(destinationInvocation, "workspace.abandon", human, protocol.Parameters{{Name: "branch", Value: "feature/workspace-transfer"}}) if abandoned.Target.Terminal.Value != model.TerminalEstablished || abandoned.Target.Workspace.Value != model.WorkspaceAbandoned { t.Fatalf("workspace abandonment did not establish its configured terminal: %#v", abandoned.Target) diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 36524fbe..704c2d4d 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -479,7 +479,7 @@ func sameStateLineage(left, right model.InvocationContext) bool { } func matchesObjectiveBinding(receipt protocol.TransitionReceipt, objective model.Objective, maximumRevision uint64) bool { - return receipt.TransitionID == "objective.bind" && strings.HasPrefix(receipt.FlowID, "run-") && + return (receipt.TransitionID == "objective.bind" || receipt.TransitionID == "objective.replace") && strings.HasPrefix(receipt.FlowID, "run-") && receipt.ObjectiveID == objective.ID && receipt.TargetID == objective.TargetID && receipt.TrustedClass == objective.TrustedClass && receipt.DeliveryID == objective.DeliveryID && receipt.ResultingStateRevision <= maximumRevision } diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index 5a5a7c7e..d33a502d 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -19,8 +19,11 @@ func applyStateTransition(state *durable.State, admission protocol.Admission, tr return fmt.Errorf("transition %q cannot create product intent from verified absence", transition.ID) } } else { - 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) + if configured && state.Objective != admission.Objective && transition.Policy.Mutation() != catalog.ObjectiveMutationReplace { + return fmt.Errorf("transition %q cannot replace configured objective; objective replacement must be declared", transition.ID) + } + if !configured && transition.Policy.Mutation() == catalog.ObjectiveMutationReplace { + return fmt.Errorf("transition %q cannot replace an unconfigured objective", transition.ID) } } state.LastTransition = transition.ID @@ -84,6 +87,7 @@ var nativeStateHandlers = map[string]nativeStateHandler{ "installation-reconcile-update": applyInstallationReconcileUpdate, "catalog-reconcile": applyCatalogReconcile, "objective-bind": applyObjectiveBind, + "objective-replace": applyObjectiveReplace, "plan-approve": applyPlanApprove, "work-package-admit": applyWorkPackageAdmit, "work-package-approve": applyWorkPackageApprove, @@ -346,14 +350,43 @@ func applyCatalogReconcile(state *durable.State, admission protocol.Admission, _ return nil } -func applyObjectiveBind(state *durable.State, admission protocol.Admission, _ catalog.Transition) error { - wasActive := state.Phase == model.PhaseActive +func validateObjectiveParameters(admission protocol.Admission) error { kind, _ := admission.Parameters.Get("target_id") delivery, _ := admission.Parameters.Get("delivery_id") if kind != string(admission.Objective.TargetID) || delivery != admission.Objective.DeliveryID { return fmt.Errorf("objective parameters do not match admitted objective") } - if state.Objective.DeliveryID != "" && state.Objective.DeliveryID != admission.Objective.DeliveryID { + return nil +} + +func applyObjectiveBind(state *durable.State, admission protocol.Admission, _ catalog.Transition) error { + if err := validateObjectiveParameters(admission); err != nil { + return err + } + if state.Objective.Validate() == nil { + return fmt.Errorf("initial objective binding requires an unconfigured objective; replacement must declare objective.replace") + } + state.Objective = admission.Objective + state.Terminal = model.TerminalNonterminal + state.Phase = model.PhaseObserved + if state.Recovery == model.RecoveryEscalated { + state.Phase = model.PhaseFrontier + } + return nil +} + +func applyObjectiveReplace(state *durable.State, admission protocol.Admission, _ catalog.Transition) error { + if err := validateObjectiveParameters(admission); err != nil { + return err + } + if state.Objective.Validate() != nil { + return fmt.Errorf("objective replacement requires a configured objective") + } + if state.Objective == admission.Objective { + return fmt.Errorf("objective replacement requires a different objective") + } + wasActive := state.Phase == model.PhaseActive + if state.Objective.DeliveryID != admission.Objective.DeliveryID { if state.Terminal != model.TerminalEstablished { return fmt.Errorf("a different delivery requires the prior delivery to be terminal") } diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go index e5e800b9..10d8f09d 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go @@ -145,7 +145,7 @@ func TestEscalatedRecoveryCanOnlyBeReconfiguredTowardExplicitAbandonment(t *test Objective: original, TransactionID: "adm-interrupted", RecoveryCause: "provider unknown", RecoverySourcePhase: model.PhaseActive, RecoveryResumption: model.PhaseFrontier, RecoveryBudget: 0, } - configure, _ := testprogram.StandardRegistry().Lookup("objective.bind") + configure, _ := testprogram.StandardRegistry().Lookup("objective.replace") configureAdmission := protocol.Admission{Objective: abandoned, Parameters: protocol.Parameters{ {Name: "target_id", Value: string(abandoned.TargetID)}, {Name: "delivery_id", Value: abandoned.DeliveryID}, }} @@ -164,7 +164,7 @@ func TestEscalatedRecoveryCanOnlyBeReconfiguredTowardExplicitAbandonment(t *test } } -func TestObjectiveBindStartsDifferentDeliveryFromCleanProductState(t *testing.T) { +func TestObjectiveReplaceStartsDifferentDeliveryFromCleanProductState(t *testing.T) { prior := model.Objective{ID: "prior", TargetID: model.ObjectiveAbandoned, DeliveryID: "prior-delivery"} next := model.Objective{ID: "next", TargetID: model.ObjectiveOpenPR, DeliveryID: "next-delivery"} state := durable.State{ @@ -176,7 +176,7 @@ func TestObjectiveBindStartsDifferentDeliveryFromCleanProductState(t *testing.T) WorkspacePath: "/worktrees/prior", WorkspaceBranch: "feature/prior", WorkspaceSourcePath: "/source", WorkspaceSourceID: "source-id", WorkspaceSourceRef: "refs/heads/main", PreviewFingerprint: "old-preview", Gates: []durable.GateEvidence{{Gate: "test", Revision: "old", Fingerprint: "old-test"}}, } - transition, _ := testprogram.StandardRegistry().Lookup("objective.bind") + transition, _ := testprogram.StandardRegistry().Lookup("objective.replace") admission := protocol.Admission{Objective: next, Parameters: protocol.Parameters{{Name: "target_id", Value: string(next.TargetID)}, {Name: "delivery_id", Value: next.DeliveryID}}} if err := applyStateTransition(&state, admission, transition); err != nil { t.Fatal(err) @@ -186,7 +186,7 @@ func TestObjectiveBindStartsDifferentDeliveryFromCleanProductState(t *testing.T) } } -func TestObjectiveBindRejectsDifferentDeliveryBeforeSafeTerminal(t *testing.T) { +func TestObjectiveReplaceRejectsDifferentDeliveryBeforeSafeTerminal(t *testing.T) { prior := model.Objective{ID: "prior", TargetID: model.ObjectiveOpenPR, DeliveryID: "prior-delivery"} next := model.Objective{ID: "next", TargetID: model.ObjectiveOpenPR, DeliveryID: "next-delivery"} state := durable.State{ @@ -196,7 +196,7 @@ func TestObjectiveBindRejectsDifferentDeliveryBeforeSafeTerminal(t *testing.T) { Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, Objective: prior, } - transition, _ := testprogram.StandardRegistry().Lookup("objective.bind") + transition, _ := testprogram.StandardRegistry().Lookup("objective.replace") admission := protocol.Admission{Objective: next, Parameters: protocol.Parameters{{Name: "target_id", Value: string(next.TargetID)}, {Name: "delivery_id", Value: next.DeliveryID}}} if err := applyStateTransition(&state, admission, transition); err == nil || !strings.Contains(err.Error(), "prior delivery") { t.Fatalf("different active delivery bind result = %v", err) diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 2b78518b..3df7aa59 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -10,12 +10,12 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const AdmissionSchemaVersion = 9 +const AdmissionSchemaVersion = 10 // PreviousAdmissionSchemaVersion is the only historical admission encoding // accepted by the committed-journal compatibility boundary. New admissions // are always written at AdmissionSchemaVersion. -const PreviousAdmissionSchemaVersion = 8 +const PreviousAdmissionSchemaVersion = 9 type Admission struct { SchemaVersion int `json:"schema_version"` @@ -35,6 +35,7 @@ type Admission struct { Invocation model.InvocationContext `json:"invocation"` Objective model.Objective `json:"objective"` ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` + ObjectiveMutation catalog.ObjectiveMutation `json:"objective_mutation,omitempty"` ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` Authority AuthorityBundle `json:"authority"` AuthorityFingerprint string `json:"authority_fingerprint"` @@ -105,7 +106,8 @@ func NewAdmissionWithWorkBundleAndRevision(snapshot model.Snapshot, objective mo ExpectedStateRevision: prescription.ExpectedStateRevision, ExpectedProgramFingerprint: prescription.ExpectedProgramFingerprint, ExpectedSnapshotFingerprint: prescription.ExpectedSnapshotFingerprint, ExpectedObjectiveBindingFingerprint: prescription.ExpectedObjectiveBindingFingerprint, SourceRevision: sourceRevision, WorktreeFingerprint: worktreeFingerprint, - SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Objective: objective, ObjectiveScope: transition.Policy.ObjectiveScope, Authority: authority.canonical(), + SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Objective: objective, + ObjectiveScope: transition.Policy.ObjectiveScope, ObjectiveMutation: transition.Policy.Mutation(), Authority: authority.canonical(), AuthorityFingerprint: capabilities.AuthorityFingerprint, RequiredCapabilities: capabilities.Required, GrantedCapabilities: capabilities.Granted, EffectiveCapabilities: capabilities.Effective, Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(), @@ -223,8 +225,8 @@ func ValidateApplicability(snapshot model.Snapshot, objective model.Objective, t if snapshot.Fingerprint == "" || len(snapshot.ProgramFingerprint) != 64 || !transition.SourceMatches(snapshot) || !transition.SupportsObjective(objective) { return fmt.Errorf("transition %q is not admissible from snapshot %q", transition.ID, snapshot.Fingerprint) } - if snapshot.Objective.Status == model.FactKnown && snapshot.Objective.Value != objective && !transition.Policy.BindsRequestedObjective { - return fmt.Errorf("transition %q cannot replace configured objective; objective.bind is required", transition.ID) + if allowed, reason := transition.ObjectiveMutationEvaluation(snapshot, objective); !allowed { + return fmt.Errorf("transition %q violates its declared objective lifecycle relation: %s", transition.ID, reason) } if err := authority.Validate(now); err != nil { return err @@ -270,6 +272,9 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, objective model.Obje if a.ObjectiveScope != transition.Policy.ObjectiveScope { return fmt.Errorf("admission %q is bound to a different objective scope", a.ID) } + if a.ObjectiveMutation != transition.Policy.Mutation() { + return fmt.Errorf("admission %q is bound to a different objective lifecycle relation", a.ID) + } if a.ExpectedStateRevision != snapshot.StateRevision { return fmt.Errorf("admission %q is stale: state revision changed", a.ID) } @@ -474,8 +479,8 @@ func (a Admission) ValidateCommittedHistoryIdentity() error { case AdmissionSchemaVersion: return a.ValidateIdentity() case PreviousAdmissionSchemaVersion: - if a.InvocationFingerprint != "" || a.ControlBundleRevision != "" { - return fmt.Errorf("legacy admission invents a current-schema invocation or control-bundle revision identity") + if a.ObjectiveMutation != "" { + return fmt.Errorf("legacy admission invents a current-schema objective lifecycle relation") } return a.validateIdentity(PreviousAdmissionSchemaVersion) default: @@ -548,6 +553,20 @@ func (a Admission) validateIdentity(schemaVersion int) error { if !a.ObjectiveScope.Valid() { return fmt.Errorf("admission has invalid objective scope %q", a.ObjectiveScope) } + if schemaVersion == AdmissionSchemaVersion { + switch a.ObjectiveMutation { + case catalog.ObjectiveMutationPreserve: + case catalog.ObjectiveMutationBindInitial, catalog.ObjectiveMutationReplace: + if a.ObjectiveScope != catalog.ObjectiveScopeNone { + return fmt.Errorf("admission declares objective mutation %q outside the none objective scope", a.ObjectiveMutation) + } + if err := a.Objective.Validate(); err != nil { + return fmt.Errorf("admission declares objective mutation %q without an exact objective: %w", a.ObjectiveMutation, err) + } + default: + return fmt.Errorf("admission declares no valid objective lifecycle relation") + } + } if a.ObjectiveScope == catalog.ObjectiveScopeOptionalPreserve { switch a.ObjectiveStatus { case model.FactKnown: diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index 9e09e584..d3d2aba8 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -13,12 +13,12 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ReceiptSchemaVersion = 13 +const ReceiptSchemaVersion = 14 // PreviousReceiptSchemaVersion is the only historical receipt encoding // accepted by the committed-journal compatibility boundary. New receipts are // always written at ReceiptSchemaVersion. -const PreviousReceiptSchemaVersion = 12 +const PreviousReceiptSchemaVersion = 13 type TransitionFactKind string @@ -96,55 +96,56 @@ func (v VerificationFact) Validate() error { // TransitionReceipt is the immutable fact for one committed transition. It is // not a request, prescription, admission, refusal, or recovery authorization. type TransitionReceipt struct { - SchemaVersion int `json:"schema_version"` - Kind TransitionFactKind `json:"kind"` - ID string `json:"id"` - FlowID string `json:"flow_id"` - Sequence uint64 `json:"sequence"` - Program ProgramIdentity `json:"program"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionVersion int `json:"transition_version"` - PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` - ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` - ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` - RuntimeVersion string `json:"runtime_version,omitempty"` - RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` - RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` - PrescriptionID string `json:"prescription_id"` - AdmissionID string `json:"admission_id"` - PriorStateRevision uint64 `json:"prior_state_revision"` - ResultingStateRevision uint64 `json:"resulting_state_revision"` - ObjectiveID string `json:"objective_id"` - TargetID model.TargetID `json:"target_id"` - TrustedClass model.TargetID `json:"trusted_class,omitempty"` - DeliveryID string `json:"delivery_id"` - ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` - ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` - ObjectiveBindingFingerprint string `json:"objective_binding_fingerprint"` - SourceFingerprint string `json:"source_fingerprint"` - TargetFingerprint string `json:"target_fingerprint"` - AuthorityFingerprint string `json:"authority_fingerprint"` - AuthoritySources []AuthoritySource `json:"authority_sources"` - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` - ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` - CommittedEffects []EffectFact `json:"committed_effects"` - EffectOutputs Parameters `json:"effect_outputs,omitempty"` - ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` - Verification VerificationFact `json:"verification"` - IdempotencyKey string `json:"idempotency_key"` - Recovery catalog.TransitionID `json:"recovery,omitempty"` - Terminal model.TerminalStatus `json:"terminal"` - StartedAt time.Time `json:"started_at"` - CommittedAt time.Time `json:"committed_at"` - DurationNanoseconds int64 `json:"duration_nanoseconds"` - ExecutionContext string `json:"execution_context,omitempty"` - PriorInvocation *model.InvocationContext `json:"prior_invocation,omitempty"` - ResultingInvocation *model.InvocationContext `json:"resulting_invocation,omitempty"` - WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` - ControlBundleSourceFingerprint string `json:"control_bundle_source_fingerprint,omitempty"` - ControlBundleTargetFingerprint string `json:"control_bundle_target_fingerprint,omitempty"` - InvocationFingerprint string `json:"invocation_fingerprint,omitempty"` + SchemaVersion int `json:"schema_version"` + Kind TransitionFactKind `json:"kind"` + ID string `json:"id"` + FlowID string `json:"flow_id"` + Sequence uint64 `json:"sequence"` + Program ProgramIdentity `json:"program"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionVersion int `json:"transition_version"` + PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` + ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` + RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` + RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` + PrescriptionID string `json:"prescription_id"` + AdmissionID string `json:"admission_id"` + PriorStateRevision uint64 `json:"prior_state_revision"` + ResultingStateRevision uint64 `json:"resulting_state_revision"` + ObjectiveID string `json:"objective_id"` + TargetID model.TargetID `json:"target_id"` + TrustedClass model.TargetID `json:"trusted_class,omitempty"` + DeliveryID string `json:"delivery_id"` + ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` + ObjectiveMutation catalog.ObjectiveMutation `json:"objective_mutation,omitempty"` + ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` + ObjectiveBindingFingerprint string `json:"objective_binding_fingerprint"` + SourceFingerprint string `json:"source_fingerprint"` + TargetFingerprint string `json:"target_fingerprint"` + AuthorityFingerprint string `json:"authority_fingerprint"` + AuthoritySources []AuthoritySource `json:"authority_sources"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` + CommittedEffects []EffectFact `json:"committed_effects"` + EffectOutputs Parameters `json:"effect_outputs,omitempty"` + ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` + Verification VerificationFact `json:"verification"` + IdempotencyKey string `json:"idempotency_key"` + Recovery catalog.TransitionID `json:"recovery,omitempty"` + Terminal model.TerminalStatus `json:"terminal"` + StartedAt time.Time `json:"started_at"` + CommittedAt time.Time `json:"committed_at"` + DurationNanoseconds int64 `json:"duration_nanoseconds"` + ExecutionContext string `json:"execution_context,omitempty"` + PriorInvocation *model.InvocationContext `json:"prior_invocation,omitempty"` + ResultingInvocation *model.InvocationContext `json:"resulting_invocation,omitempty"` + WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` + ControlBundleSourceFingerprint string `json:"control_bundle_source_fingerprint,omitempty"` + ControlBundleTargetFingerprint string `json:"control_bundle_target_fingerprint,omitempty"` + InvocationFingerprint string `json:"invocation_fingerprint,omitempty"` } type AuthoritySource struct { @@ -166,6 +167,9 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi if program.Fingerprint != admission.ExpectedProgramFingerprint { return TransitionReceipt{}, fmt.Errorf("receipt program identity differs from admitted program") } + if admission.ObjectiveMutation != transition.Policy.Mutation() { + return TransitionReceipt{}, fmt.Errorf("receipt objective lifecycle relation differs from the admitted transition policy") + } if committedAt.Before(startedAt) { return TransitionReceipt{}, fmt.Errorf("receipt commit precedes effect start") } @@ -199,7 +203,7 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi PrescriptionID: admission.PrescriptionID, AdmissionID: admission.ID, PriorStateRevision: admission.ExpectedStateRevision, ResultingStateRevision: target.StateRevision, ObjectiveID: admission.Objective.ID, TargetID: admission.Objective.TargetID, TrustedClass: admission.Objective.TrustedClass, DeliveryID: admission.Objective.DeliveryID, - ObjectiveScope: admission.ObjectiveScope, ObjectiveStatus: admission.ObjectiveStatus, + ObjectiveScope: admission.ObjectiveScope, ObjectiveMutation: admission.ObjectiveMutation, ObjectiveStatus: admission.ObjectiveStatus, ObjectiveBindingFingerprint: resultingObjectiveBindingFingerprint, SourceFingerprint: admission.ExpectedSnapshotFingerprint, TargetFingerprint: target.Fingerprint, AuthorityFingerprint: admission.AuthorityFingerprint, AuthoritySources: sources, @@ -269,8 +273,8 @@ func (r TransitionReceipt) ValidateCommittedHistory() error { case ReceiptSchemaVersion: return r.Validate() case PreviousReceiptSchemaVersion: - if len(r.EffectOutputs) != 0 || r.InvocationFingerprint != "" { - return fmt.Errorf("legacy receipt invents current-schema output or invocation evidence") + if r.ObjectiveMutation != "" { + return fmt.Errorf("legacy receipt invents a current-schema objective lifecycle relation") } return r.validate(PreviousReceiptSchemaVersion) default: @@ -378,6 +382,17 @@ func (r TransitionReceipt) validate(schemaVersion int) error { if !r.ObjectiveScope.Valid() { return fmt.Errorf("receipt has invalid objective scope %q", r.ObjectiveScope) } + if schemaVersion == ReceiptSchemaVersion { + switch r.ObjectiveMutation { + case catalog.ObjectiveMutationPreserve: + case catalog.ObjectiveMutationBindInitial, catalog.ObjectiveMutationReplace: + if r.ObjectiveScope != catalog.ObjectiveScopeNone || r.ObjectiveID == "" || !r.TargetID.Valid() || r.DeliveryID == "" { + return fmt.Errorf("receipt declares objective mutation %q without the none scope and an exact objective identity", r.ObjectiveMutation) + } + default: + return fmt.Errorf("receipt declares no valid objective lifecycle relation") + } + } if r.ObjectiveScope == catalog.ObjectiveScopeOptionalPreserve { switch r.ObjectiveStatus { case model.FactKnown: diff --git a/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go b/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go index c41b977b..61ba12c8 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt_capability_test.go @@ -22,6 +22,7 @@ func TestReceiptRejectsRehashedAuthorityProvenanceTampering(t *testing.T) { ExpectedObjectiveBindingFingerprint: strings.Repeat("d", 64), Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveApprovedPlan, DeliveryID: "delivery"}, ObjectiveScope: catalog.ObjectiveScopeBoundExact, + ObjectiveMutation: catalog.ObjectiveMutationPreserve, Authority: authority, AuthorityFingerprint: authorityFingerprint, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, GrantedCapabilities: authority.GrantedCapabilities(now), diff --git a/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go b/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go index 0a603b13..46b82bdb 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go @@ -26,6 +26,7 @@ func committedReceiptFixture(t *testing.T) (TransitionReceipt, Admission, catalo ExpectedObjectiveBindingFingerprint: strings.Repeat("d", 64), Objective: model.Objective{ID: "objective", TargetID: "approved-plan-for-repo", TrustedClass: model.ObjectiveApprovedPlan, DeliveryID: "delivery"}, ObjectiveScope: catalog.ObjectiveScopeBoundExact, + ObjectiveMutation: catalog.ObjectiveMutationPreserve, Authority: authority, AuthorityFingerprint: authorityFingerprint, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, GrantedCapabilities: authority.GrantedCapabilities(now), EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, IdempotencyKey: "idem-fixture", @@ -112,7 +113,10 @@ func TestCommittedTransitionFactPreservesCanonicalEffectOutputs(t *testing.T) { func TestObjectiveBindReceiptRecordsResultingObjectiveBinding(t *testing.T) { _, admission, transition, target, now := committedReceiptFixture(t) transition.ID = "objective.bind" - transition.Policy.BindsRequestedObjective = true + transition.Policy.ObjectiveMutation = catalog.ObjectiveMutationBindInitial + transition.Policy.ObjectiveScope = catalog.ObjectiveScopeNone + admission.ObjectiveMutation = catalog.ObjectiveMutationBindInitial + admission.ObjectiveScope = catalog.ObjectiveScopeNone target.Objective = model.Fact[model.Objective]{Status: model.FactKnown, Value: admission.Objective} want, err := ObjectiveBindingFingerprint(target) if err != nil { diff --git a/boatstack/internal/softwaredelivery/supervisor/supervisor.go b/boatstack/internal/softwaredelivery/supervisor/supervisor.go index dde02d23..cb75f128 100644 --- a/boatstack/internal/softwaredelivery/supervisor/supervisor.go +++ b/boatstack/internal/softwaredelivery/supervisor/supervisor.go @@ -131,9 +131,9 @@ func (s Supervisor) resolve(snapshot model.Snapshot, objective model.Objective, traces = append(traces, trace) continue } - trace.ObjectiveMutation = general.EvaluationTrace{Evaluated: true, Satisfied: true, Reason: "objective binding is compatible"} - if snapshot.Objective.Status == model.FactKnown && snapshot.Objective.Value != objective && !transition.Policy.BindsRequestedObjective { - trace.ObjectiveMutation = general.EvaluationTrace{Evaluated: true, Reason: "transition cannot replace the current objective binding"} + mutationAllowed, mutationReason := transition.ObjectiveMutationEvaluation(snapshot, objective) + trace.ObjectiveMutation = general.EvaluationTrace{Evaluated: true, Satisfied: mutationAllowed, Reason: mutationReason} + if !mutationAllowed { trace.Disposition = general.DispositionObjectiveRejected traces = append(traces, trace) continue @@ -284,7 +284,7 @@ func targetAlreadySatisfied(snapshot model.Snapshot, objective model.Objective, if transition.Policy.RechecksExternalState { return false } - if transition.Policy.BindsRequestedObjective { + if transition.Policy.MutatesObjectiveBinding() { return snapshot.Objective.Status == model.FactKnown && snapshot.Objective.Value == objective } if transition.Policy.RequiredWhen == "visual-evidence-required" && diff --git a/boatstack/kernel/conformance/integer.go b/boatstack/kernel/conformance/integer.go index 22b5916a..db85d11b 100644 --- a/boatstack/kernel/conformance/integer.go +++ b/boatstack/kernel/conformance/integer.go @@ -299,7 +299,7 @@ func (c *FixedClock) advance(duration time.Duration) { // IntegerProgram compiles the reference control program. func IntegerProgram() (kernel.Program, error) { return kernel.CompileProgram("integer-control", "1.0.0", "kernel-v1", "unbound", []string{"two"}, []kernel.Transition{ - {ID: "objective.bind", SourceModes: []string{"unbound"}, TargetMode: "zero", ObjectiveScope: kernel.ObjectiveNone, ObjectiveMutation: kernel.BindObjectiveMutation, RequiredCapabilities: []kernel.Capability{"objective.bind"}, OwnedFacets: []string{"supervisor.objective"}, Operation: "objective.bind", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 5}, + {ID: "objective.bind", SourceModes: []string{"unbound"}, TargetMode: "zero", ObjectiveScope: kernel.ObjectiveNone, ObjectiveMutation: kernel.BindInitialObjective, RequiredCapabilities: []kernel.Capability{"objective.bind"}, OwnedFacets: []string{"supervisor.objective"}, Operation: "objective.bind", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 5}, {ID: "counter.increment-first", SourceModes: []string{"zero"}, TargetMode: "one", ObjectiveScope: kernel.ObjectiveBoundExact, ObjectiveMutation: kernel.PreserveObjective, RequiredCapabilities: []kernel.Capability{"counter.increment"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.increment", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 10}, {ID: "counter.increment-second", SourceModes: []string{"one"}, TargetMode: "two", ObjectiveScope: kernel.ObjectiveBoundExact, ObjectiveMutation: kernel.PreserveObjective, RequiredCapabilities: []kernel.Capability{"counter.increment"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.increment", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 10}, {ID: "counter.reset", SourceModes: []string{"one", "two"}, TargetMode: "zero", ObjectiveScope: kernel.ObjectiveOptionalPreserve, ObjectiveMutation: kernel.PreserveObjective, RequiredCapabilities: []kernel.Capability{"counter.reset"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.reset", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 20}, diff --git a/boatstack/kernel/conformance/lifecycle.go b/boatstack/kernel/conformance/lifecycle.go new file mode 100644 index 00000000..ebc984fb --- /dev/null +++ b/boatstack/kernel/conformance/lifecycle.go @@ -0,0 +1,686 @@ +package conformance + +import ( + "context" + "encoding/json" + "math" + "reflect" + "sync" + "testing" + + "github.com/operatorstack/boatstack/boatstack/kernel" +) + +// Run executes the objective lifecycle laws against a fresh fixture per law. +// The laws prove that no valid path can mutate an objective binding without +// declaring one exact lifecycle relation, satisfying that relation's own +// capability, and committing a receipt that independently proves the relation. +func (suite ObjectiveLifecycleConformance) Run(t *testing.T) { + t.Helper() + t.Run("initial_bind_commits_declared_relation", suite.initialBind) + t.Run("advance_commits_exactly_next_revision", suite.advance) + t.Run("inadmissible_lifecycle_relations_reject_before_effects", suite.inadmissibleRelations) + t.Run("replacement_must_be_declared_not_inferred", suite.declaredReplacement) + t.Run("relation_capability_is_specific_not_interchangeable", suite.relationCapabilities) + t.Run("clear_removes_binding_and_receipt_proves_it", suite.clear) + t.Run("concurrent_successors_settle_exactly_once", suite.concurrentSuccessors) + t.Run("stale_prescription_after_lifecycle_commit", suite.stalePrescription) + t.Run("stale_pre_clear_candidate_cannot_resurrect", suite.staleClearResurrection) + t.Run("verification_and_commit_failures_never_advance", suite.failedAttemptsNeverAdvance) + t.Run("replay_and_cross_instance_replay_reject", suite.replay) + t.Run("restart_reconstructs_latest_accepted_revision", suite.restart) + t.Run("receipt_chain_proves_full_lifecycle", suite.receiptChain) + t.Run("accepted_reader_fails_closed_on_forged_lineage", suite.readerFailsClosed) +} + +type lifecycleValue struct { + Value string `json:"value"` +} + +func (suite ObjectiveLifecycleConformance) fresh(t testing.TB) ObjectiveLifecycleConformance { + t.Helper() + if suite.New == nil { + t.Fatal("objective lifecycle conformance requires a fresh fixture factory") + } + fixture := suite.New(t) + if fixture.Program.Validate() != nil || fixture.Stage == nil || fixture.Accepted == nil || + fixture.Snapshot == nil || fixture.Runtime == nil || fixture.Reopen == nil || + fixture.IndependentLocker == nil || fixture.FailNextVerification == nil || + fixture.FailNextCommit == nil || fixture.RetargetInstance == nil || + fixture.RemoveCommittedReceipts == nil || fixture.SubstituteCommittedReceipt == nil || + fixture.InstanceID == "" || fixture.BindTransition == "" || fixture.AdvanceTransition == "" || + fixture.ReplaceTransition == "" || fixture.ClearTransition == "" || fixture.RecoveryTransition == "" { + t.Fatal("objective lifecycle conformance fixture is incomplete") + } + return fixture +} + +func stageLifecycleValue(t testing.TB, fixture ObjectiveLifecycleConformance, id string, revision uint64, value string) kernel.Objective { + t.Helper() + objective, err := fixture.Stage(id, revision, lifecycleValue{Value: value}) + if err != nil { + t.Fatalf("stage lifecycle value: %v", err) + } + return objective +} + +func lifecycleRequest(fixture ObjectiveLifecycleConformance, transition string, objective *kernel.Objective) kernel.ResolveRequest { + return kernel.ResolveRequest{ + InstanceID: fixture.InstanceID, Objective: objective, + Authority: fixture.Authority, Requested: transition, + } +} + +func resolveLifecycle(t testing.TB, runtime kernel.Runtime, fixture ObjectiveLifecycleConformance, transition string, objective *kernel.Objective) (kernel.ResolveRequest, kernel.Prescription) { + t.Helper() + request := lifecycleRequest(fixture, transition, objective) + resolution, err := runtime.Resolve(context.Background(), request) + if err != nil || resolution.Decision.Kind != kernel.Prescribed || resolution.Prescription == nil { + t.Fatalf("resolve %s: decision=%#v error=%v", transition, resolution.Decision, err) + } + return request, *resolution.Prescription +} + +func applyLifecycle(t testing.TB, runtime kernel.Runtime, fixture ObjectiveLifecycleConformance, transition string, objective *kernel.Objective) kernel.Receipt { + t.Helper() + request, prescription := resolveLifecycle(t, runtime, fixture, transition, objective) + receipt, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + if err != nil { + t.Fatalf("apply %s: %v", transition, err) + } + return receipt +} + +// refuseLifecycle asserts that the requested relation is rejected during +// resolution, before any effect, verification, or commit. +func refuseLifecycle(t testing.TB, runtime kernel.Runtime, fixture ObjectiveLifecycleConformance, transition string, objective *kernel.Objective) { + t.Helper() + before := fixture.Snapshot() + resolution, err := runtime.Resolve(context.Background(), lifecycleRequest(fixture, transition, objective)) + if err != nil { + t.Fatalf("resolve %s: %v", transition, err) + } + if resolution.Decision.Kind == kernel.Prescribed || resolution.Prescription != nil { + t.Fatalf("control-law objective-lifecycle-admissibility: inadmissible %s was prescribed: %#v", transition, resolution.Decision) + } + after := fixture.Snapshot() + if !reflect.DeepEqual(before, after) { + t.Fatalf("control-law objective-lifecycle-admissibility: refused %s crossed the effect boundary: before=%#v after=%#v", transition, before, after) + } +} + +func lifecycleBindingOf(t testing.TB, objective kernel.Objective) *kernel.ObjectiveBinding { + t.Helper() + binding, err := kernel.BindObjective(objective) + if err != nil { + t.Fatal(err) + } + return &binding +} + +// assertLifecycleReceipt is the oracle for receipt-provable relations: the +// committed receipt alone must prove the declared relation and exact lineage. +func assertLifecycleReceipt(t testing.TB, receipt kernel.Receipt, mutation kernel.ObjectiveMutation, prior, requested, result *kernel.ObjectiveBinding) { + t.Helper() + if err := receipt.Validate(); err != nil { + t.Fatalf("control-law objective-lifecycle-receipt: committed receipt is invalid: %v", err) + } + if receipt.ObjectiveMutation != mutation || + !reflect.DeepEqual(receipt.PriorObjectiveBinding, prior) || + !reflect.DeepEqual(receipt.RequestedObjectiveBinding, requested) || + !reflect.DeepEqual(receipt.ResultObjectiveBinding, result) { + t.Fatalf("control-law objective-lifecycle-receipt: receipt does not prove relation %q with exact lineage: %#v", mutation, receipt) + } +} + +func assertLifecycleAccepted(t testing.TB, fixture ObjectiveLifecycleConformance, objective kernel.Objective, value string, receipt kernel.Receipt) { + t.Helper() + accepted, err := fixture.Accepted() + if err != nil { + t.Fatalf("control-law objective-lifecycle-reader: %v", err) + } + wantContent, _ := json.Marshal(lifecycleValue{Value: value}) + if !accepted.Binding.Matches(objective) || !reflect.DeepEqual(accepted.Content, json.RawMessage(wantContent)) || accepted.Receipt.ID != receipt.ID { + t.Fatalf("control-law objective-lifecycle-reader: accepted evidence differs from the committed relation: %#v", accepted) + } +} + +func assertLifecycleUnbound(t testing.TB, fixture ObjectiveLifecycleConformance) { + t.Helper() + snapshot := fixture.Snapshot() + if snapshot.State.ObjectiveBinding != nil { + t.Fatalf("control-law objective-lifecycle-unbound: an objective binding exists: %#v", snapshot.State.ObjectiveBinding) + } + if accepted, err := fixture.Accepted(); err == nil { + t.Fatalf("control-law objective-lifecycle-unbound: reader accepted without a binding: %#v", accepted) + } +} + +// initialBind proves bind-initial commits any valid externally versioned +// revision over verified absence, atomically with a relation-proving receipt. +func (suite ObjectiveLifecycleConformance) initialBind(t *testing.T) { + fixture := suite.fresh(t) + assertLifecycleUnbound(t, fixture) + objective := stageLifecycleValue(t, fixture, "root", 3, "v3") + runtime := fixture.Reopen(t) + before := fixture.Snapshot() + receipt := applyLifecycle(t, runtime, fixture, fixture.BindTransition, &objective) + after := fixture.Snapshot() + if after.State.ObjectiveBinding == nil || !after.State.ObjectiveBinding.Matches(objective) || after.State.Recovery != nil { + t.Fatalf("control-law objective-lifecycle-bind: exact objective revision 3 was not bound: %#v", after.State) + } + if after.CommitCount != before.CommitCount+1 || len(after.Receipts) != len(before.Receipts)+1 { + t.Fatalf("control-law objective-lifecycle-bind: binding and receipt did not commit together: before=%#v after=%#v", before, after) + } + assertLifecycleReceipt(t, receipt, kernel.BindInitialObjective, nil, lifecycleBindingOf(t, objective), lifecycleBindingOf(t, objective)) + assertLifecycleAccepted(t, fixture, objective, "v3", receipt) +} + +// advance proves the root@1 -> root@2 advancement with exact receipt lineage. +func (suite ObjectiveLifecycleConformance) advance(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + receipt := applyLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + assertLifecycleReceipt(t, receipt, kernel.AdvanceObjective, lifecycleBindingOf(t, base), lifecycleBindingOf(t, next), lifecycleBindingOf(t, next)) + if receipt.ResultObjectiveBinding.ObjectiveID != receipt.PriorObjectiveBinding.ObjectiveID || + receipt.ResultObjectiveBinding.ObjectiveRevision != receipt.PriorObjectiveBinding.ObjectiveRevision+1 { + t.Fatalf("control-law objective-lifecycle-advance: receipt does not prove same identity at exactly the next revision: %#v", receipt) + } + assertLifecycleAccepted(t, fixture, next, "v2", receipt) +} + +// inadmissibleRelations proves every invalid lifecycle request is rejected +// before effects: gaps, downgrades, duplicates, overflow, same-revision +// fingerprint substitution, identity changes, undeclared replacement, and +// relations over an incompatible binding presence. +func (suite ObjectiveLifecycleConformance) inadmissibleRelations(t *testing.T) { + type bound struct { + id string + revision uint64 + value string + } + cases := map[string]struct { + bind *bound + transition func(ObjectiveLifecycleConformance) string + id string + revision uint64 + value string + }{ + "bind over an existing binding": { + bind: &bound{"root", 1, "v1"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.BindTransition }, + id: "other", revision: 1, value: "w1", + }, + "advance while unbound": { + transition: func(f ObjectiveLifecycleConformance) string { return f.AdvanceTransition }, + id: "root", revision: 1, value: "v1", + }, + "advance with a revision gap": { + bind: &bound{"root", 1, "v1"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.AdvanceTransition }, + id: "root", revision: 3, value: "v3", + }, + "advance downgrade": { + bind: &bound{"root", 2, "v2"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.AdvanceTransition }, + id: "root", revision: 1, value: "v1", + }, + "advance duplicate revision": { + bind: &bound{"root", 1, "v1"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.AdvanceTransition }, + id: "root", revision: 1, value: "v1", + }, + "advance same-revision fingerprint substitution": { + bind: &bound{"root", 1, "v1"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.AdvanceTransition }, + id: "root", revision: 1, value: "v1-substituted", + }, + "advance revision overflow": { + bind: &bound{"root", math.MaxUint64, "vmax"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.AdvanceTransition }, + id: "root", revision: 1, value: "v1", + }, + "advance to a different identity": { + bind: &bound{"root", 1, "v1"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.AdvanceTransition }, + id: "other", revision: 2, value: "w2", + }, + "replace while unbound": { + transition: func(f ObjectiveLifecycleConformance) string { return f.ReplaceTransition }, + id: "other", revision: 1, value: "w1", + }, + "replace keeping the same identity": { + bind: &bound{"root", 1, "v1"}, + transition: func(f ObjectiveLifecycleConformance) string { return f.ReplaceTransition }, + id: "root", revision: 5, value: "v5", + }, + "clear while unbound": { + transition: func(f ObjectiveLifecycleConformance) string { return f.ClearTransition }, + }, + } + for name, test := range cases { + t.Run(name, func(t *testing.T) { + fixture := suite.fresh(t) + runtime := fixture.Reopen(t) + if test.bind != nil { + base := stageLifecycleValue(t, fixture, test.bind.id, test.bind.revision, test.bind.value) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + } + var objective *kernel.Objective + if test.id != "" { + staged := stageLifecycleValue(t, fixture, test.id, test.revision, test.value) + objective = &staged + } + refuseLifecycle(t, runtime, fixture, test.transition(fixture), objective) + }) + } +} + +// declaredReplacement proves replacement is a declared relation: the same +// different-identity objective is refused through bind and advance and commits +// only through the explicit replace relation. +func (suite ObjectiveLifecycleConformance) declaredReplacement(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + replacement := stageLifecycleValue(t, fixture, "other", 1, "w1") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + refuseLifecycle(t, runtime, fixture, fixture.BindTransition, &replacement) + refuseLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &replacement) + receipt := applyLifecycle(t, runtime, fixture, fixture.ReplaceTransition, &replacement) + assertLifecycleReceipt(t, receipt, kernel.ReplaceObjective, lifecycleBindingOf(t, base), lifecycleBindingOf(t, replacement), lifecycleBindingOf(t, replacement)) + if receipt.ResultObjectiveBinding.ObjectiveID == receipt.PriorObjectiveBinding.ObjectiveID { + t.Fatalf("control-law objective-lifecycle-replace: receipt does not prove a different objective identity: %#v", receipt) + } + assertLifecycleAccepted(t, fixture, replacement, "w1", receipt) +} + +// relationCapabilities proves each lifecycle relation requires its own +// capability: removing exactly that capability refuses the relation while +// every other granted capability remains. +func (suite ObjectiveLifecycleConformance) relationCapabilities(t *testing.T) { + cases := map[string]struct { + capability kernel.Capability + exercise func(*testing.T, ObjectiveLifecycleConformance, kernel.Runtime, kernel.Authority) + }{ + "bind requires objective.bind": { + capability: "objective.bind", + exercise: func(t *testing.T, fixture ObjectiveLifecycleConformance, runtime kernel.Runtime, restricted kernel.Authority) { + objective := stageLifecycleValue(t, fixture, "root", 1, "v1") + refuseLifecycleWithAuthority(t, runtime, fixture, fixture.BindTransition, &objective, restricted) + }, + }, + "advance requires objective.advance": { + capability: "objective.advance", + exercise: func(t *testing.T, fixture ObjectiveLifecycleConformance, runtime kernel.Runtime, restricted kernel.Authority) { + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + refuseLifecycleWithAuthority(t, runtime, fixture, fixture.AdvanceTransition, &next, restricted) + }, + }, + "replace requires objective.replace": { + capability: "objective.replace", + exercise: func(t *testing.T, fixture ObjectiveLifecycleConformance, runtime kernel.Runtime, restricted kernel.Authority) { + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + replacement := stageLifecycleValue(t, fixture, "other", 1, "w1") + refuseLifecycleWithAuthority(t, runtime, fixture, fixture.ReplaceTransition, &replacement, restricted) + }, + }, + "clear requires objective.clear": { + capability: "objective.clear", + exercise: func(t *testing.T, fixture ObjectiveLifecycleConformance, runtime kernel.Runtime, restricted kernel.Authority) { + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + refuseLifecycleWithAuthority(t, runtime, fixture, fixture.ClearTransition, nil, restricted) + }, + }, + } + for name, test := range cases { + t.Run(name, func(t *testing.T) { + fixture := suite.fresh(t) + runtime := fixture.Reopen(t) + restricted := withoutCapability(fixture.Authority, test.capability) + test.exercise(t, fixture, runtime, restricted) + }) + } +} + +func refuseLifecycleWithAuthority(t testing.TB, runtime kernel.Runtime, fixture ObjectiveLifecycleConformance, transition string, objective *kernel.Objective, authority kernel.Authority) { + t.Helper() + before := fixture.Snapshot() + request := lifecycleRequest(fixture, transition, objective) + request.Authority = authority + resolution, err := runtime.Resolve(context.Background(), request) + if err != nil { + t.Fatalf("resolve %s: %v", transition, err) + } + if resolution.Decision.Kind == kernel.Prescribed || resolution.Prescription != nil { + t.Fatalf("control-law objective-lifecycle-authority: %s was prescribed without its own capability: %#v", transition, resolution.Decision) + } + if after := fixture.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("control-law objective-lifecycle-authority: refused %s changed state: before=%#v after=%#v", transition, before, after) + } +} + +// clear proves an existing binding can be removed exactly once, with a +// receipt proving prior presence and result absence. +func (suite ObjectiveLifecycleConformance) clear(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + receipt := applyLifecycle(t, runtime, fixture, fixture.ClearTransition, nil) + assertLifecycleReceipt(t, receipt, kernel.ClearObjectiveMutation, lifecycleBindingOf(t, base), nil, nil) + assertLifecycleUnbound(t, fixture) + refuseLifecycle(t, runtime, fixture, fixture.ClearTransition, nil) +} + +// concurrentSuccessors proves that two lifecycle successors resolved from one +// base settle exactly once: one commits, the other is stale. +func (suite ObjectiveLifecycleConformance) concurrentSuccessors(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + successorA := stageLifecycleValue(t, fixture, "root", 2, "v2-a") + successorB := stageLifecycleValue(t, fixture, "root", 2, "v2-b") + resolver := fixture.Reopen(t) + applyLifecycle(t, resolver, fixture, fixture.BindTransition, &base) + requestA, prescriptionA := resolveLifecycle(t, resolver, fixture, fixture.AdvanceTransition, &successorA) + requestB, prescriptionB := resolveLifecycle(t, resolver, fixture, fixture.AdvanceTransition, &successorB) + type application struct { + objective kernel.Objective + request kernel.ResolveRequest + prescription kernel.Prescription + runtime kernel.Runtime + } + applications := []application{ + {successorA, requestA, prescriptionA, fixture.Runtime(t, fixture.Program, fixture.IndependentLocker())}, + {successorB, requestB, prescriptionB, fixture.Runtime(t, fixture.Program, fixture.IndependentLocker())}, + } + type outcome struct { + objective kernel.Objective + receipt kernel.Receipt + err error + } + start := make(chan struct{}) + results := make(chan outcome, 2) + var group sync.WaitGroup + for _, application := range applications { + application := application + group.Add(1) + go func() { + defer group.Done() + <-start + receipt, err := application.runtime.Apply(context.Background(), kernel.ApplyRequest{ + ResolveRequest: application.request, Prescription: application.prescription, + }) + results <- outcome{objective: application.objective, receipt: receipt, err: err} + }() + } + close(start) + group.Wait() + close(results) + var winners []outcome + for result := range results { + if result.err == nil { + winners = append(winners, result) + } + } + if len(winners) != 1 { + t.Fatalf("control-law objective-lifecycle-concurrency: %d successors committed, want exactly one", len(winners)) + } + accepted, err := fixture.Accepted() + if err != nil { + t.Fatal(err) + } + if !accepted.Binding.Matches(winners[0].objective) || accepted.Receipt.ID != winners[0].receipt.ID { + t.Fatalf("control-law objective-lifecycle-concurrency: accepted evidence differs from the single winner: %#v", accepted) + } + loser := successorA + if accepted.Binding.Matches(successorA) { + loser = successorB + } + if accepted.Binding.Matches(loser) { + t.Fatal("control-law objective-lifecycle-concurrency: losing successor became accepted") + } +} + +// stalePrescription proves a prescription resolved before another committed +// lifecycle relation cannot apply afterward. +func (suite ObjectiveLifecycleConformance) stalePrescription(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + replacement := stageLifecycleValue(t, fixture, "other", 1, "w1") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + staleRequest, stalePrescription := resolveLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + replaceReceipt := applyLifecycle(t, runtime, fixture, fixture.ReplaceTransition, &replacement) + before := fixture.Snapshot() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: staleRequest, Prescription: stalePrescription}); !kernel.IsStale(err) { + t.Fatalf("stale advance after replace = %v, want stale", err) + } + if after := fixture.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("control-law objective-lifecycle-staleness: stale advance changed state: before=%#v after=%#v", before, after) + } + assertLifecycleAccepted(t, fixture, replacement, "w1", replaceReceipt) +} + +// staleClearResurrection proves a candidate resolved before a committed clear +// cannot resurrect the cleared binding. +func (suite ObjectiveLifecycleConformance) staleClearResurrection(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + staleRequest, stalePrescription := resolveLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + applyLifecycle(t, runtime, fixture, fixture.ClearTransition, nil) + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: staleRequest, Prescription: stalePrescription}); !kernel.IsStale(err) { + t.Fatalf("pre-clear advance after clear = %v, want stale", err) + } + assertLifecycleUnbound(t, fixture) +} + +// failedAttemptsNeverAdvance proves verification and commit failures leave the +// accepted binding untouched, and that recovery is a preserve relation whose +// receipt proves the binding did not change. +func (suite ObjectiveLifecycleConformance) failedAttemptsNeverAdvance(t *testing.T) { + for name, fail := range map[string]func(ObjectiveLifecycleConformance){ + "verification failure": func(f ObjectiveLifecycleConformance) { f.FailNextVerification() }, + "commit failure": func(f ObjectiveLifecycleConformance) { f.FailNextCommit() }, + } { + t.Run(name, func(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + runtime := fixture.Reopen(t) + bindReceipt := applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + request, prescription := resolveLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + fail(fixture) + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); !kernel.IsRecoveryRequired(err) { + t.Fatalf("failed advance = %v, want recovery required", err) + } + failed := fixture.Snapshot() + if failed.State.ObjectiveBinding == nil || !failed.State.ObjectiveBinding.Matches(base) || failed.State.Recovery == nil { + t.Fatalf("control-law objective-lifecycle-failure: failed advance moved the binding or lost recovery: %#v", failed.State) + } + reopened := fixture.Reopen(t) + recoveryReceipt := applyLifecycle(t, reopened, fixture, fixture.RecoveryTransition, nil) + assertLifecycleReceipt(t, recoveryReceipt, kernel.PreserveObjective, lifecycleBindingOf(t, base), nil, lifecycleBindingOf(t, base)) + assertLifecycleAccepted(t, fixture, base, "v1", bindReceipt) + retryReceipt := applyLifecycle(t, reopened, fixture, fixture.AdvanceTransition, &next) + assertLifecycleAccepted(t, fixture, next, "v2", retryReceipt) + }) + } +} + +// replay proves settled and cross-instance prescriptions cannot re-commit. +func (suite ObjectiveLifecycleConformance) replay(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + request, prescription := resolveLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err != nil { + t.Fatal(err) + } + before := fixture.Snapshot() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err == nil { + t.Fatal("settled lifecycle prescription replayed") + } + if after := fixture.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("control-law objective-lifecycle-replay: replay changed accepted facts: before=%#v after=%#v", before, after) + } + + t.Run("cross instance", func(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + request, prescription := resolveLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + fixture.RetargetInstance("other-lifecycle") + request.InstanceID = "other-lifecycle" + before := fixture.Snapshot() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); !kernel.IsStale(err) { + t.Fatalf("cross-instance lifecycle replay = %v, want stale", err) + } + if after := fixture.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("control-law objective-lifecycle-instance-replay: replay changed the retargeted instance: before=%#v after=%#v", before, after) + } + }) +} + +// restart proves a fresh runtime over the same durable ports reconstructs the +// exact latest accepted revision and continues the lineage from it. +func (suite ObjectiveLifecycleConformance) restart(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + advanceReceipt := applyLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + before, err := fixture.Accepted() + if err != nil { + t.Fatal(err) + } + reopened := fixture.Reopen(t) + after, err := fixture.Accepted() + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("control-law objective-lifecycle-restart: accepted value changed across restart: before=%#v after=%#v error=%v", before, after, err) + } + assertLifecycleAccepted(t, fixture, next, "v2", advanceReceipt) + third := stageLifecycleValue(t, fixture, "root", 3, "v3") + continued := applyLifecycle(t, reopened, fixture, fixture.AdvanceTransition, &third) + assertLifecycleReceipt(t, continued, kernel.AdvanceObjective, lifecycleBindingOf(t, next), lifecycleBindingOf(t, third), lifecycleBindingOf(t, third)) + assertLifecycleAccepted(t, fixture, third, "v3", continued) +} + +// receiptChain proves the full lifecycle journey commits one continuous, +// independently verifiable receipt chain, and that a post-clear rebind starts +// a new lineage rather than resurrecting the cleared one. +func (suite ObjectiveLifecycleConformance) receiptChain(t *testing.T) { + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + replacement := stageLifecycleValue(t, fixture, "other", 1, "w1") + rebound := stageLifecycleValue(t, fixture, "reborn", 1, "r1") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + applyLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + applyLifecycle(t, runtime, fixture, fixture.ReplaceTransition, &replacement) + applyLifecycle(t, runtime, fixture, fixture.ClearTransition, nil) + assertLifecycleUnbound(t, fixture) + rebindReceipt := applyLifecycle(t, runtime, fixture, fixture.BindTransition, &rebound) + + receipts := fixture.Snapshot().Receipts + wantMutations := []kernel.ObjectiveMutation{ + kernel.BindInitialObjective, kernel.AdvanceObjective, kernel.ReplaceObjective, + kernel.ClearObjectiveMutation, kernel.BindInitialObjective, + } + if len(receipts) != len(wantMutations) { + t.Fatalf("control-law objective-lifecycle-chain: %d receipts committed, want %d", len(receipts), len(wantMutations)) + } + prescriptions := map[string]bool{} + var chain *kernel.ObjectiveBinding + for index, receipt := range receipts { + if err := receipt.Validate(); err != nil { + t.Fatalf("control-law objective-lifecycle-chain: receipt %d is invalid: %v", index, err) + } + if receipt.ObjectiveMutation != wantMutations[index] { + t.Fatalf("control-law objective-lifecycle-chain: receipt %d declares %q, want %q", index, receipt.ObjectiveMutation, wantMutations[index]) + } + if prescriptions[receipt.PrescriptionID] { + t.Fatalf("control-law objective-lifecycle-chain: prescription %q settled twice", receipt.PrescriptionID) + } + prescriptions[receipt.PrescriptionID] = true + if index > 0 && receipt.PriorStateRevision != receipts[index-1].ResultStateRevision { + t.Fatalf("control-law objective-lifecycle-chain: receipt %d does not continue the state revision chain", index) + } + if !reflect.DeepEqual(receipt.PriorObjectiveBinding, chain) { + t.Fatalf("control-law objective-lifecycle-chain: receipt %d breaks binding lineage continuity", index) + } + chain = receipt.ResultObjectiveBinding + } + assertLifecycleAccepted(t, fixture, rebound, "r1", rebindReceipt) +} + +// readerFailsClosed proves the accepted reader rejects forged, missing, or +// relabeled lifecycle evidence, and that a relabeled relation cannot even +// produce a valid receipt. +func (suite ObjectiveLifecycleConformance) readerFailsClosed(t *testing.T) { + prepare := func(t *testing.T) (ObjectiveLifecycleConformance, kernel.Receipt) { + t.Helper() + fixture := suite.fresh(t) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + runtime := fixture.Reopen(t) + applyLifecycle(t, runtime, fixture, fixture.BindTransition, &base) + return fixture, applyLifecycle(t, runtime, fixture, fixture.AdvanceTransition, &next) + } + rehash := func(t *testing.T, receipt kernel.Receipt) kernel.Receipt { + t.Helper() + receipt.ID = "" + digest, err := kernel.Fingerprint(receipt) + if err != nil { + t.Fatal(err) + } + receipt.ID = "rcp-" + digest + return receipt + } + t.Run("missing receipts", func(t *testing.T) { + fixture, _ := prepare(t) + fixture.RemoveCommittedReceipts() + if accepted, err := fixture.Accepted(); err == nil { + t.Fatalf("control-law objective-lifecycle-reader-closed: missing receipts resolved as accepted: %#v", accepted) + } + }) + t.Run("relabeled relation fails receipt validation", func(t *testing.T) { + _, receipt := prepare(t) + forged := receipt + forged.ObjectiveMutation = kernel.ReplaceObjective + forged = rehash(t, forged) + if err := forged.Validate(); err == nil { + t.Fatal("control-law objective-lifecycle-receipt: advance receipt relabeled as replace validated") + } + }) + t.Run("substituted relation breaks the chain", func(t *testing.T) { + fixture, receipt := prepare(t) + decoy := stageLifecycleValue(t, fixture, "decoy", 1, "d1") + forged := receipt + forged.ObjectiveMutation = kernel.ReplaceObjective + forged.RequestedObjectiveBinding = lifecycleBindingOf(t, decoy) + forged.ResultObjectiveBinding = lifecycleBindingOf(t, decoy) + forged = rehash(t, forged) + fixture.SubstituteCommittedReceipt(forged) + if accepted, err := fixture.Accepted(); err == nil { + t.Fatalf("control-law objective-lifecycle-reader-closed: substituted relation resolved as accepted: %#v", accepted) + } + }) +} diff --git a/boatstack/kernel/conformance/lifecycle_law_test.go b/boatstack/kernel/conformance/lifecycle_law_test.go new file mode 100644 index 00000000..9dad7299 --- /dev/null +++ b/boatstack/kernel/conformance/lifecycle_law_test.go @@ -0,0 +1,141 @@ +package conformance + +import ( + "context" + "testing" + + "github.com/operatorstack/boatstack/boatstack/kernel" +) + +// applyLifecycleThrough drives one honest lifecycle relation through the given +// runtime and returns the committed receipt. +func applyLifecycleThrough(t *testing.T, fixture ObjectiveLifecycleConformance, runtime kernel.Runtime, transition string, objective *kernel.Objective) kernel.Receipt { + t.Helper() + request, prescription := resolveLifecycle(t, runtime, fixture, transition, objective) + receipt, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + if err != nil { + t.Fatal(err) + } + return receipt +} + +// A store that persists the lifecycle state without its receipt must fail the +// accepted-evidence oracle: the binding exists but no receipt chain proves the +// declared relation. +func TestLifecycleSuiteRejectsTornStateReceiptCommit(t *testing.T) { + fixture, ports := newLifecycleRegisterFixture() + runtime, err := kernel.NewRuntime(ports.program, ports.domain, ports.operator, ports.classifier, + tornSettlementStore{inner: ports.store}, &settlementLocker{}, ports.clock) + if err != nil { + t.Fatal(err) + } + objective := stageLifecycleValue(t, fixture, "root", 1, "v1") + applyLifecycleThrough(t, fixture, runtime, fixture.BindTransition, &objective) + if accepted, acceptErr := fixture.Accepted(); acceptErr == nil { + t.Fatalf("torn lifecycle commit without a durable receipt passed the accepted-evidence oracle: %#v", accepted) + } +} + +// A store that ignores the compare-and-swap revision lets two lifecycle +// successors from one base both commit. The receipt chain then proves the +// violation: the second relation does not extend the first one's lineage. +func TestLifecycleSuiteRejectsBlindConcurrentSuccessorCommit(t *testing.T) { + fixture, ports := newLifecycleRegisterFixture() + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + successorA := stageLifecycleValue(t, fixture, "root", 2, "v2-a") + successorB := stageLifecycleValue(t, fixture, "root", 2, "v2-b") + resolver := fixture.Reopen(t) + applyLifecycleThrough(t, fixture, resolver, fixture.BindTransition, &base) + requestA, prescriptionA := resolveLifecycle(t, resolver, fixture, fixture.AdvanceTransition, &successorA) + requestB, prescriptionB := resolveLifecycle(t, resolver, fixture, fixture.AdvanceTransition, &successorB) + store := blindSettlementStore{inner: ports.store, barrier: newTwoPartyBarrier()} + applications := []kernel.ApplyRequest{ + {ResolveRequest: requestA, Prescription: prescriptionA}, + {ResolveRequest: requestB, Prescription: prescriptionB}, + } + errs := make(chan error, len(applications)) + for _, application := range applications { + application := application + runtime, err := kernel.NewRuntime(ports.program, ports.domain, ports.operator, ports.classifier, + store, &settlementLocker{}, ports.clock) + if err != nil { + t.Fatal(err) + } + go func() { + _, applyErr := runtime.Apply(context.Background(), application) + errs <- applyErr + }() + } + for range applications { + if err := <-errs; err != nil { + t.Fatalf("blind store did not admit both successors: %v", err) + } + } + if accepted, err := fixture.Accepted(); err == nil { + t.Fatalf("blind commit that settled two successors passed the lineage-continuity oracle: %#v", accepted) + } +} + +// resurrectingLifecycleStore acknowledges a committed clear but silently keeps +// the prior binding, resurrecting cleared state. +type resurrectingLifecycleStore struct{ inner *registerStore } + +func (s resurrectingLifecycleStore) Load(ctx context.Context, instanceID string) (kernel.ControlState, error) { + return s.inner.Load(ctx, instanceID) +} + +func (s resurrectingLifecycleStore) BeginEffect(ctx context.Context, revision uint64, target kernel.ControlState) error { + return s.inner.BeginEffect(ctx, revision, target) +} + +func (s resurrectingLifecycleStore) CommitTransition(ctx context.Context, revision uint64, target kernel.ControlState, receipt kernel.Receipt) error { + s.inner.mu.Lock() + if target.ObjectiveBinding == nil && s.inner.state.ObjectiveBinding != nil { + binding := *s.inner.state.ObjectiveBinding + target.ObjectiveBinding = &binding + } + s.inner.mu.Unlock() + return s.inner.CommitTransition(ctx, revision, target, receipt) +} + +func TestLifecycleSuiteRejectsClearedBindingResurrection(t *testing.T) { + fixture, ports := newLifecycleRegisterFixture() + runtime, err := kernel.NewRuntime(ports.program, ports.domain, ports.operator, ports.classifier, + resurrectingLifecycleStore{inner: ports.store}, &settlementLocker{}, ports.clock) + if err != nil { + t.Fatal(err) + } + objective := stageLifecycleValue(t, fixture, "root", 1, "v1") + applyLifecycleThrough(t, fixture, runtime, fixture.BindTransition, &objective) + applyLifecycleThrough(t, fixture, runtime, fixture.ClearTransition, nil) + snapshot := fixture.Snapshot() + if snapshot.State.ObjectiveBinding == nil { + t.Fatal("resurrecting store variant did not keep the cleared binding") + } + if accepted, acceptErr := fixture.Accepted(); acceptErr == nil { + t.Fatalf("resurrected cleared binding passed the lineage oracle: %#v", accepted) + } +} + +// A reopen path that resets durable lifecycle state must fail the restart +// reconstruction oracle instead of silently forgetting the accepted revision. +func TestLifecycleSuiteRejectsRestartStateReset(t *testing.T) { + fixture, ports := newLifecycleRegisterFixture() + honestReopen := fixture.Reopen + fixture.Reopen = func(t testing.TB) kernel.Runtime { + ports.store.reset(ports.initial) + return honestReopen(t) + } + runtime := fixture.Runtime(t, fixture.Program, fixture.IndependentLocker()) + base := stageLifecycleValue(t, fixture, "root", 1, "v1") + next := stageLifecycleValue(t, fixture, "root", 2, "v2") + applyLifecycleThrough(t, fixture, runtime, fixture.BindTransition, &base) + applyLifecycleThrough(t, fixture, runtime, fixture.AdvanceTransition, &next) + if _, err := fixture.Accepted(); err != nil { + t.Fatal(err) + } + fixture.Reopen(t) + if accepted, err := fixture.Accepted(); err == nil { + t.Fatalf("restart state reset passed the reconstruction oracle: %#v", accepted) + } +} diff --git a/boatstack/kernel/conformance/lifecycle_register.go b/boatstack/kernel/conformance/lifecycle_register.go new file mode 100644 index 00000000..d407b9b6 --- /dev/null +++ b/boatstack/kernel/conformance/lifecycle_register.go @@ -0,0 +1,447 @@ +package conformance + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "sync" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/kernel" +) + +const ( + lifecycleBindTransition = "lifecycle.bind" + lifecycleAdvanceTransition = "lifecycle.advance" + lifecycleReplaceTransition = "lifecycle.replace" + lifecycleClearTransition = "lifecycle.clear" + lifecycleRecoverTransition = "lifecycle.recover" + lifecycleMutateOperation = "lifecycle.mutate" + lifecycleRecoverOperation = "lifecycle.recover" + lifecycleClearedEvidence = "lifecycle-cleared" +) + +// ObjectiveLifecycleConformance binds a chat-blind multi-revision register to +// the reusable objective lifecycle laws. Every binding mutation must declare +// exactly one lifecycle relation, satisfy that relation's own capability, and +// commit a receipt that independently proves the relation. +type ObjectiveLifecycleConformance struct { + Program kernel.Program + InstanceID string + Authority kernel.Authority + + BindTransition string + AdvanceTransition string + ReplaceTransition string + ClearTransition string + RecoveryTransition string + + New func(testing.TB) ObjectiveLifecycleConformance + Stage func(id string, revision uint64, value any) (kernel.Objective, error) + Accepted func() (AcceptedValue, error) + Snapshot func() SettlementSnapshot + Runtime func(testing.TB, kernel.Program, kernel.Locker) kernel.Runtime + Reopen func(testing.TB) kernel.Runtime + IndependentLocker func() kernel.Locker + FailNextVerification func() + FailNextCommit func() + RetargetInstance func(string) + RemoveCommittedReceipts func() + SubstituteCommittedReceipt func(kernel.Receipt) +} + +type lifecycleCandidates struct { + mu sync.Mutex + byObjective map[string]registerCandidate +} + +func newLifecycleCandidates() *lifecycleCandidates { + return &lifecycleCandidates{byObjective: map[string]registerCandidate{}} +} + +func (s *lifecycleCandidates) stage(id string, revision uint64, value any) (kernel.Objective, error) { + content, err := json.Marshal(value) + if err != nil { + return kernel.Objective{}, err + } + sum := sha256.Sum256(content) + contentFingerprint := hex.EncodeToString(sum[:]) + objective, err := kernel.NewObjective(id, revision, struct { + CandidateFingerprint string `json:"candidate_fingerprint"` + }{contentFingerprint}) + if err != nil { + return kernel.Objective{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.byObjective[objective.Fingerprint]; ok { + if existing.ContentFingerprint != contentFingerprint || !reflect.DeepEqual(existing.Content, json.RawMessage(content)) { + return kernel.Objective{}, fmt.Errorf("immutable candidate identity already contains different content") + } + return existing.Objective, nil + } + s.byObjective[objective.Fingerprint] = registerCandidate{ + Objective: objective, ContentFingerprint: contentFingerprint, + Content: append(json.RawMessage(nil), content...), + } + return objective, nil +} + +func (s *lifecycleCandidates) resolve(objective kernel.Objective) (registerCandidate, error) { + if err := objective.Validate(); err != nil { + return registerCandidate{}, err + } + s.mu.Lock() + candidate, ok := s.byObjective[objective.Fingerprint] + s.mu.Unlock() + if !ok || !candidate.ValidateIs(objective) { + return registerCandidate{}, fmt.Errorf("exact immutable candidate is unavailable") + } + sum := sha256.Sum256(candidate.Content) + if hex.EncodeToString(sum[:]) != candidate.ContentFingerprint { + return registerCandidate{}, fmt.Errorf("immutable candidate fingerprint mismatch") + } + candidate.Content = append(json.RawMessage(nil), candidate.Content...) + return candidate, nil +} + +func (s *lifecycleCandidates) resolveBinding(binding kernel.ObjectiveBinding) (registerCandidate, error) { + s.mu.Lock() + candidate, ok := s.byObjective[binding.ObjectiveFingerprint] + s.mu.Unlock() + if !ok || !binding.Matches(candidate.Objective) { + return registerCandidate{}, fmt.Errorf("objective binding does not identify a staged immutable candidate") + } + return s.resolve(candidate.Objective) +} + +func (s *lifecycleCandidates) identities() []string { + s.mu.Lock() + defer s.mu.Unlock() + identities := make([]string, 0, len(s.byObjective)) + for identity := range s.byObjective { + identities = append(identities, identity) + } + sort.Strings(identities) + return identities +} + +type lifecycleDomain struct { + candidates *lifecycleCandidates + mu sync.Mutex + failVerification bool + verifications map[string]int +} + +func (d *lifecycleDomain) Observe(context.Context, string) (kernel.Observation, error) { + return kernel.NewObservation(struct { + Candidates []string `json:"candidates"` + }{d.candidates.identities()}) +} + +func (d *lifecycleDomain) Admissible(_ context.Context, evaluation kernel.Evaluation) (bool, string, error) { + switch evaluation.Transition.Operation { + case lifecycleMutateOperation: + if evaluation.Objective == nil { + // Clear carries no objective projection; the kernel already + // required an existing binding before this evaluation. + return true, "binding removal requires no candidate", nil + } + if _, err := d.candidates.resolve(*evaluation.Objective); err != nil { + return false, err.Error(), nil + } + return true, "exact immutable candidate is available", nil + case lifecycleRecoverOperation: + return evaluation.State.Recovery != nil, "an unresolved lifecycle attempt requires recovery", nil + default: + return false, "unknown lifecycle operation", nil + } +} + +func (d *lifecycleDomain) Verify(_ context.Context, evaluation kernel.Evaluation, effect kernel.Effect, target kernel.Observation) error { + d.mu.Lock() + d.verifications[evaluation.Transition.ID]++ + fail := d.failVerification + d.failVerification = false + d.mu.Unlock() + if fail { + return fmt.Errorf("simulated deterministic verification rejection") + } + if target.Fingerprint != evaluation.Observation.Fingerprint { + return fmt.Errorf("candidate inventory changed during execution") + } + switch evaluation.Transition.Operation { + case lifecycleMutateOperation: + want := kernel.EffectFact{Facet: "lifecycle.value", Operation: lifecycleMutateOperation, Fingerprint: lifecycleClearedEvidence} + if evaluation.Objective != nil { + candidate, err := d.candidates.resolve(*evaluation.Objective) + if err != nil { + return err + } + want.Fingerprint = candidate.ContentFingerprint + } + if len(effect.Facts) != 1 || effect.Facts[0] != want { + return fmt.Errorf("lifecycle mutation fact does not identify the exact content") + } + case lifecycleRecoverOperation: + if len(effect.Facts) != 1 || effect.Facts[0].Operation != lifecycleRecoverOperation { + return fmt.Errorf("recovery fact is incomplete") + } + default: + return fmt.Errorf("unknown lifecycle verification operation") + } + return nil +} + +func (d *lifecycleDomain) failNext() { + d.mu.Lock() + defer d.mu.Unlock() + d.failVerification = true +} + +func (d *lifecycleDomain) snapshot() map[string]int { + d.mu.Lock() + defer d.mu.Unlock() + result := make(map[string]int, len(d.verifications)) + for transition, count := range d.verifications { + result[transition] = count + } + return result +} + +type lifecycleOperator struct { + candidates *lifecycleCandidates + mu sync.Mutex + executions map[string]int +} + +func (o *lifecycleOperator) Execute(_ context.Context, operation kernel.Operation) (kernel.Effect, error) { + o.mu.Lock() + o.executions[operation.Transition.ID]++ + o.mu.Unlock() + switch operation.Transition.Operation { + case lifecycleMutateOperation: + fingerprint := lifecycleClearedEvidence + if operation.Objective != nil { + candidate, err := o.candidates.resolve(*operation.Objective) + if err != nil { + return kernel.Effect{}, err + } + fingerprint = candidate.ContentFingerprint + } + return kernel.Effect{Facts: []kernel.EffectFact{{ + Facet: "lifecycle.value", Operation: lifecycleMutateOperation, Fingerprint: fingerprint, + }}}, nil + case lifecycleRecoverOperation: + return kernel.Effect{Facts: []kernel.EffectFact{{ + Facet: "lifecycle.value", Operation: lifecycleRecoverOperation, Fingerprint: "recovery-complete", + }}}, nil + default: + return kernel.Effect{}, fmt.Errorf("unknown lifecycle operation") + } +} + +func (o *lifecycleOperator) snapshot() map[string]int { + o.mu.Lock() + defer o.mu.Unlock() + result := make(map[string]int, len(o.executions)) + for transition, count := range o.executions { + result[transition] = count + } + return result +} + +// lifecycleCapabilities is the trusted classifier: every lifecycle relation +// requires its own kernel capability and the shared apply capability. +type lifecycleCapabilities struct{} + +func (lifecycleCapabilities) RequiredCapabilities(transition kernel.Transition) ([]kernel.Capability, error) { + switch transition.Operation { + case lifecycleMutateOperation: + required := []kernel.Capability{"lifecycle.apply"} + if capability, mutates := transition.ObjectiveMutation.Capability(); mutates { + required = append(required, capability) + } + return required, nil + case lifecycleRecoverOperation: + return []kernel.Capability{"lifecycle.recover"}, nil + default: + return nil, fmt.Errorf("unclassified lifecycle operation %q", transition.Operation) + } +} + +// LifecycleRegisterFixture returns the domain-neutral objective lifecycle +// fixture over a revisioned register. +func LifecycleRegisterFixture() ObjectiveLifecycleConformance { + fixture, _ := newLifecycleRegisterFixture() + return fixture +} + +// lifecyclePorts exposes the concrete fixture ports so white-box +// counterexample tests can wire dishonest variants against the same laws. +type lifecyclePorts struct { + program kernel.Program + initial kernel.ControlState + candidates *lifecycleCandidates + domain *lifecycleDomain + operator *lifecycleOperator + classifier lifecycleCapabilities + store *registerStore + clock settlementClock +} + +func newLifecycleRegisterFixture() (ObjectiveLifecycleConformance, lifecyclePorts) { + program, err := compileLifecycleProgram() + if err != nil { + panic(err) + } + instanceID := "objective-lifecycle" + now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + authority := kernel.Authority{Receipts: []kernel.AuthorityReceipt{{ + ID: "lifecycle-authority", Subject: "fixture", Fingerprint: "lifecycle-authority", + Capabilities: []kernel.Capability{ + "objective.bind", "objective.advance", "objective.replace", "objective.clear", + "lifecycle.apply", "lifecycle.recover", + }, + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} + candidates := newLifecycleCandidates() + domain := &lifecycleDomain{candidates: candidates, verifications: map[string]int{}} + operator := &lifecycleOperator{candidates: candidates, executions: map[string]int{}} + initial := kernel.ControlState{InstanceID: instanceID, Program: program.Identity(), Mode: "supervising", Revision: 1} + store := ®isterStore{state: cloneSettlementState(initial)} + clock := settlementClock{now: now} + classifier := lifecycleCapabilities{} + + runtimeFor := func(t testing.TB, selected kernel.Program, selectedLocker kernel.Locker) kernel.Runtime { + t.Helper() + runtime, runtimeErr := kernel.NewRuntime(selected, domain, operator, classifier, store, selectedLocker, clock) + if runtimeErr != nil { + t.Fatalf("construct lifecycle runtime: %v", runtimeErr) + } + return runtime + } + snapshot := func() SettlementSnapshot { + state, receipts, commits := store.snapshot() + observation, observeErr := domain.Observe(context.Background(), state.InstanceID) + if observeErr != nil { + panic(observeErr) + } + return SettlementSnapshot{ + State: state, Observation: observation, Receipts: receipts, + Effects: operator.snapshot(), Verifications: domain.snapshot(), CommitCount: commits, + } + } + accepted := func() (AcceptedValue, error) { + state, receipts, _ := store.snapshot() + final, err := lifecycleChainResult(receipts) + if err != nil { + return AcceptedValue{}, err + } + if state.ObjectiveBinding == nil { + return AcceptedValue{}, fmt.Errorf("accepted objective binding is absent") + } + if final == nil || !reflect.DeepEqual(final.ResultObjectiveBinding, state.ObjectiveBinding) { + return AcceptedValue{}, fmt.Errorf("committed receipt chain does not prove the durable objective binding") + } + if final.InstanceID != state.InstanceID { + return AcceptedValue{}, fmt.Errorf("committed receipt chain belongs to a different instance") + } + candidate, resolveErr := candidates.resolveBinding(*state.ObjectiveBinding) + if resolveErr != nil { + return AcceptedValue{}, resolveErr + } + return AcceptedValue{ + Binding: *state.ObjectiveBinding, CandidateFingerprint: candidate.ContentFingerprint, + Content: append(json.RawMessage(nil), candidate.Content...), Receipt: *final, + }, nil + } + + fixture := ObjectiveLifecycleConformance{ + Program: program, InstanceID: instanceID, Authority: authority, + BindTransition: lifecycleBindTransition, AdvanceTransition: lifecycleAdvanceTransition, + ReplaceTransition: lifecycleReplaceTransition, ClearTransition: lifecycleClearTransition, + RecoveryTransition: lifecycleRecoverTransition, + Stage: candidates.stage, Accepted: accepted, Snapshot: snapshot, + Runtime: runtimeFor, + Reopen: func(t testing.TB) kernel.Runtime { return runtimeFor(t, program, &settlementLocker{}) }, + IndependentLocker: func() kernel.Locker { return &settlementLocker{} }, + FailNextVerification: domain.failNext, FailNextCommit: store.failNextCommit, + RetargetInstance: store.retargetInstance, + RemoveCommittedReceipts: store.removeReceipts, + SubstituteCommittedReceipt: store.substituteLastReceipt, + } + fixture.New = func(testing.TB) ObjectiveLifecycleConformance { + fresh, _ := newLifecycleRegisterFixture() + return fresh + } + ports := lifecyclePorts{ + program: program, initial: cloneSettlementState(initial), + candidates: candidates, domain: domain, operator: operator, + classifier: classifier, store: store, clock: clock, + } + return fixture, ports +} + +// lifecycleChainResult validates the committed receipt chain and returns the +// last binding-mutation receipt. Every receipt must validate, revisions must +// be strictly increasing, and each declared lifecycle relation must extend +// the exact lineage produced by the previous relation. +func lifecycleChainResult(receipts []kernel.Receipt) (*kernel.Receipt, error) { + var chain *kernel.ObjectiveBinding + var final *kernel.Receipt + var lastResultRevision uint64 + for index := range receipts { + receipt := receipts[index] + if err := receipt.Validate(); err != nil { + return nil, fmt.Errorf("committed receipt %q is invalid: %w", receipt.ID, err) + } + if receipt.PriorStateRevision < lastResultRevision { + return nil, fmt.Errorf("committed receipt %q rewinds the state revision", receipt.ID) + } + lastResultRevision = receipt.ResultStateRevision + if receipt.ObjectiveMutation == kernel.PreserveObjective { + continue + } + if !reflect.DeepEqual(receipt.PriorObjectiveBinding, chain) { + return nil, fmt.Errorf("committed receipt %q breaks objective lineage continuity", receipt.ID) + } + chain = receipt.ResultObjectiveBinding + final = &receipts[index] + } + return final, nil +} + +func compileLifecycleProgram() (kernel.Program, error) { + mutating := func(id string, mutation kernel.ObjectiveMutation, priority int) kernel.Transition { + capability, _ := mutation.Capability() + return kernel.Transition{ + ID: id, SourceModes: []string{"supervising"}, TargetMode: "supervising", + ObjectiveScope: kernel.ObjectiveNone, ObjectiveMutation: mutation, + RequiredCapabilities: []kernel.Capability{capability, "lifecycle.apply"}, + OwnedFacets: []string{"lifecycle.value", "supervisor.objective"}, + Operation: lifecycleMutateOperation, SelectionRank: 1, Selection: kernel.SelectionExplicitOnly, Priority: priority, + } + } + return kernel.CompileProgram("objective-lifecycle", "1.0.0", "kernel-v1", "supervising", []string{"retired"}, []kernel.Transition{ + mutating(lifecycleBindTransition, kernel.BindInitialObjective, 1), + mutating(lifecycleAdvanceTransition, kernel.AdvanceObjective, 2), + mutating(lifecycleReplaceTransition, kernel.ReplaceObjective, 3), + mutating(lifecycleClearTransition, kernel.ClearObjectiveMutation, 4), + { + ID: lifecycleRecoverTransition, SourceModes: []string{"supervising"}, TargetMode: "supervising", + ObjectiveScope: kernel.ObjectiveOptionalPreserve, ObjectiveMutation: kernel.PreserveObjective, + RequiredCapabilities: []kernel.Capability{"lifecycle.recover"}, OwnedFacets: []string{"lifecycle.value"}, + Operation: lifecycleRecoverOperation, SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 10, + Recovers: []string{ + lifecycleBindTransition, lifecycleAdvanceTransition, lifecycleReplaceTransition, + lifecycleClearTransition, lifecycleRecoverTransition, + }, + }, + }) +} diff --git a/boatstack/kernel/conformance/revisioned_register.go b/boatstack/kernel/conformance/revisioned_register.go index e4f7acbb..b94b45b7 100644 --- a/boatstack/kernel/conformance/revisioned_register.go +++ b/boatstack/kernel/conformance/revisioned_register.go @@ -575,7 +575,7 @@ func compileRegisterProgram(priority int) (kernel.Program, error) { return kernel.CompileProgram("revisioned-register", "1.0.0", "kernel-v1", "unbound", []string{"accepted"}, []kernel.Transition{ { ID: registerAcceptTransition, SourceModes: []string{"unbound"}, TargetMode: "accepted", - ObjectiveScope: kernel.ObjectiveNone, ObjectiveMutation: kernel.BindObjectiveMutation, + ObjectiveScope: kernel.ObjectiveNone, ObjectiveMutation: kernel.BindInitialObjective, RequiredCapabilities: []kernel.Capability{"objective.bind", "register.inspect"}, OwnedFacets: []string{"register.candidate", "supervisor.objective"}, Operation: registerInspectOperation, SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: priority, diff --git a/boatstack/kernel/objective_lifecycle_test.go b/boatstack/kernel/objective_lifecycle_test.go new file mode 100644 index 00000000..a2d16754 --- /dev/null +++ b/boatstack/kernel/objective_lifecycle_test.go @@ -0,0 +1,11 @@ +package kernel_test + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/kernel/conformance" +) + +func TestObjectiveLifecycleConformance(t *testing.T) { + conformance.LifecycleRegisterFixture().Run(t) +} diff --git a/boatstack/kernel/program.go b/boatstack/kernel/program.go index 07cb04d3..fe975e5c 100644 --- a/boatstack/kernel/program.go +++ b/boatstack/kernel/program.go @@ -35,16 +35,49 @@ func (m SelectionMode) valid() bool { return m == SelectionImplicit || m == SelectionExplicitOnly } +// ObjectiveMutation is the exact lifecycle relation a transition declares +// against the durable objective binding. Every relation other than preserve +// names one specific binding change, carries its own capability, and is +// provable from the committed receipt alone. type ObjectiveMutation string const ( - PreserveObjective ObjectiveMutation = "preserve" - BindObjectiveMutation ObjectiveMutation = "bind" + // PreserveObjective leaves the binding untouched. + PreserveObjective ObjectiveMutation = "preserve" + // BindInitialObjective binds a first objective where none is bound. + BindInitialObjective ObjectiveMutation = "bind-initial" + // AdvanceObjective moves the bound objective to exactly the next revision + // of the same objective identity. + AdvanceObjective ObjectiveMutation = "advance" + // ReplaceObjective rebinds to a different objective identity and must be + // declared as replacement rather than inferred from bind or advance. + ReplaceObjective ObjectiveMutation = "replace" + // ClearObjectiveMutation removes an existing binding. ClearObjectiveMutation ObjectiveMutation = "clear" ) func (m ObjectiveMutation) valid() bool { - return m == PreserveObjective || m == BindObjectiveMutation || m == ClearObjectiveMutation + switch m { + case PreserveObjective, BindInitialObjective, AdvanceObjective, ReplaceObjective, ClearObjectiveMutation: + return true + } + return false +} + +// Capability returns the exact capability the lifecycle relation requires. +// Preserve requires none: it is the absence of a binding mutation. +func (m ObjectiveMutation) Capability() (Capability, bool) { + switch m { + case BindInitialObjective: + return "objective.bind", true + case AdvanceObjective: + return "objective.advance", true + case ReplaceObjective: + return "objective.replace", true + case ClearObjectiveMutation: + return "objective.clear", true + } + return "", false } func (t Transition) validate() error { @@ -67,8 +100,10 @@ func (t Transition) validate() error { } seen[facet] = true } - if t.ObjectiveMutation != PreserveObjective && (t.ObjectiveScope != ObjectiveNone || !contains(t.OwnedFacets, "supervisor.objective") || !containsCapability(t.RequiredCapabilities, "objective.bind")) { - return fmt.Errorf("transition %q objective mutation requires NONE scope, supervisor.objective ownership, and objective.bind capability", t.ID) + if capability, mutates := t.ObjectiveMutation.Capability(); mutates { + if t.ObjectiveScope != ObjectiveNone || !contains(t.OwnedFacets, "supervisor.objective") || !containsCapability(t.RequiredCapabilities, capability) { + return fmt.Errorf("transition %q objective mutation %q requires NONE scope, supervisor.objective ownership, and %s capability", t.ID, t.ObjectiveMutation, capability) + } } for _, recovered := range t.Recovers { if !qualifiedSemanticID.MatchString(recovered) { diff --git a/boatstack/kernel/receipt_test.go b/boatstack/kernel/receipt_test.go index d05d5782..03a646b7 100644 --- a/boatstack/kernel/receipt_test.go +++ b/boatstack/kernel/receipt_test.go @@ -12,7 +12,7 @@ func TestReceiptRejectsLegacySchemaAndInvalidObjectiveLineage(t *testing.T) { base := Receipt{ SchemaVersion: ReceiptSchemaVersion, InstanceID: "settlement", PrescriptionID: "prx-test", Program: ProgramIdentity{ID: "settlement", Version: "1", Fingerprint: strings.Repeat("c", 64)}, - TransitionID: "register.accept", PriorStateRevision: 1, AttemptStateRevision: 2, ResultStateRevision: 3, + TransitionID: "register.accept", ObjectiveMutation: BindInitialObjective, PriorStateRevision: 1, AttemptStateRevision: 2, ResultStateRevision: 3, RequestedObjectiveBinding: bindingB, ResultObjectiveBinding: bindingB, AuthorityFingerprint: "authority", Capabilities: []Capability{"register.accept"}, Effects: []EffectFact{{Facet: "register.candidate", Operation: "register.inspect", Fingerprint: strings.Repeat("d", 64)}}, @@ -34,7 +34,15 @@ func TestReceiptRejectsLegacySchemaAndInvalidObjectiveLineage(t *testing.T) { t.Fatalf("valid receipt rejected: %v", valid.Validate()) } for name, mutate := range map[string]func(*Receipt){ - "legacy schema": func(receipt *Receipt) { receipt.SchemaVersion = 3 }, + "legacy schema": func(receipt *Receipt) { receipt.SchemaVersion = 3 }, + "undeclared lifecycle relation": func(receipt *Receipt) { receipt.ObjectiveMutation = "" }, + "initial bind over an existing binding": func(receipt *Receipt) { + receipt.PriorObjectiveBinding = bindingA + }, + "preserve that changes the binding": func(receipt *Receipt) { + receipt.ObjectiveMutation = PreserveObjective + receipt.RequestedObjectiveBinding = nil + }, "requested differs from result": func(receipt *Receipt) { receipt.RequestedObjectiveBinding = bindingA }, diff --git a/boatstack/kernel/runtime.go b/boatstack/kernel/runtime.go index ad573729..2809b748 100644 --- a/boatstack/kernel/runtime.go +++ b/boatstack/kernel/runtime.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "sort" "time" ) @@ -109,6 +110,7 @@ type Prescription struct { ID string `json:"id"` TransitionID string `json:"transition_id"` Freshness + ObjectiveMutation ObjectiveMutation `json:"objective_mutation"` ExpectedObjectiveBinding *ObjectiveBinding `json:"expected_objective_binding,omitempty"` RequestedObjectiveBinding *ObjectiveBinding `json:"requested_objective_binding,omitempty"` RequiredCapabilities []Capability `json:"required_capabilities"` @@ -142,6 +144,7 @@ type Receipt struct { PrescriptionID string `json:"prescription_id"` Program ProgramIdentity `json:"program"` TransitionID string `json:"transition_id"` + ObjectiveMutation ObjectiveMutation `json:"objective_mutation"` PriorStateRevision uint64 `json:"prior_state_revision"` AttemptStateRevision uint64 `json:"attempt_state_revision"` ResultStateRevision uint64 `json:"result_state_revision"` @@ -445,7 +448,7 @@ func (r Runtime) Apply(ctx context.Context, request ApplyRequest) (Receipt, erro target.Mode = transition.TargetMode target.Revision++ switch transition.ObjectiveMutation { - case BindObjectiveMutation: + case BindInitialObjective, AdvanceObjective, ReplaceObjective: binding, bindErr := BindObjective(*objective) if bindErr != nil { return Receipt{}, bindErr @@ -458,6 +461,7 @@ func (r Runtime) Apply(ctx context.Context, request ApplyRequest) (Receipt, erro receipt := Receipt{ SchemaVersion: ReceiptSchemaVersion, InstanceID: state.InstanceID, PrescriptionID: request.Prescription.ID, Program: state.Program, TransitionID: transition.ID, + ObjectiveMutation: transition.ObjectiveMutation, PriorStateRevision: state.Revision, AttemptStateRevision: attempt.Revision, ResultStateRevision: target.Revision, PriorObjectiveBinding: cloneBinding(state.ObjectiveBinding), RequestedObjectiveBinding: cloneBinding(request.Prescription.RequestedObjectiveBinding), ResultObjectiveBinding: cloneBinding(target.ObjectiveBinding), @@ -502,12 +506,8 @@ func (r Receipt) Validate() error { } } } - if r.RequestedObjectiveBinding != nil { - if !equalBinding(r.RequestedObjectiveBinding, r.ResultObjectiveBinding) { - return fmt.Errorf("receipt requested objective binding differs from its committed result") - } - } else if r.ResultObjectiveBinding != nil && !equalBinding(r.PriorObjectiveBinding, r.ResultObjectiveBinding) { - return fmt.Errorf("receipt synthesized an unrequested objective binding") + if err := r.validateObjectiveLifecycle(); err != nil { + return err } if _, err := normalizeCapabilities(r.Capabilities); err != nil { return err @@ -518,6 +518,56 @@ func (r Receipt) Validate() error { return nil } +// validateObjectiveLifecycle proves the declared lifecycle relation from the +// receipt's own binding lineage, without recovering transition semantics from +// a program body that may no longer be available. +func (r Receipt) validateObjectiveLifecycle() error { + prior, requested, result := r.PriorObjectiveBinding, r.RequestedObjectiveBinding, r.ResultObjectiveBinding + switch r.ObjectiveMutation { + case PreserveObjective: + if requested != nil { + return fmt.Errorf("receipt declares preserve but requests an objective binding") + } + if !equalBinding(prior, result) { + return fmt.Errorf("receipt declares preserve but the objective binding changed") + } + case BindInitialObjective: + if prior != nil { + return fmt.Errorf("receipt declares an initial bind over an existing objective binding") + } + if requested == nil || !equalBinding(requested, result) { + return fmt.Errorf("receipt declares an initial bind whose requested and result bindings disagree") + } + case AdvanceObjective: + if prior == nil || requested == nil || !equalBinding(requested, result) { + return fmt.Errorf("receipt declares an advancement without complete prior, requested, and result bindings") + } + if result.ObjectiveID != prior.ObjectiveID { + return fmt.Errorf("receipt declares an advancement that changes the objective identity") + } + if prior.ObjectiveRevision == math.MaxUint64 || result.ObjectiveRevision != prior.ObjectiveRevision+1 { + return fmt.Errorf("receipt declares an advancement that is not exactly the next revision") + } + case ReplaceObjective: + if prior == nil || requested == nil || !equalBinding(requested, result) { + return fmt.Errorf("receipt declares a replacement without complete prior, requested, and result bindings") + } + if result.ObjectiveID == prior.ObjectiveID { + return fmt.Errorf("receipt declares a replacement that keeps the objective identity") + } + case ClearObjectiveMutation: + if prior == nil { + return fmt.Errorf("receipt declares a clear without a prior objective binding") + } + if requested != nil || result != nil { + return fmt.Errorf("receipt declares a clear that retains an objective binding") + } + default: + return fmt.Errorf("receipt declares no valid objective lifecycle relation") + } + return nil +} + type StalePrescriptionError struct{ Reason string } func (e StalePrescriptionError) Error() string { return "stale prescription: " + e.Reason } @@ -540,9 +590,10 @@ func newPrescription(state ControlState, observation Observation, transition Tra if err != nil { return Prescription{}, err } - p := Prescription{SchemaVersion: PrescriptionSchemaVersion, TransitionID: transition.ID, Freshness: freshness, RequiredCapabilities: append([]Capability(nil), required...)} + p := Prescription{SchemaVersion: PrescriptionSchemaVersion, TransitionID: transition.ID, Freshness: freshness, ObjectiveMutation: transition.ObjectiveMutation, RequiredCapabilities: append([]Capability(nil), required...)} p.ExpectedObjectiveBinding = cloneBinding(state.ObjectiveBinding) - if transition.ObjectiveMutation == BindObjectiveMutation { + switch transition.ObjectiveMutation { + case BindInitialObjective, AdvanceObjective, ReplaceObjective: binding, err := BindObjective(*objective) if err != nil { return Prescription{}, err @@ -569,25 +620,59 @@ func (p Prescription) validateCurrent(state ControlState, observation Observatio } bindingFingerprint, bindingErr := Fingerprint(state.ObjectiveBinding) current, freshnessErr := NewFreshness(state.InstanceID, state.Revision, state.Program.Fingerprint, observation.Fingerprint, bindingFingerprint, authority.Fingerprint) - if p.SchemaVersion != PrescriptionSchemaVersion || bindingErr != nil || freshnessErr != nil || p.Freshness.Check(current) != nil || !equalBinding(p.ExpectedObjectiveBinding, state.ObjectiveBinding) { + if p.SchemaVersion != PrescriptionSchemaVersion || !p.ObjectiveMutation.valid() || bindingErr != nil || freshnessErr != nil || p.Freshness.Check(current) != nil || !equalBinding(p.ExpectedObjectiveBinding, state.ObjectiveBinding) { return StalePrescriptionError{Reason: "instance, state, program, objective binding, observation, or authority changed"} } return nil } func objectiveFor(state ControlState, supplied *Objective, transition Transition) (*Objective, string) { - if transition.ObjectiveMutation == BindObjectiveMutation { + switch transition.ObjectiveMutation { + case BindInitialObjective: if supplied == nil || supplied.Validate() != nil { - return nil, "objective binding requires one exact objective revision" + return nil, "initial objective binding requires one exact objective revision" } - if state.ObjectiveBinding != nil && state.ObjectiveBinding.Matches(*supplied) { - return nil, "objective revision is already bound" + if state.ObjectiveBinding != nil { + return nil, "initial objective binding requires a verified absent binding" } copy := *supplied return ©, "" - } - if transition.ObjectiveMutation == ClearObjectiveMutation && state.ObjectiveBinding == nil { - return nil, "objective binding is already absent" + case AdvanceObjective: + if supplied == nil || supplied.Validate() != nil { + return nil, "objective advancement requires one exact objective revision" + } + current := state.ObjectiveBinding + if current == nil { + return nil, "objective advancement requires an existing binding" + } + if supplied.ID != current.ObjectiveID { + return nil, "objective advancement cannot change the objective identity" + } + if current.ObjectiveRevision == math.MaxUint64 { + return nil, "objective advancement would overflow the bound revision" + } + if supplied.Revision != current.ObjectiveRevision+1 { + return nil, "objective advancement requires exactly the next revision" + } + copy := *supplied + return ©, "" + case ReplaceObjective: + if supplied == nil || supplied.Validate() != nil { + return nil, "objective replacement requires one exact objective revision" + } + current := state.ObjectiveBinding + if current == nil { + return nil, "objective replacement requires an existing binding" + } + if supplied.ID == current.ObjectiveID { + return nil, "objective replacement requires a different objective identity" + } + copy := *supplied + return ©, "" + case ClearObjectiveMutation: + if state.ObjectiveBinding == nil { + return nil, "objective binding is already absent" + } } switch transition.ObjectiveScope { case ObjectiveNone: diff --git a/boatstack/kernel/types.go b/boatstack/kernel/types.go index c574ea5a..877c16e5 100644 --- a/boatstack/kernel/types.go +++ b/boatstack/kernel/types.go @@ -16,8 +16,8 @@ import ( const ( ProgramSchemaVersion = 1 - PrescriptionSchemaVersion = 2 - ReceiptSchemaVersion = 4 + PrescriptionSchemaVersion = 3 + ReceiptSchemaVersion = 5 ) var ( diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 3748d57a..d5991dbf 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -45,5 +45,7 @@ the exact workspace to become `landed`. parse a plan, persist a slice cursor, define PR cardinality, or advance a publication sequence. -Recovery outranks ordinary progress. Objective reconfiguration is explicit and may -change an active delivery only with human or autonomy authority. +Recovery outranks ordinary progress. Objective reconfiguration is explicit: +`objective.bind` configures the first objective over verified absence, and +`objective.replace` declares reconfiguration to a different objective. Both +require human or autonomy authority. diff --git a/docs/architecture/boatstack-locus-liveness.json b/docs/architecture/boatstack-locus-liveness.json index ca5378ca..e227c1c8 100644 --- a/docs/architecture/boatstack-locus-liveness.json +++ b/docs/architecture/boatstack-locus-liveness.json @@ -268,6 +268,12 @@ "observable": true, "basis": "observed" }, + { + "id": "objective.replace", + "controllable": true, + "observable": true, + "basis": "observed" + }, { "id": "plan.abandon", "controllable": true, @@ -4410,6 +4416,204 @@ ], "basis": "inferred" }, + { + "from": "OBSERVED", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", "event": "plan.abandon", diff --git a/docs/architecture/boatstack-locus-safety.json b/docs/architecture/boatstack-locus-safety.json index 02e12975..bdd289d9 100644 --- a/docs/architecture/boatstack-locus-safety.json +++ b/docs/architecture/boatstack-locus-safety.json @@ -271,6 +271,12 @@ "observable": true, "basis": "observed" }, + { + "id": "objective.replace", + "controllable": true, + "observable": true, + "basis": "observed" + }, { "id": "plan.abandon", "controllable": true, @@ -4413,6 +4419,204 @@ ], "basis": "inferred" }, + { + "from": "OBSERVED", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "objective.replace", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "objective.replace", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "objective.replace", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", "event": "plan.abandon", diff --git a/docs/architecture/boatstack-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index 947e151e..af0b448b 100644 --- a/docs/architecture/boatstack-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -1,45 +1,46 @@ # Boatstack compiled transition catalog -Registry size: **63** transitions. Event classes: authority 9; owned-local 32; owned-external 2; recovery 7; observed-external 13. +Registry size: **64** transitions. Event classes: authority 10; owned-local 32; owned-external 2; recovery 7; observed-external 13. Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `workspace`, `work-package`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `objective`. | 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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | -| `engagement.release` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | -| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | | `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | +| `objective.replace` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.replace` | `recovery.resume` | `declared-neutral` | | `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | @@ -54,14 +55,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `publication.observe` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | -| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | -| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `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` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`c57780deb9399e5cdfa5e3bc5afb19f13a289796c8d2c1779aa608ba2e75de20` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `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` | diff --git a/docs/architecture/boatstack-transition-catalog.mmd b/docs/architecture/boatstack-transition-catalog.mmd index 6f0d93bb..6ffd43da 100644 --- a/docs/architecture/boatstack-transition-catalog.mmd +++ b/docs/architecture/boatstack-transition-catalog.mmd @@ -16,72 +16,73 @@ flowchart TB t02["engagement.renew
authority"] t03["evidence.approval.revoke
authority"] t04["objective.bind
authority"] - t05["plan.abandon
authority"] - t06["plan.approve
authority"] - t07["plan.approve-amendment
authority"] - t08["publication.abandon
authority"] + t05["objective.replace
authority"] + t06["plan.abandon
authority"] + t07["plan.approve
authority"] + t08["plan.approve-amendment
authority"] + t09["publication.abandon
authority"] end subgraph owned_local["owned-local"] - t09["catalog.reconcile
owned-local"] - t10["configuration.initialize
owned-local"] - t11["configuration.mutate
owned-local"] - t12["delivery.slice.advance
owned-local"] - t13["evidence.visual.attach
owned-local"] - t14["gate.build.record
owned-local"] - t15["gate.change.record
owned-local"] - t16["gate.journey.record
owned-local"] - t17["gate.review.record
owned-local"] - t18["gate.test.record
owned-local"] - t19["installation.initialize
owned-local"] - t20["installation.reconcile-update
owned-local"] - t21["installation.update
owned-local"] - t22["invocation.rebind
owned-local"] - t23["plan.activate
owned-local"] - t24["plan.amend
owned-local"] - t25["plan.create
owned-local"] - t26["plan.invalidate
owned-local"] - t27["plan.validate
owned-local"] - t28["publication.observe
owned-local"] - t29["publication.preview
owned-local"] - t30["repository.attach
owned-local"] - t31["repository.detach
owned-local"] - t32["runtime.hydrate
owned-local"] - t33["runtime.replace
owned-local"] - t34["workspace.abandon
owned-local"] - t35["workspace.activate
owned-local"] - t36["workspace.cleanup
owned-local"] - t37["workspace.cut
owned-local"] - t38["workspace.publish
owned-local"] - t39["workspace.reap
owned-local"] - t40["workspace.sync
owned-local"] + t10["catalog.reconcile
owned-local"] + t11["configuration.initialize
owned-local"] + t12["configuration.mutate
owned-local"] + t13["delivery.slice.advance
owned-local"] + t14["evidence.visual.attach
owned-local"] + t15["gate.build.record
owned-local"] + t16["gate.change.record
owned-local"] + t17["gate.journey.record
owned-local"] + t18["gate.review.record
owned-local"] + t19["gate.test.record
owned-local"] + t20["installation.initialize
owned-local"] + t21["installation.reconcile-update
owned-local"] + t22["installation.update
owned-local"] + t23["invocation.rebind
owned-local"] + t24["plan.activate
owned-local"] + t25["plan.amend
owned-local"] + t26["plan.create
owned-local"] + t27["plan.invalidate
owned-local"] + t28["plan.validate
owned-local"] + t29["publication.observe
owned-local"] + t30["publication.preview
owned-local"] + t31["repository.attach
owned-local"] + t32["repository.detach
owned-local"] + t33["runtime.hydrate
owned-local"] + t34["runtime.replace
owned-local"] + t35["workspace.abandon
owned-local"] + t36["workspace.activate
owned-local"] + t37["workspace.cleanup
owned-local"] + t38["workspace.cut
owned-local"] + t39["workspace.publish
owned-local"] + t40["workspace.reap
owned-local"] + t41["workspace.sync
owned-local"] end subgraph owned_external["owned-external"] - t41["publication.correct
owned-external"] - t42["publication.execute
owned-external"] + t42["publication.correct
owned-external"] + t43["publication.execute
owned-external"] end subgraph recovery["recovery"] - t43["configuration.reconcile
recovery"] - t44["publication.reconcile
recovery"] - t45["recovery.escalate
recovery"] - t46["recovery.resume
recovery"] - t47["recovery.rollback
recovery"] - t48["runtime.reconcile
recovery"] - t49["workspace.reconcile
recovery"] + t44["configuration.reconcile
recovery"] + t45["publication.reconcile
recovery"] + t46["recovery.escalate
recovery"] + t47["recovery.resume
recovery"] + t48["recovery.rollback
recovery"] + t49["runtime.reconcile
recovery"] + t50["workspace.reconcile
recovery"] end subgraph observed_external["observed-external"] - t50["external.branch-changed
observed-external"] - t51["external.ci-completed
observed-external"] - t52["external.configuration-drifted
observed-external"] - t53["external.files-changed
observed-external"] - t54["external.head-changed
observed-external"] - t55["external.host-interrupted
observed-external"] - t56["external.lease-expired
observed-external"] - t57["external.pr-closed
observed-external"] - t58["external.pr-merged
observed-external"] - t59["external.pr-opened
observed-external"] - t60["external.pr-updated
observed-external"] - t61["external.provider-unavailable
observed-external"] - t62["external.runtime-disappeared
observed-external"] + t51["external.branch-changed
observed-external"] + t52["external.ci-completed
observed-external"] + t53["external.configuration-drifted
observed-external"] + t54["external.files-changed
observed-external"] + t55["external.head-changed
observed-external"] + t56["external.host-interrupted
observed-external"] + t57["external.lease-expired
observed-external"] + t58["external.pr-closed
observed-external"] + t59["external.pr-merged
observed-external"] + t60["external.pr-opened
observed-external"] + t61["external.pr-updated
observed-external"] + t62["external.provider-unavailable
observed-external"] + t63["external.runtime-disappeared
observed-external"] end p_DORMANT --> t00 p_OBSERVED --> t00 @@ -105,46 +106,52 @@ flowchart TB t04 --> p_ACTIVE t04 --> p_FRONTIER p_OBSERVED --> t05 + p_DORMANT --> t05 p_ACTIVE --> t05 p_FRONTIER --> t05 - t05 --> p_ABANDONED + p_TERMINAL --> t05 + p_ABANDONED --> t05 + t05 --> p_OBSERVED + t05 --> p_ACTIVE + t05 --> p_FRONTIER + p_OBSERVED --> t06 p_ACTIVE --> t06 p_FRONTIER --> t06 - t06 --> p_ACTIVE - t06 --> p_TERMINAL + t06 --> p_ABANDONED p_ACTIVE --> t07 p_FRONTIER --> t07 t07 --> p_ACTIVE + t07 --> p_TERMINAL p_ACTIVE --> t08 p_FRONTIER --> t08 - t08 --> p_ABANDONED - p_DORMANT --> t09 - p_OBSERVED --> t09 + t08 --> p_ACTIVE p_ACTIVE --> t09 - p_RECOVERY --> t09 p_FRONTIER --> t09 - p_UNRESOLVED --> t09 - p_TERMINAL --> t09 - p_ABANDONED --> t09 - t09 --> p_DORMANT - t09 --> p_OBSERVED - t09 --> p_ACTIVE - t09 --> p_RECOVERY - t09 --> p_FRONTIER - t09 --> p_UNRESOLVED - t09 --> p_TERMINAL t09 --> p_ABANDONED + p_DORMANT --> t10 p_OBSERVED --> t10 + p_ACTIVE --> t10 + p_RECOVERY --> t10 + p_FRONTIER --> t10 + p_UNRESOLVED --> t10 + p_TERMINAL --> t10 + p_ABANDONED --> t10 + t10 --> p_DORMANT t10 --> p_OBSERVED + t10 --> p_ACTIVE + t10 --> p_RECOVERY + t10 --> p_FRONTIER + t10 --> p_UNRESOLVED t10 --> p_TERMINAL + t10 --> p_ABANDONED p_OBSERVED --> t11 - p_ACTIVE --> t11 - p_FRONTIER --> t11 - p_UNRESOLVED --> t11 t11 --> p_OBSERVED - t11 --> p_ACTIVE t11 --> p_TERMINAL + p_OBSERVED --> t12 p_ACTIVE --> t12 + p_FRONTIER --> t12 + p_UNRESOLVED --> t12 + t12 --> p_OBSERVED t12 --> p_ACTIVE t12 --> p_TERMINAL p_ACTIVE --> t13 @@ -152,31 +159,22 @@ flowchart TB t13 --> p_TERMINAL p_ACTIVE --> t14 t14 --> p_ACTIVE + t14 --> p_TERMINAL p_ACTIVE --> t15 t15 --> p_ACTIVE p_ACTIVE --> t16 t16 --> p_ACTIVE p_ACTIVE --> t17 t17 --> p_ACTIVE - t17 --> p_TERMINAL p_ACTIVE --> t18 t18 --> p_ACTIVE t18 --> p_TERMINAL - p_DORMANT --> t19 - p_OBSERVED --> t19 - t19 --> p_OBSERVED + p_ACTIVE --> t19 + t19 --> p_ACTIVE + t19 --> p_TERMINAL p_DORMANT --> t20 p_OBSERVED --> t20 - p_ACTIVE --> t20 - p_FRONTIER --> t20 - p_TERMINAL --> t20 - p_ABANDONED --> t20 - t20 --> p_DORMANT t20 --> p_OBSERVED - t20 --> p_ACTIVE - t20 --> p_FRONTIER - t20 --> p_TERMINAL - t20 --> p_ABANDONED p_DORMANT --> t21 p_OBSERVED --> t21 p_ACTIVE --> t21 @@ -189,108 +187,113 @@ flowchart TB t21 --> p_FRONTIER t21 --> p_TERMINAL t21 --> p_ABANDONED + p_DORMANT --> t22 p_OBSERVED --> t22 - p_UNRESOLVED --> t22 + p_ACTIVE --> t22 + p_FRONTIER --> t22 + p_TERMINAL --> t22 + p_ABANDONED --> t22 + t22 --> p_DORMANT t22 --> p_OBSERVED + t22 --> p_ACTIVE + t22 --> p_FRONTIER + t22 --> p_TERMINAL + t22 --> p_ABANDONED p_OBSERVED --> t23 - p_ACTIVE --> t23 - t23 --> p_ACTIVE + p_UNRESOLVED --> t23 + t23 --> p_OBSERVED + p_OBSERVED --> t24 p_ACTIVE --> t24 - p_FRONTIER --> t24 t24 --> p_ACTIVE - p_OBSERVED --> t25 p_ACTIVE --> t25 + p_FRONTIER --> t25 t25 --> p_ACTIVE - p_ACTIVE --> t26 p_OBSERVED --> t26 - t26 --> p_FRONTIER - p_OBSERVED --> t27 + p_ACTIVE --> t26 + t26 --> p_ACTIVE p_ACTIVE --> t27 - t27 --> p_ACTIVE + p_OBSERVED --> t27 t27 --> p_FRONTIER p_OBSERVED --> t28 p_ACTIVE --> t28 - p_RECOVERY --> t28 - p_UNRESOLVED --> t28 t28 --> p_ACTIVE - t28 --> p_TERMINAL t28 --> p_FRONTIER - t28 --> p_UNRESOLVED + p_OBSERVED --> t29 p_ACTIVE --> t29 + p_RECOVERY --> t29 + p_UNRESOLVED --> t29 t29 --> p_ACTIVE - p_DORMANT --> t30 - p_OBSERVED --> t30 - t30 --> p_OBSERVED + t29 --> p_TERMINAL + t29 --> p_FRONTIER + t29 --> p_UNRESOLVED + p_ACTIVE --> t30 + t30 --> p_ACTIVE p_DORMANT --> t31 p_OBSERVED --> t31 - p_FRONTIER --> t31 - t31 --> p_DORMANT + t31 --> p_OBSERVED + p_DORMANT --> t32 p_OBSERVED --> t32 - p_RECOVERY --> t32 - p_UNRESOLVED --> t32 - t32 --> p_OBSERVED - t32 --> p_ACTIVE - t32 --> p_TERMINAL + p_FRONTIER --> t32 + t32 --> p_DORMANT p_OBSERVED --> t33 p_RECOVERY --> t33 + p_UNRESOLVED --> t33 t33 --> p_OBSERVED + t33 --> p_ACTIVE t33 --> p_TERMINAL - p_ACTIVE --> t34 - p_FRONTIER --> t34 - t34 --> p_ABANDONED - p_OBSERVED --> t35 + p_OBSERVED --> t34 + p_RECOVERY --> t34 + t34 --> p_OBSERVED + t34 --> p_TERMINAL p_ACTIVE --> t35 - t35 --> p_ACTIVE + p_FRONTIER --> t35 + t35 --> p_ABANDONED p_OBSERVED --> t36 p_ACTIVE --> t36 - p_TERMINAL --> t36 - p_ABANDONED --> t36 - t36 --> p_OBSERVED - t36 --> p_TERMINAL - t36 --> p_ABANDONED + t36 --> p_ACTIVE p_OBSERVED --> t37 p_ACTIVE --> t37 - t37 --> p_ACTIVE + p_TERMINAL --> t37 + p_ABANDONED --> t37 + t37 --> p_OBSERVED + t37 --> p_TERMINAL + t37 --> p_ABANDONED + p_OBSERVED --> t38 p_ACTIVE --> t38 t38 --> p_ACTIVE - p_OBSERVED --> t39 - p_TERMINAL --> t39 - p_ABANDONED --> t39 - t39 --> p_OBSERVED - t39 --> p_TERMINAL - t39 --> p_ABANDONED - p_ACTIVE --> t40 - t40 --> p_ACTIVE - t40 --> p_FRONTIER - p_OBSERVED --> t41 + p_ACTIVE --> t39 + t39 --> p_ACTIVE + p_OBSERVED --> t40 + p_TERMINAL --> t40 + p_ABANDONED --> t40 + t40 --> p_OBSERVED + t40 --> p_TERMINAL + t40 --> p_ABANDONED p_ACTIVE --> t41 - p_TERMINAL --> t41 t41 --> p_ACTIVE - t41 --> p_RECOVERY + t41 --> p_FRONTIER + p_OBSERVED --> t42 p_ACTIVE --> t42 + p_TERMINAL --> t42 t42 --> p_ACTIVE t42 --> p_RECOVERY - p_RECOVERY --> t43 - p_UNRESOLVED --> t43 - t43 --> p_OBSERVED - t43 --> p_FRONTIER - t43 --> p_TERMINAL + p_ACTIVE --> t43 + t43 --> p_ACTIVE + t43 --> p_RECOVERY p_RECOVERY --> t44 p_UNRESOLVED --> t44 - t44 --> p_ACTIVE - t44 --> p_TERMINAL + t44 --> p_OBSERVED t44 --> p_FRONTIER - t44 --> p_UNRESOLVED + t44 --> p_TERMINAL p_RECOVERY --> t45 p_UNRESOLVED --> t45 + t45 --> p_ACTIVE + t45 --> p_TERMINAL t45 --> p_FRONTIER + t45 --> p_UNRESOLVED p_RECOVERY --> t46 - t46 --> p_DORMANT - t46 --> p_OBSERVED - t46 --> p_ACTIVE + p_UNRESOLVED --> t46 t46 --> p_FRONTIER - t46 --> p_TERMINAL - t46 --> p_ABANDONED p_RECOVERY --> t47 t47 --> p_DORMANT t47 --> p_OBSERVED @@ -299,25 +302,25 @@ flowchart TB t47 --> p_TERMINAL t47 --> p_ABANDONED p_RECOVERY --> t48 - p_UNRESOLVED --> t48 + t48 --> p_DORMANT t48 --> p_OBSERVED + t48 --> p_ACTIVE t48 --> p_FRONTIER t48 --> p_TERMINAL + t48 --> p_ABANDONED p_RECOVERY --> t49 p_UNRESOLVED --> t49 - t49 --> p_DORMANT t49 --> p_OBSERVED - t49 --> p_ACTIVE t49 --> p_FRONTIER t49 --> p_TERMINAL - t49 --> p_ABANDONED - p_DORMANT --> t50 - p_OBSERVED --> t50 - p_ACTIVE --> t50 p_RECOVERY --> t50 - p_FRONTIER --> t50 p_UNRESOLVED --> t50 + t50 --> p_DORMANT t50 --> p_OBSERVED + t50 --> p_ACTIVE + t50 --> p_FRONTIER + t50 --> p_TERMINAL + t50 --> p_ABANDONED p_DORMANT --> t51 p_OBSERVED --> t51 p_ACTIVE --> t51 @@ -325,8 +328,6 @@ flowchart TB p_FRONTIER --> t51 p_UNRESOLVED --> t51 t51 --> p_OBSERVED - t51 --> p_ACTIVE - t51 --> p_TERMINAL p_DORMANT --> t52 p_OBSERVED --> t52 p_ACTIVE --> t52 @@ -334,7 +335,8 @@ flowchart TB p_FRONTIER --> t52 p_UNRESOLVED --> t52 t52 --> p_OBSERVED - t52 --> p_UNRESOLVED + t52 --> p_ACTIVE + t52 --> p_TERMINAL p_DORMANT --> t53 p_OBSERVED --> t53 p_ACTIVE --> t53 @@ -342,6 +344,7 @@ flowchart TB p_FRONTIER --> t53 p_UNRESOLVED --> t53 t53 --> p_OBSERVED + t53 --> p_UNRESOLVED p_DORMANT --> t54 p_OBSERVED --> t54 p_ACTIVE --> t54 @@ -355,23 +358,21 @@ flowchart TB p_RECOVERY --> t55 p_FRONTIER --> t55 p_UNRESOLVED --> t55 - t55 --> p_RECOVERY + t55 --> p_OBSERVED p_DORMANT --> t56 p_OBSERVED --> t56 p_ACTIVE --> t56 p_RECOVERY --> t56 p_FRONTIER --> t56 p_UNRESOLVED --> t56 - t56 --> p_DORMANT - t56 --> p_FRONTIER + t56 --> p_RECOVERY p_DORMANT --> t57 p_OBSERVED --> t57 p_ACTIVE --> t57 p_RECOVERY --> t57 p_FRONTIER --> t57 p_UNRESOLVED --> t57 - t57 --> p_OBSERVED - t57 --> p_ACTIVE + t57 --> p_DORMANT t57 --> p_FRONTIER p_DORMANT --> t58 p_OBSERVED --> t58 @@ -381,7 +382,7 @@ flowchart TB p_UNRESOLVED --> t58 t58 --> p_OBSERVED t58 --> p_ACTIVE - t58 --> p_TERMINAL + t58 --> p_FRONTIER p_DORMANT --> t59 p_OBSERVED --> t59 p_ACTIVE --> t59 @@ -406,13 +407,22 @@ flowchart TB p_RECOVERY --> t61 p_FRONTIER --> t61 p_UNRESOLVED --> t61 - t61 --> p_UNRESOLVED - t61 --> p_RECOVERY + t61 --> p_OBSERVED + t61 --> p_ACTIVE + t61 --> p_TERMINAL p_DORMANT --> t62 p_OBSERVED --> t62 p_ACTIVE --> t62 p_RECOVERY --> t62 p_FRONTIER --> t62 p_UNRESOLVED --> t62 - t62 --> p_OBSERVED + t62 --> p_UNRESOLVED t62 --> p_RECOVERY + p_DORMANT --> t63 + p_OBSERVED --> t63 + p_ACTIVE --> t63 + p_RECOVERY --> t63 + p_FRONTIER --> t63 + p_UNRESOLVED --> t63 + t63 --> p_OBSERVED + t63 --> p_RECOVERY diff --git a/docs/architecture/kernel.md b/docs/architecture/kernel.md index da0eb04d..994442d8 100644 --- a/docs/architecture/kernel.md +++ b/docs/architecture/kernel.md @@ -42,7 +42,9 @@ The general kernel never imports the software-delivery implementation. - control-instance identity and monotonic state revision; - external `Objective` and durable exact `ObjectiveBinding`; - objective scopes: `none`, `optional-preserve`, and `bound-exact`; -- explicit objective bind and clear mutations; +- explicit objective lifecycle relations: `preserve`, `bind-initial`, + `advance`, `replace`, and `clear`, each with its own capability and + receipt-provable lineage; - explicit per-transition selection: a positive selection rank and a selection mode (`implicit` or `explicit-only`), alongside priority; - one transition relation used by resolve and apply; @@ -157,11 +159,15 @@ domain state never constitute acceptance. Any failure before the final atomic commit leaves the prior accepted binding authoritative; rejected or orphaned candidates may persist but remain untrusted. -Each committed receipt records explicit objective lineage — the prior accepted -binding, the requested candidate binding, and the resulting accepted binding — -so a verifier can prove the exact accepted delta from the receipt alone. The -runtime never synthesizes an unrequested binding: an absent requested binding -must leave the prior binding as the result. +Each committed receipt records explicit objective lineage — the declared +lifecycle relation plus the prior accepted binding, the requested candidate +binding, and the resulting accepted binding — so a verifier can prove the +exact accepted delta and its declared relation from the receipt alone. +Preserve requires no requested binding and an unchanged result; bind-initial +requires an absent prior; advance requires the same objective at exactly the +next revision; replace requires a different objective identity; clear requires +a prior binding and no result. The runtime never synthesizes an unrequested +binding or infers a relation from program semantics. Accepted-state readers reconstruct from durable evidence only: load the exact objective binding, resolve it to immutable candidate content, and require the @@ -216,6 +222,9 @@ receipt substitution, and restart state reset. 14. Domain substitution: the integer domain runs without kernel changes. 15. Trusted settlement: only an exact committed binding plus receipt is accepted state. 16. Receipt lineage: receipts prove the prior, requested, and resulting bindings. +17. Lifecycle explicitness: every binding mutation declares exactly one of + bind-initial, advance, replace, or clear, satisfies that relation's own + capability, and commits a receipt that independently proves the relation. ## Current implementation anchors diff --git a/docs/generated-files.md b/docs/generated-files.md index aea2ae98..d211941c 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -99,7 +99,7 @@ architecture artifacts: | `docs/architecture/boatstack-locus-liveness.json` | `boatstack-helper catalog --format locus-liveness` | Repository and Go tests compare every checked byte with a fresh render and -require both Locus alphabets to equal all 63 executable catalog transitions. +require both Locus alphabets to equal all 64 executable catalog transitions. The StandardFlow graph contains exactly the 30 transitions whose compiled origin is the program runtime. The Locus phase graph is intentionally conservative: it expands each declared diff --git a/release-notes/2026-08-23-objective-lifecycle-relations.md b/release-notes/2026-08-23-objective-lifecycle-relations.md new file mode 100644 index 00000000..45b3dcaf --- /dev/null +++ b/release-notes/2026-08-23-objective-lifecycle-relations.md @@ -0,0 +1,38 @@ +### Objective binding mutations declare explicit, receipt-provable lifecycle relations + +The kernel's overloaded bind mutation is gone. Every transition now declares +exactly one objective lifecycle relation — preserve, bind-initial, advance, +replace, or clear — and each mutating relation requires its own capability: +`objective.bind`, `objective.advance`, `objective.replace`, or +`objective.clear`. Initial binds require verified absence and may bind any +valid externally versioned revision; advances keep the objective identity and +move to exactly the next revision, rejecting gaps, downgrades, duplicates, +overflow, and same-revision fingerprint substitution; replacements must be +declared and rebind a different objective identity; clears remove an existing +binding; preserve cannot silently change one. Prescriptions and receipts +record the declared relation, and receipt validation proves the relation from +the receipt's own binding lineage without recovering transition semantics +from a program body. The prescription schema advanced to 3 and the kernel +receipt schema to 5 with no compatibility readers. + +Software delivery replaces the `binds_requested_objective` policy flag with +the same explicit relation: `objective.bind` configures the first objective +over verified absence, and a new `objective.replace` transition declares +reconfiguration to a different objective, projected through the compiler into +the kernel's replace relation and its own capability. Admissions and +software-delivery receipts record the declared relation with schema bumps to +10 and 14, keeping the committed-history reader for the previous shapes +exact. Untargeted canonical selection still reconfigures a supplied different +objective — now through the declared replacement rather than an inferred +rebind. + +A new domain-neutral objective lifecycle conformance suite runs a +multi-revision register through every relation: valid binds, advances, +declared replacements, and clears; rejection of every invalid lineage before +effects; per-relation capability isolation; concurrent successors settling +exactly once; stale and pre-clear prescriptions; verification and commit +failures that never advance accepted state; replay and cross-instance replay +rejection; restart reconstruction; and a receipt chain that independently +proves the full lifecycle. White-box counterexamples prove the laws reject +torn commits, blind concurrent commits, cleared-binding resurrection, +relabeled relations, and restart state resets.