Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions .github/tests/test_detached_supervision.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import hashlib
import json
import os
import subprocess
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
)
Expand All @@ -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}",
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions boatstack/core/transitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2672,6 +2672,7 @@
"class": "authority",
"source_phases": [
"OBSERVED",
"DORMANT",
"ACTIVE",
"FRONTIER",
"TERMINAL",
Expand Down
54 changes: 54 additions & 0 deletions boatstack/flow/standard/supervisor_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion boatstack/flow/standard/transitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -5583,7 +5583,9 @@
"privacy_classification": "metadata-only",
"telemetry_classification": "transition-receipt",
"cost_class": "declared-neutral",
"policy": {},
"policy": {
"rechecks_external_state": true
},
"priority": 77
},
{
Expand Down
6 changes: 4 additions & 2 deletions boatstack/internal/effects/host_skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion boatstack/internal/effects/host_skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions boatstack/internal/kernel/catalog/transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions boatstack/internal/kernel/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type ResolveRequest struct {
Invocation model.InvocationContext
Goal model.Goal
Authority protocol.AuthorityBundle
Parameters protocol.Parameters
Requested catalog.TransitionID
}

Expand Down Expand Up @@ -76,6 +77,30 @@ 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
}
} 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
}

Expand Down Expand Up @@ -170,6 +195,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 {
Expand Down
64 changes: 64 additions & 0 deletions boatstack/internal/kernel/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -274,6 +278,66 @@ 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 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()
Expand Down
2 changes: 2 additions & 0 deletions boatstack/internal/kernel/ports/ports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading