Skip to content

Commit cedaf56

Browse files
authored
Fix transition prescription consistency (#190)
* Fix transition prescription consistency * Preflight effects before prescription
1 parent 400818b commit cedaf56

23 files changed

Lines changed: 432 additions & 117 deletions

.github/tests/test_detached_supervision.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import hashlib
56
import json
67
import os
78
import subprocess
@@ -247,7 +248,7 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
247248
"next", "--repo", self.repo, *goal, *flow,
248249
"--human", "contract", "--repository-authority",
249250
)
250-
self.assertEqual(progressing["decision"]["kind"], "PRESCRIBED")
251+
self.assertEqual(progressing["decision"]["kind"], "CANDIDATE")
251252
self.assertEqual(progressing["decision"]["transition"]["id"], "plan.create")
252253

253254
plan = Path(self.work.name) / "source-plan.md"
@@ -301,7 +302,7 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
301302
prescribed = self.helper_json(
302303
"next", "--repo", self.repo, *goal, *flow, *actor,
303304
)
304-
self.assertEqual(prescribed["decision"]["kind"], "PRESCRIBED")
305+
self.assertEqual(prescribed["decision"]["kind"], "CANDIDATE")
305306
self.assertEqual(
306307
prescribed["decision"]["transition"]["id"], "installation.initialize"
307308
)
@@ -321,6 +322,28 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
321322
}
322323
)
323324
)
325+
canonical_config = json.loads(config.read_text())
326+
canonical_config["hosts"] = sorted(canonical_config["hosts"])
327+
canonical_config["policy"]["external_effect_authority"] = (
328+
"human-or-autonomy-plus-provider"
329+
)
330+
config_fingerprint = hashlib.sha256(
331+
json.dumps(canonical_config, separators=(",", ":")).encode()
332+
).hexdigest()
333+
bound_initialization = self.helper_json(
334+
"next", "--repo", self.repo,
335+
"--transition", "installation.initialize", *goal, *flow, *actor,
336+
"--param", f"source_revision={self._git(self.repo, 'rev-parse', 'HEAD').stdout.strip()}",
337+
"--param", f"runtime_path={self.binary.resolve()}",
338+
"--param", f"runtime_sha256={hashlib.sha256(self.binary.read_bytes()).hexdigest()}",
339+
"--param", f"config_path={config}",
340+
"--param", f"config_sha256={config_fingerprint}",
341+
)
342+
self.assertEqual(bound_initialization["decision"]["kind"], "PRESCRIBED")
343+
self.assertEqual(
344+
bound_initialization["decision"]["transition"]["id"],
345+
"installation.initialize",
346+
)
324347
initialized_process = self.run_helper(
325348
"init", "--repo", self.repo, *goal, *flow, *actor,
326349
"--param", f"config_path={config}",
@@ -357,9 +380,20 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
357380
"next", "--repo", self.repo, *goal, *flow, *actor,
358381
"--repository-authority",
359382
)
360-
self.assertEqual(plan["decision"]["kind"], "PRESCRIBED")
383+
self.assertEqual(plan["decision"]["kind"], "CANDIDATE")
361384
self.assertEqual(plan["decision"]["transition"]["id"], "plan.create")
362385

386+
plan_source = Path(self.work.name) / "retained-authority-plan.md"
387+
plan_source.write_text("# Retained authority\n\nContinue in one operation context.\n")
388+
bound = self.helper_json(
389+
"next", "--repo", self.repo, "--transition", "plan.create",
390+
*goal, *flow, *actor, "--repository-authority",
391+
"--param", f"source_path={plan_source}",
392+
"--param", "delivery_id=preserve-repository-authority-context",
393+
)
394+
self.assertEqual(bound["decision"]["kind"], "PRESCRIBED")
395+
self.assertEqual(bound["decision"]["transition"]["id"], "plan.create")
396+
363397
def test_repository_authority_rematerialization_fails_closed_without_verified_config(self) -> None:
364398
# control-law: repository-authority-requires-exact-verified-fingerprint
365399
root = Path(self.work.name) / "unverified"

