From aeec98c1372f636068e88fba3a0e9b6949398006 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 11 Aug 2026 17:28:36 +0100 Subject: [PATCH 1/2] Fix transition prescription consistency --- .github/tests/test_detached_supervision.py | 40 +++++- boatstack/core/transitions.json | 1 + .../flow/standard/supervisor_parity_test.go | 54 ++++++++ boatstack/flow/standard/transitions.json | 4 +- boatstack/internal/effects/host_skills.go | 6 +- .../internal/effects/host_skills_test.go | 2 +- .../internal/kernel/catalog/transition.go | 1 + boatstack/internal/kernel/engine/engine.go | 15 +++ .../internal/kernel/engine/engine_test.go | 38 ++++++ .../internal/kernel/protocol/admission.go | 88 +++++++------ .../internal/kernel/protocol/parameters.go | 17 ++- .../internal/kernel/supervisor/supervisor.go | 19 ++- boatstack/internal/plant/observer.go | 7 +- boatstack/internal/plant/observer_test.go | 13 ++ boatstack/kernel.go | 2 +- boatstack/references/workflow.md | 5 +- docs/architecture/boatstack-v2-kernel.md | 1 + .../boatstack-v2-locus-liveness.json | 33 +++++ .../boatstack-v2-locus-safety.json | 33 +++++ .../boatstack-v2-transition-catalog.md | 124 +++++++++--------- .../boatstack-v2-transition-catalog.mmd | 1 + .../2026-08-11-transition-law-consistency.md | 3 + 22 files changed, 390 insertions(+), 117 deletions(-) create mode 100644 release-notes/2026-08-11-transition-law-consistency.md diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index 6ef2943d..ca872176 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import os import subprocess @@ -247,7 +248,7 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - "next", "--repo", self.repo, *goal, *flow, "--human", "contract", "--repository-authority", ) - self.assertEqual(progressing["decision"]["kind"], "PRESCRIBED") + self.assertEqual(progressing["decision"]["kind"], "CANDIDATE") self.assertEqual(progressing["decision"]["transition"]["id"], "plan.create") plan = Path(self.work.name) / "source-plan.md" @@ -301,7 +302,7 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali prescribed = self.helper_json( "next", "--repo", self.repo, *goal, *flow, *actor, ) - self.assertEqual(prescribed["decision"]["kind"], "PRESCRIBED") + self.assertEqual(prescribed["decision"]["kind"], "CANDIDATE") self.assertEqual( prescribed["decision"]["transition"]["id"], "installation.initialize" ) @@ -321,6 +322,28 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali } ) ) + canonical_config = json.loads(config.read_text()) + canonical_config["hosts"] = sorted(canonical_config["hosts"]) + canonical_config["policy"]["external_effect_authority"] = ( + "human-or-autonomy-plus-provider" + ) + config_fingerprint = hashlib.sha256( + json.dumps(canonical_config, separators=(",", ":")).encode() + ).hexdigest() + bound_initialization = self.helper_json( + "next", "--repo", self.repo, + "--transition", "installation.initialize", *goal, *flow, *actor, + "--param", f"source_revision={self._git(self.repo, 'rev-parse', 'HEAD').stdout.strip()}", + "--param", f"runtime_path={self.binary.resolve()}", + "--param", f"runtime_sha256={hashlib.sha256(self.binary.read_bytes()).hexdigest()}", + "--param", f"config_path={config}", + "--param", f"config_sha256={config_fingerprint}", + ) + self.assertEqual(bound_initialization["decision"]["kind"], "PRESCRIBED") + self.assertEqual( + bound_initialization["decision"]["transition"]["id"], + "installation.initialize", + ) initialized_process = self.run_helper( "init", "--repo", self.repo, *goal, *flow, *actor, "--param", f"config_path={config}", @@ -357,9 +380,20 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali "next", "--repo", self.repo, *goal, *flow, *actor, "--repository-authority", ) - self.assertEqual(plan["decision"]["kind"], "PRESCRIBED") + self.assertEqual(plan["decision"]["kind"], "CANDIDATE") self.assertEqual(plan["decision"]["transition"]["id"], "plan.create") + plan_source = Path(self.work.name) / "retained-authority-plan.md" + plan_source.write_text("# Retained authority\n\nContinue in one operation context.\n") + bound = self.helper_json( + "next", "--repo", self.repo, "--transition", "plan.create", + *goal, *flow, *actor, "--repository-authority", + "--param", f"source_path={plan_source}", + "--param", "delivery_id=preserve-repository-authority-context", + ) + self.assertEqual(bound["decision"]["kind"], "PRESCRIBED") + self.assertEqual(bound["decision"]["transition"]["id"], "plan.create") + def test_repository_authority_rematerialization_fails_closed_without_verified_config(self) -> None: # control-law: repository-authority-requires-exact-verified-fingerprint root = Path(self.work.name) / "unverified" diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index 7eeb7f21..f12414d0 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -2672,6 +2672,7 @@ "class": "authority", "source_phases": [ "OBSERVED", + "DORMANT", "ACTIVE", "FRONTIER", "TERMINAL", diff --git a/boatstack/flow/standard/supervisor_parity_test.go b/boatstack/flow/standard/supervisor_parity_test.go index 4b0b3f03..bd352331 100644 --- a/boatstack/flow/standard/supervisor_parity_test.go +++ b/boatstack/flow/standard/supervisor_parity_test.go @@ -140,6 +140,60 @@ func TestUntargetedResolutionReconfiguresDifferentGoalAndSkipsSatisfiedGoal(t *t } } +func TestDormantBootstrapGoalReconfiguresBeforeEngagement(t *testing.T) { + // control-law: a retained bootstrap goal cannot be bypassed by engagement + snapshot := snapshotFor(t, model.PhaseDormant, model.TerminalNonterminal) + requested := model.Goal{ID: "basic-project", Kind: model.GoalApprovedPlan, DeliveryID: "basic-project"} + authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} + + untargeted := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, requested, authority, "") + if untargeted.Kind != DecisionPrescribed || untargeted.Transition == nil || untargeted.Transition.ID != "goal.configure" { + t.Fatalf("untargeted decision = %#v, want goal.configure", untargeted) + } + targeted := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, requested, authority, untargeted.Transition.ID) + if targeted.Kind != DecisionPrescribed || targeted.Transition == nil || targeted.Transition.ID != untargeted.Transition.ID { + t.Fatalf("targeted decision = %#v, want parity with %#v", targeted, untargeted) + } + engagement := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, requested, authority, "engagement.begin") + if engagement.Kind != DecisionRefused { + t.Fatalf("engagement decision = %#v, want refusal until goal.configure", engagement) + } +} + +func TestDisabledHostIsRefusedBeforeUntargetedSelection(t *testing.T) { + // control-law: host policy applies before both targeted and untargeted selection + snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) + snapshot.Invocation.Host = "codex" + snapshot = recanonicalize(t, snapshot) + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "") + if decision.Kind != DecisionRefused || decision.Transition != nil { + t.Fatalf("disabled-host decision = %#v, want REFUSED", decision) + } +} + +func TestPublicationObservationRemainsSelectableForVolatileExternalState(t *testing.T) { + // control-law: a nonterminal provider observation is evidence, not permanent progress + snapshot, goal := openPRSnapshot(t, "build", "test", "review", "change", "journey") + goal.Kind = model.GoalMerged + snapshot.Goal = model.Known(goal, snapshot.Goal.Evidence[0]) + snapshot.Publication = model.Known(model.PublicationOpen, snapshot.Publication.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + var transitions []catalog.Transition + for _, transition := range testprogram.StandardRegistry().All() { + if transition.ID == "publication.observe" || transition.Class == catalog.EventRecovery { + transitions = append(transitions, transition) + } + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + decision := New(registry, testGoalContracts()).Resolve(snapshot, goal, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "publication.observe" { + t.Fatalf("volatile publication decision = %#v, want publication.observe", decision) + } +} + func TestUntargetedResolutionExcludesExplicitControlTransitions(t *testing.T) { // control-law: untargeted-resolution-cannot-invent-repair-or-slice-intent snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json index 49be596e..a9553a80 100644 --- a/boatstack/flow/standard/transitions.json +++ b/boatstack/flow/standard/transitions.json @@ -5583,7 +5583,9 @@ "privacy_classification": "metadata-only", "telemetry_classification": "transition-receipt", "cost_class": "declared-neutral", - "policy": {}, + "policy": { + "rechecks_external_state": true + }, "priority": 77 }, { diff --git a/boatstack/internal/effects/host_skills.go b/boatstack/internal/effects/host_skills.go index af56d796..438dab5c 100644 --- a/boatstack/internal/effects/host_skills.go +++ b/boatstack/internal/effects/host_skills.go @@ -92,8 +92,10 @@ materialized authority receipts. %s -Begin each cycle with an untargeted authority-bearing `+"`next`"+`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `+"`next`"+`. A `+"`CANDIDATE`"+` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `+"`PRESCRIBED`"+` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/boatstack/internal/effects/host_skills_test.go b/boatstack/internal/effects/host_skills_test.go index 2aa487b4..76cc293e 100644 --- a/boatstack/internal/effects/host_skills_test.go +++ b/boatstack/internal/effects/host_skills_test.go @@ -50,7 +50,7 @@ func TestHostSkillProjectionPreservesAuthorityBoundaries(t *testing.T) { for _, contract := range []string{ "authority-free\n`FRONTIER`", "command-scoped context", "every `next`, `apply`, `recover`, and re-resolution", "requested authority sources separately from currently\nmaterialized authority receipts", - "complete apply response and stderr", "authority-bearing `FRONTIER`", "Never synthesize missing\nauthority", + "complete apply response and stderr", "authority-bearing `FRONTIER`", "Never synthesize missing\nauthority", "`CANDIDATE`", "immediately preceding `PRESCRIBED`", "every requested authority source is materialized\nor conclusively rejected against the post-receipt state", } { if !strings.Contains(value, contract) { diff --git a/boatstack/internal/kernel/catalog/transition.go b/boatstack/internal/kernel/catalog/transition.go index 7485211b..ae3a954e 100644 --- a/boatstack/internal/kernel/catalog/transition.go +++ b/boatstack/internal/kernel/catalog/transition.go @@ -184,6 +184,7 @@ type PolicyContract struct { 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"` } // FacetCondition is an executable, serializable predicate over one canonical diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go index e0d24f00..7a9b7bf7 100644 --- a/boatstack/internal/kernel/engine/engine.go +++ b/boatstack/internal/kernel/engine/engine.go @@ -40,6 +40,7 @@ type ResolveRequest struct { Invocation model.InvocationContext Goal model.Goal Authority protocol.AuthorityBundle + Parameters protocol.Parameters Requested catalog.TransitionID } @@ -76,6 +77,19 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution return Resolution{}, err } decision := e.control.Resolve(snapshot, goal, request.Authority.Set(now), request.Requested) + if decision.Kind == supervisor.DecisionPrescribed && decision.Transition != nil { + if applicabilityErr := protocol.ValidateApplicability(snapshot, goal, *decision.Transition, request.Authority, request.Parameters, now); applicabilityErr != nil { + if protocol.IsMissingParameter(applicabilityErr) { + decision.Kind = supervisor.DecisionCandidate + decision.Reason = applicabilityErr.Error() + "; bind the declared parameters and re-resolve this transition" + decision.Candidates = []catalog.TransitionID{decision.Transition.ID} + } else { + decision.Kind = supervisor.DecisionRefused + decision.Reason = applicabilityErr.Error() + decision.Transition = nil + } + } + } return Resolution{Snapshot: snapshot, Goal: goal, Decision: decision}, nil } @@ -170,6 +184,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if request.AdmissionLifetime <= 0 { request.AdmissionLifetime = 2 * time.Minute } + request.ResolveRequest.Parameters = request.Parameters resolution, err := e.Resolve(ctx, request.ResolveRequest) result.Source, result.Goal, result.Decision = resolution.Snapshot, resolution.Goal, resolution.Decision if err != nil { diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index 94f22f2c..f20e5c5a 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -274,6 +274,44 @@ func TestRequiredObserverFailureReturnsTypedUnresolvedDecision(t *testing.T) { } } +func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T) { + // control-law: a selected transition is only a candidate until deterministic admission inputs are complete + now := time.Unix(30, 0).UTC() + transitions := testRegistry(t).All() + for index := range transitions { + if transitions[index].ID == "test.advance" { + transitions[index].Parameters = []catalog.ParameterSpec{{Name: "value", Required: true}} + } + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}} + kernel, err := New(registry, syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + req := request(now).ResolveRequest + req.Requested = "" + candidate, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if candidate.Decision.Kind != supervisor.DecisionCandidate || candidate.Decision.Transition == nil || candidate.Decision.Transition.ID != "test.advance" { + t.Fatalf("incomplete resolution = %+v, want CANDIDATE", candidate.Decision) + } + req.Requested = "test.advance" + req.Parameters = protocol.Parameters{{Name: "value", Value: "bound"}} + prescribed, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if prescribed.Decision.Kind != supervisor.DecisionPrescribed || prescribed.Decision.Transition == nil || prescribed.Decision.Transition.ID != "test.advance" { + t.Fatalf("complete resolution = %+v, want PRESCRIBED", prescribed.Decision) + } +} + func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) { // control-law: synthetic-flow-crosses-exact-admission-and-postcondition-without-standard-flow now := time.Unix(30, 0).UTC() diff --git a/boatstack/internal/kernel/protocol/admission.go b/boatstack/internal/kernel/protocol/admission.go index 028cc50d..0f3ebeff 100644 --- a/boatstack/internal/kernel/protocol/admission.go +++ b/boatstack/internal/kernel/protocol/admission.go @@ -32,49 +32,13 @@ 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) { - if !transition.Controllable() { - return Admission{}, fmt.Errorf("transition %q is uncontrollable and cannot be admitted", transition.ID) - } - if err := snapshot.Invocation.Validate(true); err != nil { - return Admission{}, err - } - if err := goal.Validate(); err != nil { + if err := ValidateApplicability(snapshot, goal, transition, authority, parameters, now); err != nil { return Admission{}, err } - if snapshot.Fingerprint == "" || len(snapshot.ProgramFingerprint) != 64 || !transition.SourceMatches(snapshot) || !transition.SupportsGoal(goal) { - return Admission{}, fmt.Errorf("transition %q is not admissible from snapshot %q", transition.ID, snapshot.Fingerprint) - } if lifetime <= 0 { return Admission{}, fmt.Errorf("admission lifetime must be positive") } - if err := authority.Validate(now); err != nil { - return Admission{}, err - } - if err := validateAuthorityEvidence(snapshot, authority); err != nil { - return Admission{}, err - } - if err := parameters.Validate(transition); err != nil { - return Admission{}, err - } - if err := validateProviderAuthorityBinding(authority, transition, parameters); err != nil { - return Admission{}, err - } sourceRevision, worktreeFingerprint := gitBinding(snapshot) - if transition.BindsSourceRevision { - declared, _ := parameters.Get("source_revision") - if sourceRevision == "" || worktreeFingerprint == "" || declared != sourceRevision { - return Admission{}, fmt.Errorf("transition %q must bind the current Git revision and worktree fingerprint", transition.ID) - } - } - if !authority.Set(now).Satisfies(transition.Authority, transition.AuthorityAll) { - return Admission{}, fmt.Errorf("transition %q lacks required authority", transition.ID) - } - if err := validatePolicyAuthority(snapshot, transition, authority.Set(now)); err != nil { - return Admission{}, err - } - if err := validateRecoveryPermission(snapshot, transition); err != nil { - return Admission{}, err - } a := Admission{ SchemaVersion: AdmissionSchemaVersion, TransitionID: transition.ID, TransitionVersion: transition.Version, ProgramFingerprint: snapshot.ProgramFingerprint, SnapshotFingerprint: snapshot.Fingerprint, SourceRevision: sourceRevision, WorktreeFingerprint: worktreeFingerprint, @@ -101,6 +65,56 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T return a, nil } +// 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. +func ValidateApplicability(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition, authority AuthorityBundle, parameters Parameters, now time.Time) error { + if !transition.Controllable() { + return fmt.Errorf("transition %q is uncontrollable and cannot be admitted", transition.ID) + } + if err := snapshot.Invocation.Validate(true); err != nil { + return err + } + if err := goal.Validate(); err != nil { + return err + } + if snapshot.Fingerprint == "" || len(snapshot.ProgramFingerprint) != 64 || !transition.SourceMatches(snapshot) || !transition.SupportsGoal(goal) { + return fmt.Errorf("transition %q is not admissible from snapshot %q", transition.ID, snapshot.Fingerprint) + } + if snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value != goal && !transition.Policy.BindsRequestedGoal { + return fmt.Errorf("transition %q cannot replace configured goal; goal.configure is required", transition.ID) + } + if err := authority.Validate(now); err != nil { + return err + } + if err := validateAuthorityEvidence(snapshot, authority); err != nil { + return err + } + if err := parameters.Validate(transition); err != nil { + return err + } + if err := validateProviderAuthorityBinding(authority, transition, parameters); err != nil { + return err + } + sourceRevision, worktreeFingerprint := gitBinding(snapshot) + if transition.BindsSourceRevision { + declared, _ := parameters.Get("source_revision") + if sourceRevision == "" || worktreeFingerprint == "" || declared != sourceRevision { + return fmt.Errorf("transition %q must bind the current Git revision and worktree fingerprint", transition.ID) + } + } + if !authority.Set(now).Satisfies(transition.Authority, transition.AuthorityAll) { + return fmt.Errorf("transition %q lacks required authority", transition.ID) + } + if err := validatePolicyAuthority(snapshot, transition, authority.Set(now)); err != nil { + return err + } + if err := validateRecoveryPermission(snapshot, transition); err != nil { + return err + } + return nil +} + func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition, now time.Time) error { if err := a.ValidateIdentity(); err != nil { return err diff --git a/boatstack/internal/kernel/protocol/parameters.go b/boatstack/internal/kernel/protocol/parameters.go index 5c2214d6..f6196149 100644 --- a/boatstack/internal/kernel/protocol/parameters.go +++ b/boatstack/internal/kernel/protocol/parameters.go @@ -1,6 +1,7 @@ package protocol import ( + "errors" "fmt" "path/filepath" "sort" @@ -15,6 +16,20 @@ type Parameter struct { type Parameters []Parameter +type MissingParameterError struct { + Transition catalog.TransitionID + Parameter string +} + +func (e MissingParameterError) Error() string { + return fmt.Sprintf("transition %q requires parameter %q", e.Transition, e.Parameter) +} + +func IsMissingParameter(err error) bool { + var missing MissingParameterError + return errors.As(err, &missing) +} + func (p Parameters) Canonical() Parameters { result := append(Parameters(nil), p...) sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) @@ -63,7 +78,7 @@ func (p Parameters) Validate(transition catalog.Transition) error { } for _, spec := range transition.Parameters { if spec.Required && !seen[spec.Name] { - return fmt.Errorf("transition %q requires parameter %q", transition.ID, spec.Name) + return MissingParameterError{Transition: transition.ID, Parameter: spec.Name} } } return nil diff --git a/boatstack/internal/kernel/supervisor/supervisor.go b/boatstack/internal/kernel/supervisor/supervisor.go index f73f87ec..c12b79be 100644 --- a/boatstack/internal/kernel/supervisor/supervisor.go +++ b/boatstack/internal/kernel/supervisor/supervisor.go @@ -12,6 +12,7 @@ type DecisionKind string const ( DecisionPrescribed DecisionKind = "PRESCRIBED" + DecisionCandidate DecisionKind = "CANDIDATE" DecisionTerminal DecisionKind = "TERMINAL" DecisionFrontier DecisionKind = "FRONTIER" DecisionBlocked DecisionKind = "BLOCKED" @@ -57,11 +58,7 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority return base } } - if requested != "" && snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value != goal && requested != "goal.configure" { - base.Kind, base.Reason = DecisionRefused, "requested goal differs from configured goal; goal.configure is required" - return base - } - if requested != "" && snapshot.ConfigurationPolicy.Status == model.FactKnown && !hostEnabled(snapshot.ConfigurationPolicy.Value.Hosts, snapshot.Invocation.Host) { + if snapshot.ConfigurationPolicy.Status == model.FactKnown && !hostEnabled(snapshot.ConfigurationPolicy.Value.Hosts, snapshot.Invocation.Host) { base.Kind, base.Reason = DecisionRefused, fmt.Sprintf("host %q is not enabled by repository policy", snapshot.Invocation.Host) return base } @@ -70,6 +67,15 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority return base } admissible := s.registry.Admissible(snapshot, goal) + if snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value != goal { + filtered := admissible[:0] + for _, candidate := range admissible { + if candidate.Policy.BindsRequestedGoal { + filtered = append(filtered, candidate) + } + } + admissible = filtered + } if snapshot.Phase.Value == model.PhaseRecovery { filtered := admissible[:0] for _, candidate := range admissible { @@ -150,6 +156,9 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority } func targetAlreadySatisfied(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition) bool { + if transition.Policy.RechecksExternalState { + return false + } if transition.Policy.BindsRequestedGoal { return snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value == goal } diff --git a/boatstack/internal/plant/observer.go b/boatstack/internal/plant/observer.go index e9308423..a7f901c1 100644 --- a/boatstack/internal/plant/observer.go +++ b/boatstack/internal/plant/observer.go @@ -646,7 +646,7 @@ func pendingJournalEvidence(root, ignoreAdmissionID string, now time.Time) (pend if budget < 0 { budget = 0 } - permitted := recoveryContract(header.TransitionID, external, budget) + permitted := recoveryContract(header.TransitionID, external, len(header.Mutations) > 0, budget) cause := header.Reason if cause == "" { cause = "process ended before transition receipt" @@ -739,7 +739,7 @@ func conflictingPending(records []pendingJournalRecord) pendingJournalSet { return result } -func recoveryContract(transitionID string, external bool, budget int) []string { +func recoveryContract(transitionID string, external, staged bool, budget int) []string { if budget == 0 { return []string{"recovery.escalate"} } @@ -756,6 +756,9 @@ func recoveryContract(transitionID string, external bool, budget int) []string { case "workspace.cleanup", "workspace.reap": return []string{"recovery.escalate"} default: + if !staged { + return []string{"recovery.rollback", "recovery.escalate"} + } return []string{"recovery.resume", "recovery.rollback", "recovery.escalate"} } } diff --git a/boatstack/internal/plant/observer_test.go b/boatstack/internal/plant/observer_test.go index 54dbd8f6..bb5e380e 100644 --- a/boatstack/internal/plant/observer_test.go +++ b/boatstack/internal/plant/observer_test.go @@ -249,6 +249,19 @@ func TestRecoveryAttemptsExhaustToEscalationOnly(t *testing.T) { } } +func TestRecoveryWithoutStagedManifestCannotPrescribeResume(t *testing.T) { + // control-law: recovery selection cannot promise a replay that prepare will reject + permitted := recoveryContract("plan.create", false, false, 3) + for _, transition := range permitted { + if transition == "recovery.resume" { + t.Fatalf("unstaged recovery permits resume: %v", permitted) + } + } + if len(permitted) != 2 || permitted[0] != "recovery.rollback" || permitted[1] != "recovery.escalate" { + t.Fatalf("unstaged recovery contract = %v", permitted) + } +} + func TestInterruptedRecoveryAttemptCollapsesToEscalatableTransactionGroup(t *testing.T) { // control-law: recovery-of-recovery-does-not-create-an-unselectable-conflict root := t.TempDir() diff --git a/boatstack/kernel.go b/boatstack/kernel.go index ee15805f..fe18b479 100644 --- a/boatstack/kernel.go +++ b/boatstack/kernel.go @@ -109,7 +109,7 @@ func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces. } switch request.Operation { case surfaces.OperationResolve: - resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Goal: request.Goal, Authority: request.Authority, Requested: request.TransitionID}) + resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Goal: request.Goal, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID}) response.Goal, response.Decision = resolution.Goal, &resolution.Decision if resolution.Snapshot.Fingerprint != "" { response.Snapshot = &resolution.Snapshot diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index ccf843b3..c6c37351 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -17,8 +17,9 @@ Event families: - recovery; - observed external plant changes. -The supervisor returns one of `PRESCRIBED`, `TERMINAL`, `FRONTIER`, -`BLOCKED`, `REFUSED`, or `UNRESOLVED`. Only `PRESCRIBED` can produce an +The supervisor returns one of `CANDIDATE`, `PRESCRIBED`, `TERMINAL`, `FRONTIER`, +`BLOCKED`, `REFUSED`, or `UNRESOLVED`. `CANDIDATE` identifies the deterministic +next transition while required parameters remain unbound. Only `PRESCRIBED` can produce an admission. Only an independently verified postcondition can produce a receipt. Untargeted resolution excludes transitions whose target is already established and transitions that encode separate maintenance, repair, abandonment, or diff --git a/docs/architecture/boatstack-v2-kernel.md b/docs/architecture/boatstack-v2-kernel.md index ba775f19..5b6491b6 100644 --- a/docs/architecture/boatstack-v2-kernel.md +++ b/docs/architecture/boatstack-v2-kernel.md @@ -531,6 +531,7 @@ it is not an independently maintained graph. `supervisor.Resolve(snapshot, goal, authority, optionalObservedEvent)` is pure and deterministic. It evaluates the executable registry and returns exactly one: +- `CANDIDATE`: one deterministic next transition still needs declared parameters; - `PRESCRIBED`: one exact next transition and prescription; - `TERMINAL`: goal predicate established by current terminal evidence; - `FRONTIER`: a genuine human/reasoning authority decision is required; diff --git a/docs/architecture/boatstack-v2-locus-liveness.json b/docs/architecture/boatstack-v2-locus-liveness.json index 37e8cedd..31796d9a 100644 --- a/docs/architecture/boatstack-v2-locus-liveness.json +++ b/docs/architecture/boatstack-v2-locus-liveness.json @@ -3403,6 +3403,39 @@ ], "basis": "inferred" }, + { + "from": "DORMANT", + "event": "goal.configure", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "goal.configure", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "goal.configure", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "ACTIVE", "event": "goal.configure", diff --git a/docs/architecture/boatstack-v2-locus-safety.json b/docs/architecture/boatstack-v2-locus-safety.json index 17989d43..5d0a7e32 100644 --- a/docs/architecture/boatstack-v2-locus-safety.json +++ b/docs/architecture/boatstack-v2-locus-safety.json @@ -3406,6 +3406,39 @@ ], "basis": "inferred" }, + { + "from": "DORMANT", + "event": "goal.configure", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "goal.configure", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "goal.configure", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "ACTIVE", "event": "goal.configure", diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md index 68118824..4bac11a0 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-v2-transition-catalog.md @@ -7,67 +7,67 @@ 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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / 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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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.update` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE | OBSERVED / ACTIVE / TERMINAL | 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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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` | -| `plan.approve` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | -| `plan.approve-amendment` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | -| `plan.create` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | -| `plan.invalidate` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | -| `plan.validate` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | -| `publication.abandon` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | -| `publication.correct` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | -| `publication.execute` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | -| `publication.observe` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `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` | -| `workspace.cut` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | -| `workspace.publish` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | -| `workspace.reap` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | -| `workspace.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | -| `workspace.sync` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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.update` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE | OBSERVED / ACTIVE / TERMINAL | 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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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` | +| `plan.approve` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | +| `plan.approve-amendment` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | +| `plan.create` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | +| `plan.invalidate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | +| `plan.validate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | +| `publication.abandon` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | +| `publication.correct` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | +| `publication.execute` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | +| `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `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` | +| `workspace.cut` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | +| `workspace.publish` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | +| `workspace.reap` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | +| `workspace.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | +| `workspace.sync` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | `*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`. diff --git a/docs/architecture/boatstack-v2-transition-catalog.mmd b/docs/architecture/boatstack-v2-transition-catalog.mmd index 2dd67b25..164403a6 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.mmd +++ b/docs/architecture/boatstack-v2-transition-catalog.mmd @@ -95,6 +95,7 @@ flowchart TB p_FRONTIER --> t03 t03 --> p_FRONTIER p_OBSERVED --> t04 + p_DORMANT --> t04 p_ACTIVE --> t04 p_FRONTIER --> t04 p_TERMINAL --> t04 diff --git a/release-notes/2026-08-11-transition-law-consistency.md b/release-notes/2026-08-11-transition-law-consistency.md new file mode 100644 index 00000000..e422c821 --- /dev/null +++ b/release-notes/2026-08-11-transition-law-consistency.md @@ -0,0 +1,3 @@ +### Keep prescriptions consistent with deterministic apply checks + +Boatstack now applies one shared applicability law before reporting a transition as prescribed. Required parameters are surfaced as a candidate before admission, retained goals are reconfigured before engagement, recovery omits impossible resume actions, and volatile provider observations remain selectable until the configured goal is established. From 8a45f811b67603031bcd461e0cd62c8e6a589a73 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 11 Aug 2026 17:41:07 +0100 Subject: [PATCH 2/2] Preflight effects before prescription --- boatstack/internal/kernel/engine/engine.go | 11 ++++++++ .../internal/kernel/engine/engine_test.go | 26 +++++++++++++++++++ boatstack/internal/kernel/ports/ports.go | 2 ++ docs/architecture/boatstack-v2-kernel.md | 3 +++ .../2026-08-11-transition-law-consistency.md | 2 +- 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go index 7a9b7bf7..01d84153 100644 --- a/boatstack/internal/kernel/engine/engine.go +++ b/boatstack/internal/kernel/engine/engine.go @@ -88,6 +88,17 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution decision.Reason = applicabilityErr.Error() decision.Transition = nil } + } else { + admission, admissionErr := protocol.NewAdmission(snapshot, goal, *decision.Transition, request.Authority, request.Parameters, now, 2*time.Minute) + if admissionErr != nil { + decision.Kind = supervisor.DecisionUnresolved + decision.Reason = admissionErr.Error() + decision.Transition = nil + } else if _, preflightErr := e.effects.Prepare(ctx, admission, *decision.Transition); preflightErr != nil { + decision.Kind = supervisor.DecisionUnresolved + decision.Reason = fmt.Sprintf("transition %q failed deterministic effect preflight: %v", admission.TransitionID, preflightErr) + decision.Transition = nil + } } } return Resolution{Snapshot: snapshot, Goal: goal, Decision: decision}, nil diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index f20e5c5a..9c4e56b5 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -105,9 +105,13 @@ type fakeEffects struct { executions, rollbacks int result ports.EffectResult err error + prepareErr error } func (e *fakeEffects) Prepare(context.Context, protocol.Admission, catalog.Transition) (ports.PreparedEffect, error) { + if e.prepareErr != nil { + return nil, e.prepareErr + } return e, nil } func (e *fakeEffects) Manifest() []ports.ResourceMutation { return nil } @@ -312,6 +316,28 @@ func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T } } +func TestResolutionDoesNotPrescribeAnEffectThatDeterministicPreflightRejects(t *testing.T) { + // control-law: effect preparation cannot introduce a deterministic apply-only refusal + now := time.Unix(30, 0).UTC() + effects := &fakeEffects{prepareErr: errors.New("malformed artifact")} + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source")}} + journal := &fakeJournal{} + kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + resolved, err := kernel.Resolve(context.Background(), request(now).ResolveRequest) + if err != nil { + t.Fatal(err) + } + if resolved.Decision.Kind != supervisor.DecisionUnresolved || resolved.Decision.Transition != nil || !strings.Contains(resolved.Decision.Reason, "malformed artifact") { + t.Fatalf("preflight decision = %+v, want typed UNRESOLVED without prescription", resolved.Decision) + } + if effects.executions != 0 || journal.begun != 0 { + t.Fatalf("preflight crossed mutation boundary: effects=%d journals=%d", effects.executions, journal.begun) + } +} + func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) { // control-law: synthetic-flow-crosses-exact-admission-and-postcondition-without-standard-flow now := time.Unix(30, 0).UTC() diff --git a/boatstack/internal/kernel/ports/ports.go b/boatstack/internal/kernel/ports/ports.go index 1af9c582..0c80bb5c 100644 --- a/boatstack/internal/kernel/ports/ports.go +++ b/boatstack/internal/kernel/ports/ports.go @@ -92,6 +92,8 @@ type PreparedEffect interface { } type EffectDriver interface { + // Prepare is a side-effect-free preflight. It may read exact plant state and + // construct a mutation manifest, but it must not execute or install it. Prepare(context.Context, protocol.Admission, catalog.Transition) (PreparedEffect, error) } diff --git a/docs/architecture/boatstack-v2-kernel.md b/docs/architecture/boatstack-v2-kernel.md index 5b6491b6..f782f3a0 100644 --- a/docs/architecture/boatstack-v2-kernel.md +++ b/docs/architecture/boatstack-v2-kernel.md @@ -548,6 +548,9 @@ repository presence is not engagement; a saved plan is not active authority. Resolution never fabricates progress. If several controllable events remain equally admissible after declared deterministic priority, the answer is `FRONTIER` or `UNRESOLVED`, never map-order selection or first-match behavior. +Before `PRESCRIBED`, resolution also runs the effect driver's side-effect-free +preflight over the exact admission context; deterministic artifact, durable-state, +or recovery refusals therefore cannot first appear at apply. ## 9. Admission and authority model diff --git a/release-notes/2026-08-11-transition-law-consistency.md b/release-notes/2026-08-11-transition-law-consistency.md index e422c821..1ef5ee71 100644 --- a/release-notes/2026-08-11-transition-law-consistency.md +++ b/release-notes/2026-08-11-transition-law-consistency.md @@ -1,3 +1,3 @@ ### Keep prescriptions consistent with deterministic apply checks -Boatstack now applies one shared applicability law before reporting a transition as prescribed. Required parameters are surfaced as a candidate before admission, retained goals are reconfigured before engagement, recovery omits impossible resume actions, and volatile provider observations remain selectable until the configured goal is established. +Boatstack now applies one shared applicability law and side-effect-free effect preflight before reporting a transition as prescribed. Required parameters are surfaced as a candidate before admission, retained goals are reconfigured before engagement, recovery omits impossible resume actions, and volatile provider observations remain selectable until the configured goal is established.