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 6ef2943..ca87217 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 7eeb7f2..f12414d 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 4b0b3f0..bd35233 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 49be596..a9553a8 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 af56d79..438dab5 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 2aa487b..76cc293 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 7485211..ae3a954 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 e0d24f0..7a9b7bf 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 94f22f2..f20e5c5 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 028cc50..0f3ebef 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 5c2214d..f619614 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 f73f87e..c12b79b 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 e930842..a7f901c 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 54dbd8f..bb5e380 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 ee15805..fe18b47 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 ccf843b..c6c3735 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 ba775f1..5b6491b 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 37e8ced..31796d9 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 17989d4..5d0a7e3 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 6811882..4bac11a 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 2dd67b2..164403a 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 0000000..e422c82 --- /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 7a9b7bf..01d8415 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 f20e5c5..9c4e56b 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 1af9c58..0c80bb5 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 5b6491b..f782f3a 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 e422c82..1ef5ee7 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.