boatstack/core/transitions.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2672,6 +2672,7 @@
26722672
"class": "authority",
26732673
"source_phases": [
26742674
"OBSERVED",
2675+
"DORMANT",
26752676
"ACTIVE",
26762677
"FRONTIER",
26772678
"TERMINAL",

boatstack/flow/standard/supervisor_parity_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,60 @@ func TestUntargetedResolutionReconfiguresDifferentGoalAndSkipsSatisfiedGoal(t *t
140140
}
141141
}
142142

143+
func TestDormantBootstrapGoalReconfiguresBeforeEngagement(t *testing.T) {
144+
// control-law: a retained bootstrap goal cannot be bypassed by engagement
145+
snapshot := snapshotFor(t, model.PhaseDormant, model.TerminalNonterminal)
146+
requested := model.Goal{ID: "basic-project", Kind: model.GoalApprovedPlan, DeliveryID: "basic-project"}
147+
authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true}
148+
149+
untargeted := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, requested, authority, "")
150+
if untargeted.Kind != DecisionPrescribed || untargeted.Transition == nil || untargeted.Transition.ID != "goal.configure" {
151+
t.Fatalf("untargeted decision = %#v, want goal.configure", untargeted)
152+
}
153+
targeted := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, requested, authority, untargeted.Transition.ID)
154+
if targeted.Kind != DecisionPrescribed || targeted.Transition == nil || targeted.Transition.ID != untargeted.Transition.ID {
155+
t.Fatalf("targeted decision = %#v, want parity with %#v", targeted, untargeted)
156+
}
157+
engagement := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, requested, authority, "engagement.begin")
158+
if engagement.Kind != DecisionRefused {
159+
t.Fatalf("engagement decision = %#v, want refusal until goal.configure", engagement)
160+
}
161+
}
162+
163+
func TestDisabledHostIsRefusedBeforeUntargetedSelection(t *testing.T) {
164+
// control-law: host policy applies before both targeted and untargeted selection
165+
snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal)
166+
snapshot.Invocation.Host = "codex"
167+
snapshot = recanonicalize(t, snapshot)
168+
decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "")
169+
if decision.Kind != DecisionRefused || decision.Transition != nil {
170+
t.Fatalf("disabled-host decision = %#v, want REFUSED", decision)
171+
}
172+
}
173+
174+
func TestPublicationObservationRemainsSelectableForVolatileExternalState(t *testing.T) {
175+
// control-law: a nonterminal provider observation is evidence, not permanent progress
176+
snapshot, goal := openPRSnapshot(t, "build", "test", "review", "change", "journey")
177+
goal.Kind = model.GoalMerged
178+
snapshot.Goal = model.Known(goal, snapshot.Goal.Evidence[0])
179+
snapshot.Publication = model.Known(model.PublicationOpen, snapshot.Publication.Evidence[0])
180+
snapshot = recanonicalize(t, snapshot)
181+
var transitions []catalog.Transition
182+
for _, transition := range testprogram.StandardRegistry().All() {
183+
if transition.ID == "publication.observe" || transition.Class == catalog.EventRecovery {
184+
transitions = append(transitions, transition)
185+
}
186+
}
187+
registry, err := catalog.New(transitions)
188+
if err != nil {
189+
t.Fatal(err)
190+
}
191+
decision := New(registry, testGoalContracts()).Resolve(snapshot, goal, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "")
192+
if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "publication.observe" {
193+
t.Fatalf("volatile publication decision = %#v, want publication.observe", decision)
194+
}
195+
}
196+
143197
func TestUntargetedResolutionExcludesExplicitControlTransitions(t *testing.T) {
144198
// control-law: untargeted-resolution-cannot-invent-repair-or-slice-intent
145199
snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal)

