diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py
index ca87217..c5fc49b 100644
--- a/.github/tests/test_detached_supervision.py
+++ b/.github/tests/test_detached_supervision.py
@@ -187,6 +187,11 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No
),
)
+ self.helper_json(
+ "apply", "--repo", self.repo, "--transition", "goal.configure",
+ *self.goal_flags(), "--human", "contract",
+ "--param", "goal_kind=approved-plan", "--param", "delivery_id=bootstrap",
+ )
self.helper_json(
"apply", "--repo", self.repo, "--transition", "engagement.begin",
*self.goal_flags(), "--repository-authority",
@@ -233,6 +238,12 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
"init", "--repo", self.repo, *goal, *flow, "--human", "contract",
"--param", f"config_path={config}",
)
+ self.helper_json(
+ "apply", "--repo", self.repo, "--transition", "goal.configure",
+ *goal, *flow, "--human", "contract",
+ "--param", "goal_kind=open-or-updated-pr",
+ "--param", "delivery_id=codex-driver-authority-triggers",
+ )
self.helper_json(
"apply", "--repo", self.repo, "--transition", "engagement.begin",
*goal, *flow, "--repository-authority",
@@ -355,6 +366,14 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
for field in ('"admission"', '"receipt"', '"snapshot"', '"target_fingerprint"', '"recovery"'):
self.assertIn(field, initialized_process.stdout)
+ configured = self.helper_json(
+ "apply", "--repo", self.repo, "--transition", "goal.configure",
+ *goal, *flow, *actor,
+ "--param", "goal_kind=open-or-updated-pr",
+ "--param", "delivery_id=preserve-repository-authority-context",
+ )
+ self.assertEqual(configured["receipt"]["transition_id"], "goal.configure")
+
engagement = self.helper_json(
"next", "--repo", self.repo, *goal, *flow, *actor,
"--repository-authority",
diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py
index ac67b1c..0472cda 100644
--- a/.github/tests/test_repository_contract.py
+++ b/.github/tests/test_repository_contract.py
@@ -452,6 +452,12 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
"--goal-id", "bootstrap", "--goal-kind", "approved-plan",
"--delivery", "bootstrap",
)
+ self.run_command(
+ launcher, "apply", "--repo", repository,
+ "--transition", "goal.configure", *goal,
+ "--human", "contract", "--param", "goal_kind=approved-plan",
+ "--param", "delivery_id=bootstrap", env=env,
+ )
self.run_command(
launcher, "apply", "--repo", repository,
"--transition", "engagement.begin", *goal,
diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go
index d53776d..1db995d 100644
--- a/boatstack/cmd/boatstack-helper/main.go
+++ b/boatstack/cmd/boatstack-helper/main.go
@@ -200,11 +200,7 @@ func classifyCommand(command string) (surfaces.Operation, catalog.TransitionID,
return surfaces.OperationGuard, "", nil, nil
}
if transition, ok := aliases[command]; ok {
- defaults := map[string]string{}
- if command == "init" {
- defaults["goal-kind"], defaults["delivery"], defaults["goal-id"] = string(model.GoalApprovedPlan), "bootstrap", "bootstrap"
- }
- return surfaces.OperationApply, transition, defaults, nil
+ return surfaces.OperationApply, transition, nil, nil
}
return "", "", nil, fmt.Errorf("unknown command %q", command)
}
diff --git a/boatstack/control/control.go b/boatstack/control/control.go
index 0411137..65d3e66 100644
--- a/boatstack/control/control.go
+++ b/boatstack/control/control.go
@@ -25,6 +25,7 @@ type AuthorityClass = catalog.AuthorityClass
type FacetCondition = catalog.FacetCondition
type SelectionClass = catalog.SelectionClass
type GoalContract = catalog.GoalContract
+type GoalScope = catalog.GoalScope
type EffectID = catalog.EffectID
type Prescription = catalog.Prescription
type ParameterSpec = catalog.ParameterSpec
@@ -56,6 +57,8 @@ const (
SelectionExplicitOnly = catalog.SelectionExplicitOnly
SelectionObservedExternal = catalog.SelectionObservedExternal
+ GoalScopeOptionalPreserve = catalog.GoalScopeOptionalPreserve
+
GoalApprovedPlan = model.GoalApprovedPlan
GoalVerified = model.GoalVerified
GoalOpenPR = model.GoalOpenPR
diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json
index 1817655..783d370 100644
--- a/boatstack/core/transitions.json
+++ b/boatstack/core/transitions.json
@@ -699,7 +699,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 15,
"allows_identity_rebind": true
},
@@ -869,7 +869,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 12,
"allows_identity_rebind": true
},
@@ -1211,7 +1211,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 20
},
{
@@ -1392,7 +1392,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 25
},
{
@@ -1548,7 +1548,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 4
},
{
@@ -1720,7 +1720,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 22
},
{
@@ -1925,7 +1925,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 60
},
{
@@ -2067,7 +2067,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 3
},
{
@@ -2261,7 +2261,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 11
},
{
@@ -2417,7 +2417,8 @@
{
"facet": "goal",
"statuses": [
- "known"
+ "known",
+ "absent"
]
},
{
@@ -2470,7 +2471,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 65
},
{
@@ -2541,7 +2542,7 @@
{"facet": "recovery", "statuses": ["known"], "values": ["none"]},
{"facet": "transaction", "statuses": ["known"], "values": ["none"]},
{"facet": "terminal", "statuses": ["known"], "values": ["nonterminal", "stale", "established"]},
- {"facet": "goal", "statuses": ["known"]},
+ {"facet": "goal", "statuses": ["known", "absent"]},
{"facet": "configuration", "statuses": ["known"], "values": ["verified"]}
],
"admission_predicate": "predicate:exact-admission:installation.reconcile-update",
@@ -2567,7 +2568,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {"reconciles_program": true},
+ "policy": {"reconciles_program": true, "goal_scope": "optional-preserve"},
"priority": 1
},
{
@@ -2750,7 +2751,8 @@
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
"policy": {
- "reconciles_program": true
+ "reconciles_program": true,
+ "goal_scope": "optional-preserve"
},
"priority": 1
},
@@ -3038,7 +3040,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 2
},
{
@@ -3182,7 +3184,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 3
},
{
@@ -3321,7 +3323,7 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
- "policy": {},
+ "policy": {"goal_scope": "optional-preserve"},
"priority": 5
},
{
diff --git a/boatstack/internal/effects/artifacts.go b/boatstack/internal/effects/artifacts.go
index a847135..5b44d22 100644
--- a/boatstack/internal/effects/artifacts.go
+++ b/boatstack/internal/effects/artifacts.go
@@ -89,9 +89,13 @@ type publicationPreview struct {
func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition, state *durable.State) ([]ports.ResourceMutation, error) {
var mutations []ports.ResourceMutation
- deliveryID, err := safeSegment(admission.Goal.DeliveryID, "delivery identity")
- if err != nil {
- return nil, err
+ var deliveryID string
+ if transitionUsesDeliveryArtifacts(transition.ID) {
+ var err error
+ deliveryID, err = safeSegment(admission.Goal.DeliveryID, "delivery identity")
+ if err != nil {
+ return nil, err
+ }
}
artifactRoot := filepath.Join(layout.RepositoryRoot, ".boatstack")
switch transition.ID {
@@ -291,6 +295,18 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio
return mutations, nil
}
+func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool {
+ switch id {
+ case "plan.create", "plan.amend", "plan.validate", "plan.approve", "plan.approve-amendment",
+ "evidence.approval.revoke", "gate.build.record", "gate.test.record", "gate.review.record",
+ "gate.change.record", "gate.journey.record", "evidence.visual.attach", "publication.preview",
+ "publication.execute", "publication.correct":
+ return true
+ default:
+ return false
+ }
+}
+
func loadPublicationPreview(path string) (publicationPreview, error) {
raw, err := os.ReadFile(path)
if err != nil {
diff --git a/boatstack/internal/effects/integration_test.go b/boatstack/internal/effects/integration_test.go
index 0ddadf4..0ae2af4 100644
--- a/boatstack/internal/effects/integration_test.go
+++ b/boatstack/internal/effects/integration_test.go
@@ -184,6 +184,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing
{Name: "source_revision", Value: "external-config-fixture"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)},
{Name: "config_path", Value: initialPath}, {Name: "config_sha256", Value: configFingerprint(t, initialConfig)},
})
+ apply("goal.configure", human, false, protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}})
apply("repository.attach", human, false, protocol.Parameters{{Name: "topology", Value: "detached"}, {Name: "config_authority", Value: "external"}})
resolver, err := plant.NewResolver(externalRoot)
if err != nil {
@@ -273,6 +274,9 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) {
if initialized.Receipt == nil || initialized.Receipt.ProgramFingerprint != oldProgram.Fingerprint() {
t.Fatalf("initial receipt did not freeze old program: %#v", initialized.Receipt)
}
+ if initialized.Snapshot == nil || initialized.Snapshot.Goal.Status != model.FactAbsent || initialized.Receipt.GoalStatus != model.FactAbsent || initialized.Receipt.GoalID != "" {
+ t.Fatalf("installation initialization invented product intent: %#v", initialized)
+ }
newProgram := testProgram()
newKernel, err := boatstack.NewKernel(externalRoot, newProgram)
@@ -342,6 +346,9 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) {
reconciled.Snapshot.Program.Value != model.ProgramCurrent || reconciled.Snapshot.Phase.Value != model.PhaseObserved {
t.Fatalf("reconciliation did not establish exact program identity: %#v", reconciled)
}
+ if reconciled.Snapshot.Goal.Status != model.FactAbsent || reconciled.Receipt.GoalStatus != model.FactAbsent || reconciled.Receipt.GoalID != "" {
+ t.Fatalf("reconcile-update invented product intent: %#v", reconciled)
+ }
afterSuccess, err := os.ReadFile(layout.StatePath)
if err != nil {
t.Fatal(err)
@@ -356,6 +363,20 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) {
if !bytes.Equal(afterSuccess, afterReplay) {
t.Fatal("rejected repeated reconciliation mutated durable state")
}
+ updated, err := newKernel.Handle(ctx, surfaces.Request{
+ SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-current-update",
+ FlowID: "flow-program-drift", Goal: model.Goal{ID: "ignored-command-goal", Kind: model.GoalOpenPR, DeliveryID: "ignored"},
+ TransitionID: "installation.update", Authority: human,
+ Parameters: protocol.Parameters{
+ {Name: "source_revision", Value: "program-current"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)},
+ },
+ })
+ if err != nil {
+ t.Fatalf("current-program update after reconciliation: %v", err)
+ }
+ if updated.Snapshot == nil || updated.Snapshot.Goal.Status != model.FactAbsent || updated.Receipt == nil || updated.Receipt.GoalStatus != model.FactAbsent || updated.Receipt.GoalID != "" {
+ t.Fatalf("reconcile to update composition invented product intent: %#v", updated)
+ }
}
func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *testing.T) {
@@ -420,6 +441,7 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test
{Name: "source_revision", Value: "extension-fixture"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)},
{Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)},
})
+ apply("goal.configure", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}})
apply("engagement.begin", authority(catalog.AuthorityRepository), nil)
planPath := filepath.Join(t.TempDir(), "plan.md")
planRaw := []byte("# Extension plan\n")
@@ -547,6 +569,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing.
{Name: "source_revision", Value: "integration-revision"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)},
{Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)},
})
+ apply(approvedGoal, "goal.configure", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "goal_kind", Value: string(approvedGoal.Kind)}, {Name: "delivery_id", Value: approvedGoal.DeliveryID}})
apply(approvedGoal, "engagement.begin", authority(catalog.AuthorityRepository), nil)
updatedConfigPath := filepath.Join(t.TempDir(), "project-v2-updated.json")
@@ -677,6 +700,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T)
{Name: "source_revision", Value: "integration-revision"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)},
{Name: "config_path", Value: configSource}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)},
})
+ apply(sourceInvocation, "goal.configure", human, protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}})
run(t, repository, "git", "add", ".boatstack/project.json")
run(t, repository, "git", "commit", "-q", "-m", "install V2 configuration")
repositoryAuthority := func(path string) protocol.AuthorityBundle {
@@ -768,7 +792,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T)
if err != nil {
t.Fatal(err)
}
- if strings.Count(strings.TrimSpace(string(eventBytes)), "\n")+1 != 7 {
+ if strings.Count(strings.TrimSpace(string(eventBytes)), "\n")+1 != 8 {
t.Fatalf("shared flow telemetry lost a cross-worktree transition: %s", eventBytes)
}
}
diff --git a/boatstack/internal/effects/receipts.go b/boatstack/internal/effects/receipts.go
index b07016c..a4c14e3 100644
--- a/boatstack/internal/effects/receipts.go
+++ b/boatstack/internal/effects/receipts.go
@@ -136,6 +136,8 @@ type processEvent struct {
Sequence uint64 `json:"sequence"`
Timestamp time.Time `json:"timestamp"`
GoalID string `json:"goal_id"`
+ GoalScope string `json:"goal_scope,omitempty"`
+ GoalStatus string `json:"goal_status,omitempty"`
TransitionID string `json:"transition_id"`
ProgramFingerprint string `json:"program_fingerprint"`
SourceFingerprint string `json:"source_fingerprint"`
@@ -181,6 +183,7 @@ func (s *ReceiptStore) Append(ctx context.Context, receipt protocol.TransitionRe
}
event := processEvent{
SchemaVersion: 1, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID,
+ GoalScope: string(receipt.GoalScope), GoalStatus: string(receipt.GoalStatus),
TransitionID: string(receipt.TransitionID), ProgramFingerprint: receipt.ProgramFingerprint, SourceFingerprint: receipt.SourceFingerprint, TargetFingerprint: receipt.TargetFingerprint,
Outcome: string(receipt.Outcome), DurationNanoseconds: receipt.DurationNanoseconds,
AuthorityClasses: append([]string(nil), receipt.AuthorityClasses...), Recovery: string(receipt.Recovery), Terminal: string(receipt.Terminal), FailureClass: receipt.FailureClass,
diff --git a/boatstack/internal/effects/recovery_test.go b/boatstack/internal/effects/recovery_test.go
index 070bc4e..a735176 100644
--- a/boatstack/internal/effects/recovery_test.go
+++ b/boatstack/internal/effects/recovery_test.go
@@ -164,7 +164,8 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T)
if err != nil {
t.Fatal(err)
}
- if result.Target.Phase.Value != model.PhaseDormant || result.Target.Recovery.Value != model.RecoveryNone || result.Receipt.ID == "" {
+ if result.Target.Phase.Value != model.PhaseDormant || result.Target.Recovery.Value != model.RecoveryNone || result.Target.Goal.Status != model.FactAbsent ||
+ result.Receipt.ID == "" || result.Receipt.GoalStatus != model.FactAbsent || result.Receipt.GoalID != "" {
t.Fatalf("rollback target=%#v receipt=%q", result.Target, result.Receipt.ID)
}
layout, _, _ := resolver.ResolveLayout(ctx, restartedInvocation)
diff --git a/boatstack/internal/effects/state_reducer.go b/boatstack/internal/effects/state_reducer.go
index dfcaacf..5eb3cfa 100644
--- a/boatstack/internal/effects/state_reducer.go
+++ b/boatstack/internal/effects/state_reducer.go
@@ -11,11 +11,17 @@ import (
func applyStateTransition(state *durable.State, admission protocol.Admission, transition catalog.Transition) error {
configured := state.Goal.Validate() == nil
- switch transition.ID {
- case "installation.initialize", "goal.configure":
+ if transition.Policy.GoalScope == catalog.GoalScopeOptionalPreserve {
+ if configured && state.Goal != admission.Goal {
+ return fmt.Errorf("transition %q must preserve the exact configured product goal", transition.ID)
+ }
+ if !configured && admission.Goal.Validate() == nil {
+ return fmt.Errorf("transition %q cannot create product intent from verified absence", transition.ID)
+ }
+ } else if transition.ID == "goal.configure" {
state.Goal = admission.Goal
configured = true
- default:
+ } else {
if configured && state.Goal != admission.Goal {
return fmt.Errorf("transition %q cannot replace configured goal; use goal.configure", transition.ID)
}
diff --git a/boatstack/internal/kernel/catalog/transition.go b/boatstack/internal/kernel/catalog/transition.go
index ae3a954..f229b1f 100644
--- a/boatstack/internal/kernel/catalog/transition.go
+++ b/boatstack/internal/kernel/catalog/transition.go
@@ -176,15 +176,22 @@ type InterruptionContract struct {
ResumptionPredicate string `json:"resumption_predicate"`
}
+type GoalScope string
+
+const GoalScopeOptionalPreserve GoalScope = "optional-preserve"
+
+func (s GoalScope) Valid() bool { return s == "" || s == GoalScopeOptionalPreserve }
+
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"`
- BindsRequestedGoal bool `json:"binds_requested_goal,omitempty"`
- ReconcilesProgram bool `json:"reconciles_program,omitempty"`
- RechecksExternalState bool `json:"rechecks_external_state,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"`
+ BindsRequestedGoal bool `json:"binds_requested_goal,omitempty"`
+ ReconcilesProgram bool `json:"reconciles_program,omitempty"`
+ RechecksExternalState bool `json:"rechecks_external_state,omitempty"`
+ GoalScope GoalScope `json:"goal_scope,omitempty"`
}
// FacetCondition is an executable, serializable predicate over one canonical
@@ -280,6 +287,9 @@ func (t Transition) ImplicitlySelectable() bool {
}
func (t Transition) SupportsGoal(goal model.Goal) bool {
+ if t.Policy.GoalScope == GoalScopeOptionalPreserve {
+ return true
+ }
if len(t.GoalKinds) == 0 {
return true
}
@@ -309,6 +319,9 @@ func (t Transition) SourceMatches(snapshot model.Snapshot) bool {
return false
}
}
+ if t.Policy.GoalScope == GoalScopeOptionalPreserve && snapshot.Goal.Status != model.FactKnown && snapshot.Goal.Status != model.FactAbsent {
+ return false
+ }
return true
}
@@ -485,6 +498,12 @@ func validateTransition(t Transition) error {
}
managedOperations[operation] = true
}
+ if !t.Policy.GoalScope.Valid() {
+ return fmt.Errorf("%s: invalid goal scope %q", t.ID, t.Policy.GoalScope)
+ }
+ if t.Policy.GoalScope == GoalScopeOptionalPreserve && t.Policy.BindsRequestedGoal {
+ return fmt.Errorf("%s: optional-preserve maintenance cannot bind a requested product goal", t.ID)
+ }
if t.Policy.BindsRequestedGoal && (t.Origin.Kind != OriginCoreSystem || !conditionNamesFacet(t.TargetConditions, model.FacetGoal)) {
return fmt.Errorf("%s: requested-goal binding requires a CoreSystem goal target", t.ID)
}
diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go
index 01d8415..a88ee2b 100644
--- a/boatstack/internal/kernel/engine/engine.go
+++ b/boatstack/internal/kernel/engine/engine.go
@@ -66,11 +66,20 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution
return Resolution{}, fmt.Errorf("observer returned a different invocation identity")
}
goal := request.Goal
- if err := goal.Validate(); err != nil {
- if snapshot.Goal.Status != model.FactKnown {
+ if transition, requested := e.registry.Lookup(request.Requested); requested && transition.Policy.GoalScope == catalog.GoalScopeOptionalPreserve {
+ goal, err = protocol.GoalForTransition(snapshot, request.Goal, transition)
+ if err != nil {
+ return Resolution{}, err
+ }
+ } else if err := goal.Validate(); err != nil {
+ switch snapshot.Goal.Status {
+ case model.FactKnown:
+ goal = snapshot.Goal.Value
+ case model.FactAbsent:
+ goal = model.Goal{}
+ default:
return Resolution{}, fmt.Errorf("no valid requested or configured goal: %w", err)
}
- goal = snapshot.Goal.Value
}
now := e.clock.Now()
if err := request.Authority.Validate(now); err != nil {
@@ -78,6 +87,13 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution
}
decision := e.control.Resolve(snapshot, goal, request.Authority.Set(now), request.Requested)
if decision.Kind == supervisor.DecisionPrescribed && decision.Transition != nil {
+ goal, err = protocol.GoalForTransition(snapshot, goal, *decision.Transition)
+ if err != nil {
+ decision.Kind = supervisor.DecisionRefused
+ decision.Reason = err.Error()
+ decision.Transition = nil
+ return Resolution{Snapshot: snapshot, Goal: goal, Decision: decision}, nil
+ }
if applicabilityErr := protocol.ValidateApplicability(snapshot, goal, *decision.Transition, request.Authority, request.Parameters, now); applicabilityErr != nil {
if protocol.IsMissingParameter(applicabilityErr) {
decision.Kind = supervisor.DecisionCandidate
@@ -182,6 +198,9 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe
if canonicalErr != nil {
return result, canonicalErr
}
+ if err := validateReplayGoalState(prior, snapshot); err != nil {
+ return result, err
+ }
if !replayStateSettled(snapshot) {
return result, ReplayRecoveryError{ReceiptID: prior.ID}
}
@@ -233,6 +252,9 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe
if canonicalErr != nil {
return result, canonicalErr
}
+ if err := validateReplayGoalState(prior, current); err != nil {
+ return result, err
+ }
if !replayStateSettled(current) {
return result, ReplayRecoveryError{ReceiptID: prior.ID}
}
@@ -264,6 +286,9 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe
if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil {
return result, err
}
+ if err := validateReplayGoalState(prior, lockedSnapshot); err != nil {
+ return result, err
+ }
if !replayStateSettled(lockedSnapshot) {
return result, ReplayRecoveryError{ReceiptID: prior.ID}
}
@@ -370,8 +395,10 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques
if prior.FlowID != request.FlowID {
return fmt.Errorf("idempotency receipt belongs to flow %q, not %q", prior.FlowID, request.FlowID)
}
- if request.Goal.Validate() == nil && (prior.GoalID != request.Goal.ID || prior.GoalKind != request.Goal.Kind || prior.DeliveryID != request.Goal.DeliveryID) {
- return fmt.Errorf("idempotency receipt belongs to a different configured goal")
+ if prior.GoalScope != catalog.GoalScopeOptionalPreserve && request.Goal.Validate() == nil {
+ if prior.GoalID != request.Goal.ID || prior.GoalKind != request.Goal.Kind || prior.DeliveryID != request.Goal.DeliveryID {
+ return fmt.Errorf("idempotency receipt belongs to a different configured goal")
+ }
}
if request.Requested != "" && prior.TransitionID != request.Requested {
return fmt.Errorf("idempotency receipt belongs to transition %q, not %q", prior.TransitionID, request.Requested)
@@ -379,6 +406,26 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques
return nil
}
+func validateReplayGoalState(prior protocol.TransitionReceipt, snapshot model.Snapshot) error {
+ if prior.GoalScope != catalog.GoalScopeOptionalPreserve {
+ return nil
+ }
+ switch prior.GoalStatus {
+ case model.FactKnown:
+ if snapshot.Goal.Status != model.FactKnown || snapshot.Goal.Value.ID != prior.GoalID ||
+ snapshot.Goal.Value.Kind != prior.GoalKind || snapshot.Goal.Value.DeliveryID != prior.DeliveryID {
+ return fmt.Errorf("idempotency receipt product-goal binding no longer matches current state")
+ }
+ case model.FactAbsent:
+ if snapshot.Goal.Status != model.FactAbsent {
+ return fmt.Errorf("idempotency receipt preserved an absent product goal, but current state is %q", snapshot.Goal.Status)
+ }
+ default:
+ return fmt.Errorf("idempotency receipt has invalid preserved product-goal status %q", prior.GoalStatus)
+ }
+ return nil
+}
+
func replayStateSettled(snapshot model.Snapshot) bool {
return snapshot.Phase.Status == model.FactKnown && snapshot.Phase.Value != model.PhaseRecovery &&
snapshot.Recovery.Status == model.FactKnown && snapshot.Recovery.Value == model.RecoveryNone &&
diff --git a/boatstack/internal/kernel/engine/maintenance_replay_test.go b/boatstack/internal/kernel/engine/maintenance_replay_test.go
new file mode 100644
index 0000000..330f9a8
--- /dev/null
+++ b/boatstack/internal/kernel/engine/maintenance_replay_test.go
@@ -0,0 +1,44 @@
+package engine
+
+import (
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+func TestMaintenanceReplayBindsDurableGoalState(t *testing.T) {
+ // control-law: maintenance-replay-preserves-verified-product-goal-state
+ configured := model.Goal{ID: "configured", Kind: model.GoalOpenPR, DeliveryID: "delivery"}
+ commandGoal := model.Goal{ID: "command", Kind: model.GoalApprovedPlan, DeliveryID: "other"}
+ request := ApplyRequest{ResolveRequest: ResolveRequest{Goal: commandGoal}, FlowID: "flow"}
+
+ tests := []struct {
+ name string
+ receipt protocol.TransitionReceipt
+ fact model.Fact[model.Goal]
+ wantErr bool
+ }{
+ {name: "absent survives command goal and retry", receipt: protocol.TransitionReceipt{GoalScope: catalog.GoalScopeOptionalPreserve, GoalStatus: model.FactAbsent}, fact: model.Fact[model.Goal]{Status: model.FactAbsent}},
+ {name: "known survives conflicting command goal and retry", receipt: protocol.TransitionReceipt{GoalScope: catalog.GoalScopeOptionalPreserve, GoalStatus: model.FactKnown, GoalID: configured.ID, GoalKind: configured.Kind, DeliveryID: configured.DeliveryID}, fact: model.Fact[model.Goal]{Status: model.FactKnown, Value: configured}},
+ {name: "absent cannot replay after product goal appears", receipt: protocol.TransitionReceipt{GoalScope: catalog.GoalScopeOptionalPreserve, GoalStatus: model.FactAbsent}, fact: model.Fact[model.Goal]{Status: model.FactKnown, Value: configured}, wantErr: true},
+ {name: "known cannot replay after product goal changes", receipt: protocol.TransitionReceipt{GoalScope: catalog.GoalScopeOptionalPreserve, GoalStatus: model.FactKnown, GoalID: configured.ID, GoalKind: configured.Kind, DeliveryID: configured.DeliveryID}, fact: model.Fact[model.Goal]{Status: model.FactKnown, Value: commandGoal}, wantErr: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ test.receipt.FlowID = request.FlowID
+ test.receipt.ProgramFingerprint = syntheticProgramFingerprint
+ if err := validateReplayRequest(test.receipt, request, syntheticProgramFingerprint); err != nil {
+ t.Fatalf("command goal affected maintenance replay identity: %v", err)
+ }
+ err := validateReplayGoalState(test.receipt, model.Snapshot{Observation: model.Observation{Goal: test.fact}})
+ if test.wantErr && err == nil {
+ t.Fatal("changed durable product-goal state was accepted for replay")
+ }
+ if !test.wantErr && err != nil {
+ t.Fatalf("unchanged durable product-goal state rejected: %v", err)
+ }
+ })
+ }
+}
diff --git a/boatstack/internal/kernel/protocol/admission.go b/boatstack/internal/kernel/protocol/admission.go
index 082322a..d1ce452 100644
--- a/boatstack/internal/kernel/protocol/admission.go
+++ b/boatstack/internal/kernel/protocol/admission.go
@@ -25,6 +25,8 @@ type Admission struct {
SourcePhase model.ProtocolPhase `json:"source_phase"`
Invocation model.InvocationContext `json:"invocation"`
Goal model.Goal `json:"goal"`
+ GoalScope catalog.GoalScope `json:"goal_scope,omitempty"`
+ GoalStatus model.FactStatus `json:"goal_status,omitempty"`
Authority AuthorityBundle `json:"authority"`
Parameters Parameters `json:"parameters,omitempty"`
Evidence []string `json:"evidence"`
@@ -34,6 +36,11 @@ type Admission struct {
}
func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition, authority AuthorityBundle, parameters Parameters, now time.Time, lifetime time.Duration) (Admission, error) {
+ var err error
+ goal, err = GoalForTransition(snapshot, goal, transition)
+ if err != nil {
+ return Admission{}, err
+ }
if err := ValidateApplicability(snapshot, goal, transition, authority, parameters, now); err != nil {
return Admission{}, err
}
@@ -44,9 +51,12 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T
a := Admission{
SchemaVersion: AdmissionSchemaVersion, TransitionID: transition.ID, TransitionVersion: transition.Version,
ProgramFingerprint: snapshot.ProgramFingerprint, SnapshotFingerprint: snapshot.Fingerprint, SourceRevision: sourceRevision, WorktreeFingerprint: worktreeFingerprint,
- SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Goal: goal, Authority: authority.canonical(),
+ SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Goal: goal, GoalScope: transition.Policy.GoalScope, Authority: authority.canonical(),
Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(),
}
+ if transition.Policy.GoalScope == catalog.GoalScopeOptionalPreserve {
+ a.GoalStatus = snapshot.Goal.Status
+ }
if snapshot.RecordedProgramFingerprint != "" && snapshot.RecordedProgramFingerprint != snapshot.ProgramFingerprint {
a.PriorProgramFingerprint = snapshot.RecordedProgramFingerprint
delta, err := ProgramDeltaFingerprint(snapshot.RecordedProgramFingerprint, snapshot.ProgramFingerprint)
@@ -75,6 +85,28 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T
return a, nil
}
+// GoalForTransition binds maintenance to verified durable product-goal state.
+// Command-scoped product intent is deliberately irrelevant to maintenance.
+func GoalForTransition(snapshot model.Snapshot, requested model.Goal, transition catalog.Transition) (model.Goal, error) {
+ if transition.Policy.GoalScope != catalog.GoalScopeOptionalPreserve {
+ if err := requested.Validate(); err != nil {
+ return model.Goal{}, err
+ }
+ return requested, nil
+ }
+ switch snapshot.Goal.Status {
+ case model.FactKnown:
+ if err := snapshot.Goal.Value.Validate(); err != nil {
+ return model.Goal{}, fmt.Errorf("transition %q has invalid configured product-goal evidence: %w", transition.ID, err)
+ }
+ return snapshot.Goal.Value, nil
+ case model.FactAbsent:
+ return model.Goal{}, nil
+ default:
+ return model.Goal{}, fmt.Errorf("transition %q requires known or verified-absent product goal evidence", transition.ID)
+ }
+}
+
// ValidateApplicability is the deterministic transition law shared by
// resolution and admission. A transition that fails here must never be
// reported as prescribed for the same snapshot and context.
@@ -85,7 +117,9 @@ func ValidateApplicability(snapshot model.Snapshot, goal model.Goal, transition
if err := snapshot.Invocation.Validate(true); err != nil {
return err
}
- if err := goal.Validate(); err != nil {
+ var err error
+ goal, err = GoalForTransition(snapshot, goal, transition)
+ if err != nil {
return err
}
if snapshot.Fingerprint == "" || len(snapshot.ProgramFingerprint) != 64 || !transition.SourceMatches(snapshot) || !transition.SupportsGoal(goal) {
@@ -135,6 +169,9 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, tra
if a.TransitionID != transition.ID || a.TransitionVersion != transition.Version {
return fmt.Errorf("admission %q is bound to a different transition", a.ID)
}
+ if a.GoalScope != transition.Policy.GoalScope {
+ return fmt.Errorf("admission %q is bound to a different product-goal scope", a.ID)
+ }
if a.SnapshotFingerprint != snapshot.Fingerprint {
return fmt.Errorf("admission %q is stale: snapshot changed", a.ID)
}
@@ -163,6 +200,9 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, tra
if a.Goal != goal {
return fmt.Errorf("admission %q is bound to a different goal", a.ID)
}
+ if a.GoalScope == catalog.GoalScopeOptionalPreserve && (a.GoalStatus != snapshot.Goal.Status || (a.GoalStatus == model.FactKnown && a.Goal != snapshot.Goal.Value)) {
+ return fmt.Errorf("admission %q is stale: product goal changed", a.ID)
+ }
if err := a.Authority.Validate(now); err != nil {
return err
}
@@ -316,7 +356,23 @@ func (a Admission) ValidateIdentity() error {
if err := a.Invocation.Validate(true); err != nil {
return err
}
- if err := a.Goal.Validate(); err != nil {
+ if !a.GoalScope.Valid() {
+ return fmt.Errorf("admission has invalid product-goal scope %q", a.GoalScope)
+ }
+ if a.GoalScope == catalog.GoalScopeOptionalPreserve {
+ switch a.GoalStatus {
+ case model.FactKnown:
+ if err := a.Goal.Validate(); err != nil {
+ return err
+ }
+ case model.FactAbsent:
+ if a.Goal.Validate() == nil {
+ return fmt.Errorf("maintenance admission cannot bind product intent to verified absence")
+ }
+ default:
+ return fmt.Errorf("maintenance admission requires known or verified-absent product goal status")
+ }
+ } else if err := a.Goal.Validate(); err != nil {
return err
}
identity := a
diff --git a/boatstack/internal/kernel/protocol/maintenance_goal_test.go b/boatstack/internal/kernel/protocol/maintenance_goal_test.go
new file mode 100644
index 0000000..8a5e7b4
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/maintenance_goal_test.go
@@ -0,0 +1,42 @@
+package protocol
+
+import (
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+func TestMaintenanceGoalBindingUsesOnlyDurableProductState(t *testing.T) {
+ // control-law: maintenance-admission-is-independent-from-command-product-intent
+ transition := catalog.Transition{ID: "installation.update", Policy: catalog.PolicyContract{GoalScope: catalog.GoalScopeOptionalPreserve}}
+ configured := model.Goal{ID: "configured", Kind: model.GoalOpenPR, DeliveryID: "delivery"}
+ conflicting := model.Goal{ID: "command", Kind: model.GoalApprovedPlan, DeliveryID: "other"}
+
+ tests := []struct {
+ name string
+ fact model.Fact[model.Goal]
+ request model.Goal
+ want model.Goal
+ wantFail bool
+ }{
+ {name: "goal absent", fact: model.Fact[model.Goal]{Status: model.FactAbsent}, request: conflicting},
+ {name: "goal known and preserved", fact: model.Fact[model.Goal]{Status: model.FactKnown, Value: configured}, want: configured},
+ {name: "conflicting command-scoped goal ignored", fact: model.Fact[model.Goal]{Status: model.FactKnown, Value: configured}, request: conflicting, want: configured},
+ {name: "unknown goal fails closed", fact: model.Fact[model.Goal]{Status: model.FactUnknown}, request: conflicting, wantFail: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := GoalForTransition(model.Snapshot{Observation: model.Observation{Goal: test.fact}}, test.request, transition)
+ if test.wantFail {
+ if err == nil {
+ t.Fatalf("unknown product-goal evidence produced %#v", got)
+ }
+ return
+ }
+ if err != nil || got != test.want {
+ t.Fatalf("goal binding = %#v, %v; want %#v", got, err, test.want)
+ }
+ })
+ }
+}
diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go
index 453e999..3be4c3e 100644
--- a/boatstack/internal/kernel/protocol/receipt.go
+++ b/boatstack/internal/kernel/protocol/receipt.go
@@ -36,6 +36,8 @@ type TransitionReceipt struct {
GoalID string `json:"goal_id"`
GoalKind model.GoalKind `json:"goal_kind"`
DeliveryID string `json:"delivery_id"`
+ GoalScope catalog.GoalScope `json:"goal_scope,omitempty"`
+ GoalStatus model.FactStatus `json:"goal_status,omitempty"`
SourceFingerprint string `json:"source_fingerprint"`
TargetFingerprint string `json:"target_fingerprint"`
AuthorityClasses []string `json:"authority_classes"`
@@ -68,6 +70,7 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition
receipt := TransitionReceipt{
SchemaVersion: ReceiptSchemaVersion, FlowID: flowID, Sequence: sequence, TransitionID: transition.ID,
TransitionVersion: transition.Version, ProgramFingerprint: admission.ProgramFingerprint, AdmissionID: admission.ID, GoalID: admission.Goal.ID, GoalKind: admission.Goal.Kind, DeliveryID: admission.Goal.DeliveryID,
+ GoalScope: admission.GoalScope, GoalStatus: admission.GoalStatus,
SourceFingerprint: admission.SnapshotFingerprint, TargetFingerprint: target.Fingerprint,
AuthorityClasses: classes, IdempotencyKey: admission.IdempotencyKey, Verifier: transition.Verifier,
Outcome: outcome, Recovery: transition.Interruption.Recovery, Terminal: terminal,
@@ -95,9 +98,28 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition
}
func (r TransitionReceipt) Validate() error {
- if r.SchemaVersion != ReceiptSchemaVersion || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || len(r.ProgramFingerprint) != 64 || r.AdmissionID == "" || r.GoalID == "" || !r.GoalKind.Valid() || r.DeliveryID == "" || r.SourceFingerprint == "" || r.TargetFingerprint == "" || r.IdempotencyKey == "" || r.Verifier == "" {
+ if r.SchemaVersion != ReceiptSchemaVersion || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || len(r.ProgramFingerprint) != 64 || r.AdmissionID == "" || r.SourceFingerprint == "" || r.TargetFingerprint == "" || r.IdempotencyKey == "" || r.Verifier == "" {
return fmt.Errorf("receipt has incomplete identity or evidence")
}
+ if !r.GoalScope.Valid() {
+ return fmt.Errorf("receipt has invalid product-goal scope %q", r.GoalScope)
+ }
+ if r.GoalScope == catalog.GoalScopeOptionalPreserve {
+ switch r.GoalStatus {
+ case model.FactKnown:
+ if r.GoalID == "" || !r.GoalKind.Valid() || r.DeliveryID == "" {
+ return fmt.Errorf("maintenance receipt has incomplete known product-goal binding")
+ }
+ case model.FactAbsent:
+ if r.GoalID != "" || r.GoalKind != "" || r.DeliveryID != "" {
+ return fmt.Errorf("maintenance receipt invents product intent from verified absence")
+ }
+ default:
+ return fmt.Errorf("maintenance receipt requires known or verified-absent product-goal status")
+ }
+ } else if r.GoalID == "" || !r.GoalKind.Valid() || r.DeliveryID == "" {
+ return fmt.Errorf("receipt has incomplete product-goal identity")
+ }
if r.StartedAt.IsZero() || r.CompletedAt.Before(r.StartedAt) || r.DurationNanoseconds != r.CompletedAt.Sub(r.StartedAt).Nanoseconds() {
return fmt.Errorf("receipt has invalid timing evidence")
}
diff --git a/boatstack/internal/kernel/supervisor/supervisor.go b/boatstack/internal/kernel/supervisor/supervisor.go
index fc776cb..872e4d7 100644
--- a/boatstack/internal/kernel/supervisor/supervisor.go
+++ b/boatstack/internal/kernel/supervisor/supervisor.go
@@ -40,7 +40,8 @@ func New(registry catalog.Registry, contracts catalog.GoalContracts) Supervisor
func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority catalog.AuthoritySet, requested catalog.TransitionID) Decision {
base := Decision{SnapshotFingerprint: snapshot.Fingerprint}
- if err := goal.Validate(); err != nil || snapshot.Fingerprint == "" {
+ goalAbsent := snapshot.Goal.Status == model.FactAbsent
+ if (goal.Validate() != nil && !goalAbsent) || snapshot.Fingerprint == "" {
base.Kind, base.Reason = DecisionUnresolved, "goal or canonical snapshot is invalid"
return base
}
diff --git a/boatstack/internal/surfaces/render.go b/boatstack/internal/surfaces/render.go
index 134b4e3..409ffc4 100644
--- a/boatstack/internal/surfaces/render.go
+++ b/boatstack/internal/surfaces/render.go
@@ -25,7 +25,10 @@ type CommandAST struct {
}
func PrescriptionCommand(transition catalog.Transition, repository string, goal model.Goal, flowID string, parameters protocol.Parameters) CommandAST {
- arguments := []string{"apply", "--repo", repository, "--transition", string(transition.ID), "--goal-kind", string(goal.Kind), "--delivery", goal.DeliveryID, "--goal-id", goal.ID, "--flow", flowID}
+ arguments := []string{"apply", "--repo", repository, "--transition", string(transition.ID), "--flow", flowID}
+ if goal.Validate() == nil {
+ arguments = append(arguments, "--goal-kind", string(goal.Kind), "--delivery", goal.DeliveryID, "--goal-id", goal.ID)
+ }
canonical := parameters.Canonical()
for _, parameter := range canonical {
arguments = append(arguments, "--param", parameter.Name+"="+parameter.Value)
diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go
index 0b13156..8c3150b 100644
--- a/boatstack/sdk/sdk.go
+++ b/boatstack/sdk/sdk.go
@@ -48,9 +48,12 @@ const (
type TransitionID = catalog.TransitionID
type Transition = catalog.Transition
+type GoalScope = catalog.GoalScope
type AuthorityClass = catalog.AuthorityClass
const (
+ GoalScopeOptionalPreserve = catalog.GoalScopeOptionalPreserve
+
AuthorityRepository = catalog.AuthorityRepository
AuthorityHuman = catalog.AuthorityHuman
AuthorityAutonomy = catalog.AuthorityAutonomy
diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md
index 2b42308..b468370 100644
--- a/docs/architecture/boatstack-v2-transition-catalog.md
+++ b/docs/architecture/boatstack-v2-transition-catalog.md
@@ -7,39 +7,39 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w
| Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Verifier | Recovery | Cost |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
-| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` |
-| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` |
-| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` |
+| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` |
+| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` |
+| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` |
| `delivery.slice.advance` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` |
-| `engagement.release` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` |
-| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` |
+| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | GOAL_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` |
+| `engagement.release` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` |
+| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` |
| `evidence.approval.revoke` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` |
| `evidence.visual.attach` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` |
| `gate.change.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` |
| `gate.journey.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` |
| `gate.review.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` |
| `gate.test.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` |
-| `goal.configure` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `goal_kind*`, `delivery_id*` | `goal` | `verifier:fresh-observation:goal.configure` | `recovery.resume` | `declared-neutral` |
-| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_path*`, `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `source_revision*`, `runtime_path*`, `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`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` |
-| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` |
+| `goal.configure` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `goal_kind*`, `delivery_id*` | `goal` | `verifier:fresh-observation:goal.configure` | `recovery.resume` | `declared-neutral` |
+| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_path*`, `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `source_revision*`, `runtime_path*`, `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` |
+| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` |
| `plan.abandon` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` |
| `plan.activate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` |
| `plan.amend` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` |
@@ -54,14 +54,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w
| `publication.observe` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` |
| `publication.preview` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` |
| `publication.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` |
-| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` |
-| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` |
-| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` |
-| `repository.attach` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` |
-| `repository.detach` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` |
-| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` |
-| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` |
-| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` |
+| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` |
+| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` |
+| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` |
+| `repository.attach` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` |
+| `repository.detach` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` |
+| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` |
+| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` |
+| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` |
| `workspace.abandon` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` |
| `workspace.activate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` |
| `workspace.cleanup` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` |
diff --git a/release-notes/2026-08-11-maintenance-goal-isolation.md b/release-notes/2026-08-11-maintenance-goal-isolation.md
new file mode 100644
index 0000000..728a938
--- /dev/null
+++ b/release-notes/2026-08-11-maintenance-goal-isolation.md
@@ -0,0 +1,3 @@
+### Keep maintenance independent from product intent
+
+Boatstack maintenance now binds the verified durable product-goal state as preserved context instead of requiring or creating a delivery goal. Updates and their recovery paths preserve a known goal, preserve verified absence, reject unknown goal evidence, and keep command-scoped product intent outside maintenance admission.