boatstack/flow/standard/transitions.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5583,7 +5583,9 @@
55835583
"privacy_classification": "metadata-only",
55845584
"telemetry_classification": "transition-receipt",
55855585
"cost_class": "declared-neutral",
5586-
"policy": {},
5586+
"policy": {
5587+
"rechecks_external_state": true
5588+
},
55875589
"priority": 77
55885590
},
55895591
{

boatstack/internal/effects/host_skills.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ materialized authority receipts.
9292
9393
%s
9494
95-
Begin each cycle with an untargeted authority-bearing `+"`next`"+`. Apply only the
96-
stable transition ID from the immediately preceding prescription and only its
95+
Begin each cycle with an untargeted authority-bearing `+"`next`"+`. A `+"`CANDIDATE`"+`
96+
identifies the next transition but is not permission to apply it: bind only its
97+
declared parameters and re-resolve that exact transition. Apply only the stable
98+
transition ID from the immediately preceding `+"`PRESCRIBED`"+` result and only its
9799
declared parameters. Preserve the complete apply response and stderr, including
98100
admission, receipt, postcondition, error, recovery, and transaction fields.
99101
Re-resolve with the same context after every complete receipt.

boatstack/internal/effects/host_skills_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func TestHostSkillProjectionPreservesAuthorityBoundaries(t *testing.T) {
5050
for _, contract := range []string{
5151
"authority-free\n`FRONTIER`", "command-scoped context", "every `next`, `apply`, `recover`, and re-resolution",
5252
"requested authority sources separately from currently\nmaterialized authority receipts",
53-
"complete apply response and stderr", "authority-bearing `FRONTIER`", "Never synthesize missing\nauthority",
53+
"complete apply response and stderr", "authority-bearing `FRONTIER`", "Never synthesize missing\nauthority", "`CANDIDATE`", "immediately preceding `PRESCRIBED`",
5454
"every requested authority source is materialized\nor conclusively rejected against the post-receipt state",
5555
} {
5656
if !strings.Contains(value, contract) {

boatstack/internal/kernel/catalog/transition.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ type PolicyContract struct {
184184
ManagedOperations []string `json:"managed_operations,omitempty"`
185185
BindsRequestedGoal bool `json:"binds_requested_goal,omitempty"`
186186
ReconcilesProgram bool `json:"reconciles_program,omitempty"`
187+
RechecksExternalState bool `json:"rechecks_external_state,omitempty"`
187188
}
188189

189190
// FacetCondition is an executable, serializable predicate over one canonical

boatstack/internal/kernel/engine/engine.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type ResolveRequest struct {
4040
Invocation model.InvocationContext
4141
Goal model.Goal
4242
Authority protocol.AuthorityBundle
43+
Parameters protocol.Parameters
4344
Requested catalog.TransitionID
4445
}
4546

@@ -76,6 +77,30 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution
7677
return Resolution{}, err
7778
}
7879
decision := e.control.Resolve(snapshot, goal, request.Authority.Set(now), request.Requested)
80+
if decision.Kind == supervisor.DecisionPrescribed && decision.Transition != nil {
81+
if applicabilityErr := protocol.ValidateApplicability(snapshot, goal, *decision.Transition, request.Authority, request.Parameters, now); applicabilityErr != nil {
82+
if protocol.IsMissingParameter(applicabilityErr) {
83+
decision.Kind = supervisor.DecisionCandidate
84+
decision.Reason = applicabilityErr.Error() + "; bind the declared parameters and re-resolve this transition"
85+
decision.Candidates = []catalog.TransitionID{decision.Transition.ID}
86+
} else {
87+
decision.Kind = supervisor.DecisionRefused
88+
decision.Reason = applicabilityErr.Error()
89+
decision.Transition = nil
90+
}
91+
} else {
92+
admission, admissionErr := protocol.NewAdmission(snapshot, goal, *decision.Transition, request.Authority, request.Parameters, now, 2*time.Minute)
93+
if admissionErr != nil {
94+
decision.Kind = supervisor.DecisionUnresolved
95+
decision.Reason = admissionErr.Error()
96+
decision.Transition = nil
97+
} else if _, preflightErr := e.effects.Prepare(ctx, admission, *decision.Transition); preflightErr != nil {
98+
decision.Kind = supervisor.DecisionUnresolved
99+
decision.Reason = fmt.Sprintf("transition %q failed deterministic effect preflight: %v", admission.TransitionID, preflightErr)
100+
decision.Transition = nil
101+
}
102+
}
103+
}
79104
return Resolution{Snapshot: snapshot, Goal: goal, Decision: decision}, nil
80105
}
81106

@@ -170,6 +195,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe
170195
if request.AdmissionLifetime <= 0 {
171196
request.AdmissionLifetime = 2 * time.Minute
172197
}
198+
request.ResolveRequest.Parameters = request.Parameters
173199
resolution, err := e.Resolve(ctx, request.ResolveRequest)
174200
result.Source, result.Goal, result.Decision = resolution.Snapshot, resolution.Goal, resolution.Decision
175201
if err != nil {

boatstack/internal/kernel/engine/engine_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,13 @@ type fakeEffects struct {
105105
executions, rollbacks int
106106
result ports.EffectResult
107107
err error
108+
prepareErr error
108109
}
109110

110111
func (e *fakeEffects) Prepare(context.Context, protocol.Admission, catalog.Transition) (ports.PreparedEffect, error) {
112+
if e.prepareErr != nil {
113+
return nil, e.prepareErr
114+
}
111115
return e, nil
112116
}
113117
func (e *fakeEffects) Manifest() []ports.ResourceMutation { return nil }
@@ -274,6 +278,66 @@ func TestRequiredObserverFailureReturnsTypedUnresolvedDecision(t *testing.T) {
274278
}
275279
}
276280

281+
func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T) {
282+
// control-law: a selected transition is only a candidate until deterministic admission inputs are complete
283+
now := time.Unix(30, 0).UTC()
284+
transitions := testRegistry(t).All()
285+
for index := range transitions {
286+
if transitions[index].ID == "test.advance" {
287+
transitions[index].Parameters = []catalog.ParameterSpec{{Name: "value", Required: true}}
288+
}
289+
}
290+
registry, err := catalog.New(transitions)
291+
if err != nil {
292+
t.Fatal(err)
293+
}
294+
observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}}
295+
kernel, err := New(registry, syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{})
296+
if err != nil {
297+
t.Fatal(err)
298+
}
299+
req := request(now).ResolveRequest
300+
req.Requested = ""
301+
candidate, err := kernel.Resolve(context.Background(), req)
302+
if err != nil {
303+
t.Fatal(err)
304+
}
305+
if candidate.Decision.Kind != supervisor.DecisionCandidate || candidate.Decision.Transition == nil || candidate.Decision.Transition.ID != "test.advance" {
306+
t.Fatalf("incomplete resolution = %+v, want CANDIDATE", candidate.Decision)
307+
}
308+
req.Requested = "test.advance"
309+
req.Parameters = protocol.Parameters{{Name: "value", Value: "bound"}}
310+
prescribed, err := kernel.Resolve(context.Background(), req)
311+
if err != nil {
312+
t.Fatal(err)
313+
}
314+
if prescribed.Decision.Kind != supervisor.DecisionPrescribed || prescribed.Decision.Transition == nil || prescribed.Decision.Transition.ID != "test.advance" {
315+
t.Fatalf("complete resolution = %+v, want PRESCRIBED", prescribed.Decision)
316+
}
317+
}
318+
319+
func TestResolutionDoesNotPrescribeAnEffectThatDeterministicPreflightRejects(t *testing.T) {
320+
// control-law: effect preparation cannot introduce a deterministic apply-only refusal
321+
now := time.Unix(30, 0).UTC()
322+
effects := &fakeEffects{prepareErr: errors.New("malformed artifact")}
323+
observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source")}}
324+
journal := &fakeJournal{}
325+
kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, &memoryReceipts{})
326+
if err != nil {
327+
t.Fatal(err)
328+
}
329+
resolved, err := kernel.Resolve(context.Background(), request(now).ResolveRequest)
330+
if err != nil {
331+
t.Fatal(err)
332+
}
333+
if resolved.Decision.Kind != supervisor.DecisionUnresolved || resolved.Decision.Transition != nil || !strings.Contains(resolved.Decision.Reason, "malformed artifact") {
334+
t.Fatalf("preflight decision = %+v, want typed UNRESOLVED without prescription", resolved.Decision)
335+
}
336+
if effects.executions != 0 || journal.begun != 0 {
337+
t.Fatalf("preflight crossed mutation boundary: effects=%d journals=%d", effects.executions, journal.begun)
338+
}
339+
}
340+
277341
func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) {
278342
// control-law: synthetic-flow-crosses-exact-admission-and-postcondition-without-standard-flow
279343
now := time.Unix(30, 0).UTC()

boatstack/internal/kernel/ports/ports.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ type PreparedEffect interface {
9292
}
9393

9494
type EffectDriver interface {
95+
// Prepare is a side-effect-free preflight. It may read exact plant state and
96+
// construct a mutation manifest, but it must not execute or install it.
9597
Prepare(context.Context, protocol.Admission, catalog.Transition) (PreparedEffect, error)
9698
}
9799

0 commit comments

Comments
 (0)