Skip to content

Commit 5adada0

Browse files
authored
Bind prescriptions to state and program revisions (#196)
1 parent 02cf817 commit 5adada0

46 files changed

Lines changed: 1263 additions & 273 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/tests/test_detached_supervision.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ def run_helper(
9090
def helper_json(self, *args: object, cwd: Path | None = None) -> dict:
9191
return json.loads(self.run_helper(*args, cwd=cwd).stdout)
9292

93+
def apply_prescribed(
94+
self, transition: str, *args: object, cwd: Path | None = None
95+
) -> dict:
96+
resolved = self.helper_json(
97+
"next", "--transition", transition, *args, cwd=cwd
98+
)
99+
prescription = resolved["prescription"]
100+
correlation = resolved["snapshot"]["invocation"]["correlation_id"]
101+
return self.helper_json(
102+
"apply", "--transition", transition, *args,
103+
"--correlation", correlation,
104+
"--prescription-id", prescription["id"],
105+
"--expected-state-revision", prescription["expected_state_revision"],
106+
"--expected-program-fingerprint", prescription["expected_program_fingerprint"],
107+
"--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"],
108+
cwd=cwd,
109+
)
110+
93111
def porcelain(self, repository: Path | None = None) -> str:
94112
repository = repository or self.repo
95113
return subprocess.run(
@@ -198,13 +216,13 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No
198216
),
199217
)
200218

201-
self.helper_json(
202-
"apply", "--repo", self.repo, "--transition", "goal.configure",
219+
self.apply_prescribed(
220+
"goal.configure", "--repo", self.repo,
203221
*self.goal_flags(), "--human", "contract",
204222
"--param", "goal_kind=approved-plan", "--param", "delivery_id=bootstrap",
205223
)
206-
self.helper_json(
207-
"apply", "--repo", self.repo, "--transition", "engagement.begin",
224+
self.apply_prescribed(
225+
"engagement.begin", "--repo", self.repo,
208226
*self.goal_flags(), "--repository-authority",
209227
)
210228
ordinary = self.helper_json(
@@ -249,14 +267,14 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
249267
"init", "--repo", self.repo, *goal, *flow, "--human", "contract",
250268
"--param", f"config_path={config}",
251269
)
252-
self.helper_json(
253-
"apply", "--repo", self.repo, "--transition", "goal.configure",
270+
self.apply_prescribed(
271+
"goal.configure", "--repo", self.repo,
254272
*goal, *flow, "--human", "contract",
255273
"--param", "goal_kind=open-or-updated-pr",
256274
"--param", "delivery_id=codex-driver-authority-triggers",
257275
)
258-
self.helper_json(
259-
"apply", "--repo", self.repo, "--transition", "engagement.begin",
276+
self.apply_prescribed(
277+
"engagement.begin", "--repo", self.repo,
260278
*goal, *flow, "--repository-authority",
261279
)
262280

@@ -377,8 +395,8 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
377395
for field in ('"admission"', '"receipt"', '"snapshot"', '"target_fingerprint"', '"recovery"'):
378396
self.assertIn(field, initialized_process.stdout)
379397

380-
configured = self.helper_json(
381-
"apply", "--repo", self.repo, "--transition", "goal.configure",
398+
configured = self.apply_prescribed(
399+
"goal.configure", "--repo", self.repo,
382400
*goal, *flow, *actor,
383401
"--param", "goal_kind=open-or-updated-pr",
384402
"--param", "delivery_id=preserve-repository-authority-context",
@@ -392,19 +410,19 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
392410
self.assertEqual(engagement["decision"]["kind"], "PRESCRIBED")
393411
self.assertEqual(engagement["decision"]["transition"]["id"], "engagement.begin")
394412

395-
engaged_process = self.run_helper(
396-
"apply", "--repo", self.repo, "--transition", "engagement.begin",
413+
engaged = self.apply_prescribed(
414+
"engagement.begin", "--repo", self.repo,
397415
*goal, *flow, *actor, "--repository-authority",
398416
)
399-
engaged = json.loads(engaged_process.stdout)
400417
self.assertEqual(engaged["receipt"]["transition_id"], "engagement.begin")
401418
self.assertEqual(engaged["receipt"]["flow_id"], flow[1])
402419
self.assertEqual(
403420
{receipt["class"] for receipt in engaged["admission"]["authority"]["receipts"]},
404421
{"human", "repository-policy"},
405422
)
423+
engaged_output = json.dumps(engaged)
406424
for field in ('"admission"', '"receipt"', '"snapshot"', '"target_fingerprint"', '"recovery"'):
407-
self.assertIn(field, engaged_process.stdout)
425+
self.assertIn(field, engaged_output)
408426

409427
plan = self.helper_json(
410428
"next", "--repo", self.repo, *goal, *flow, *actor,

.github/tests/test_repository_contract.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,33 @@ def run_helper(
7979
self.helper, *args, env=env, stdin=stdin, expected=expected
8080
)
8181

82+
def apply_prescribed(
83+
self,
84+
binary: Path,
85+
transition: str,
86+
*args: object,
87+
cwd: Path | None = None,
88+
env: dict[str, str] | None = None,
89+
) -> dict:
90+
resolved = json.loads(
91+
self.run_command(
92+
binary, "next", "--transition", transition, *args,
93+
cwd=cwd, env=env,
94+
).stdout
95+
)
96+
prescription = resolved["prescription"]
97+
correlation = resolved["snapshot"]["invocation"]["correlation_id"]
98+
applied = self.run_command(
99+
binary, "apply", "--transition", transition, *args,
100+
"--correlation", correlation,
101+
"--prescription-id", prescription["id"],
102+
"--expected-state-revision", prescription["expected_state_revision"],
103+
"--expected-program-fingerprint", prescription["expected_program_fingerprint"],
104+
"--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"],
105+
cwd=cwd, env=env,
106+
)
107+
return json.loads(applied.stdout)
108+
82109
def init_repository(self, root: Path) -> None:
83110
self.run_command("git", "init", "-b", "main", cwd=root)
84111
self.run_command("git", "config", "user.name", "Boatstack Test", cwd=root)
@@ -494,15 +521,13 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
494521
"--goal-id", "bootstrap", "--goal-kind", "approved-plan",
495522
"--delivery", "bootstrap",
496523
)
497-
self.run_command(
498-
launcher, "apply", "--repo", repository,
499-
"--transition", "goal.configure", *goal,
524+
self.apply_prescribed(
525+
launcher, "goal.configure", "--repo", repository, *goal,
500526
"--human", "contract", "--param", "goal_kind=approved-plan",
501527
"--param", "delivery_id=bootstrap", env=env,
502528
)
503-
self.run_command(
504-
launcher, "apply", "--repo", repository,
505-
"--transition", "engagement.begin", *goal,
529+
self.apply_prescribed(
530+
launcher, "engagement.begin", "--repo", repository, *goal,
506531
"--repository-authority", env=env,
507532
)
508533
ordinary = json.loads(
@@ -594,7 +619,7 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No
594619
prior_program = candidate_status["snapshot"]["recorded_program_fingerprint"]
595620
split_reconciliation = self.run_command(
596621
self.helper,
597-
"apply",
622+
"next",
598623
"--repo",
599624
repository,
600625
"--transition",
@@ -612,7 +637,6 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No
612637
"--param",
613638
"accept_obligation_change=true",
614639
env=env,
615-
expected=1,
616640
)
617641
self.assertIn(
618642
"catalog reconciliation cannot activate a different runtime",

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ the complete contract and the historical failure synthesis.
3636
The [Control Program ABI](docs/architecture/control-program-abi.md) defines the
3737
strict repository source, canonical fingerprint, compatibility gate, and
3838
program-qualified transition identity used by complete user-facing Flows.
39+
The [prescription transaction boundary](docs/architecture/prescription-transactions.md)
40+
defines the exact durable-state and executable-program compare-and-swap contract
41+
between resolution and effects.
3942

4043
## Install
4144

@@ -72,12 +75,17 @@ See [Getting started](docs/getting-started.md) and
7275
```sh
7376
boatstack status --repo . --format json
7477
boatstack catalog --format json
75-
boatstack apply --repo . --transition <stable-id> --format json
78+
boatstack next --repo . --goal-id <goal> --goal-kind <kind> --delivery <delivery> --format json
79+
boatstack apply --repo . --transition <stable-id> --flow <flow> \
80+
--prescription-id <id> --expected-state-revision <revision> \
81+
--expected-program-fingerprint <sha256> \
82+
--expected-snapshot-fingerprint <sha256> --format json
7683
```
7784

7885
- `status`, `next`, `doctor`, `catalog`, and `events` are read-only.
79-
- `apply` and `recover` request stable transition IDs from the 63-event
80-
executable catalog.
86+
- `apply` and `recover` consume a stable transition ID plus the exact
87+
prescription returned by `next`; stale state or program identity causes zero
88+
effects and requires re-resolution.
8189
- Friendly aliases such as `plan-create`, `plan-approve`,
8290
`workspace-cut`, `record-test`, and `publish-pr` map to those IDs.
8391
- `guard` is the shared safety-hook query. It blocks high-confidence

boatstack/cmd/boatstack-helper/main.go

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
2424
"github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
2525
"github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
26-
"github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor"
2726
boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime"
2827
"github.com/operatorstack/boatstack/boatstack/internal/surfaces"
2928
)
@@ -37,22 +36,27 @@ func (s *stringList) Set(value string) error {
3736
}
3837

3938
type commandOptions struct {
40-
repository string
41-
format string
42-
goalID string
43-
goalKind string
44-
deliveryID string
45-
flowID string
46-
transitionID string
47-
idempotencyKey string
48-
humanActor string
49-
repositoryPolicy bool
50-
acceptProgramChange bool
51-
parameters stringList
52-
authorityReceipts stringList
53-
follow bool
54-
host string
55-
command string
39+
repository string
40+
format string
41+
goalID string
42+
goalKind string
43+
deliveryID string
44+
flowID string
45+
transitionID string
46+
correlationID string
47+
prescriptionID string
48+
expectedStateRevision uint64
49+
expectedProgramFingerprint string
50+
expectedSnapshotFingerprint string
51+
idempotencyKey string
52+
humanActor string
53+
repositoryPolicy bool
54+
acceptProgramChange bool
55+
parameters stringList
56+
authorityReceipts stringList
57+
follow bool
58+
host string
59+
command string
5660
}
5761

5862
func main() {
@@ -101,13 +105,27 @@ func run(arguments []string) error {
101105
if err != nil {
102106
return err
103107
}
104-
response, handleErr := kernel.Handle(context.Background(), request)
105-
if command == "update" && options.acceptProgramChange && handleErr != nil && response.ProgramChange != nil && response.Decision != nil &&
106-
response.Decision.Kind == supervisor.DecisionUnresolved && response.Decision.Reason == supervisor.ReasonProgramDrift {
107-
request.TransitionID = "installation.reconcile-update"
108-
request.Parameters = append(request.Parameters, protocol.Parameter{Name: "accept_obligation_change", Value: "true"}).Canonical()
109-
response, handleErr = kernel.Handle(context.Background(), request)
108+
if (operation == surfaces.OperationApply || operation == surfaces.OperationRecover) && request.Prescription.ID == "" && command != "apply" && command != "recover" {
109+
resolveRequest := request
110+
resolveRequest.Operation = surfaces.OperationResolve
111+
resolveRequest.FlowID = ""
112+
resolveRequest.Prescription = protocol.Prescription{}
113+
resolved, resolveErr := kernel.Handle(context.Background(), resolveRequest)
114+
if resolveErr != nil || resolved.Prescription == nil {
115+
if renderErr := renderResponse(resolved, options.format); renderErr != nil {
116+
return renderErr
117+
}
118+
if resolveErr == nil {
119+
if resolved.Decision != nil && resolved.Decision.Reason != "" {
120+
return errors.New(resolved.Decision.Reason)
121+
}
122+
return fmt.Errorf("transition %q was not prescribed", request.TransitionID)
123+
}
124+
return resolveErr
125+
}
126+
request.Prescription = *resolved.Prescription
110127
}
128+
response, handleErr := kernel.Handle(context.Background(), request)
111129
if command == "events" && options.follow {
112130
if options.format != "jsonl" {
113131
return fmt.Errorf("events --follow requires --format jsonl")
@@ -229,6 +247,11 @@ func parseOptions(command string, arguments []string, transition catalog.Transit
229247
flags.StringVar(&options.deliveryID, "delivery", options.deliveryID, "delivery identity")
230248
flags.StringVar(&options.flowID, "flow", "", "flow identity")
231249
flags.StringVar(&options.transitionID, "transition", options.transitionID, "stable semantic transition id")
250+
flags.StringVar(&options.correlationID, "correlation", "", "command-scoped correlation identity from resolution")
251+
flags.StringVar(&options.prescriptionID, "prescription-id", "", "exact prescription identity from resolution")
252+
flags.Uint64Var(&options.expectedStateRevision, "expected-state-revision", 0, "exact durable state revision observed during resolution")
253+
flags.StringVar(&options.expectedProgramFingerprint, "expected-program-fingerprint", "", "exact executable control-program fingerprint observed during resolution")
254+
flags.StringVar(&options.expectedSnapshotFingerprint, "expected-snapshot-fingerprint", "", "exact admission-relevant snapshot fingerprint observed during resolution")
232255
flags.StringVar(&options.idempotencyKey, "idempotency-key", "", "exact prior admission idempotency key for safe replay")
233256
flags.StringVar(&options.humanActor, "human", "", "explicit command-scoped human authority actor")
234257
flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from the V2 project configuration")
@@ -253,10 +276,11 @@ func parseOptions(command string, arguments []string, transition catalog.Transit
253276
if err := populateRuntimeParameters(&options); err != nil {
254277
return commandOptions{}, err
255278
}
256-
if command == "reconcile-update" {
279+
if command == "reconcile-update" || (command == "update" && options.acceptProgramChange) {
257280
if !options.acceptProgramChange {
258281
return commandOptions{}, fmt.Errorf("reconcile-update requires explicit --accept-program-change")
259282
}
283+
options.transitionID = "installation.reconcile-update"
260284
options.parameters = append(options.parameters, "accept_obligation_change=true")
261285
}
262286
case "correct-pr":
@@ -405,7 +429,10 @@ func buildRevision() string {
405429

406430
func buildRequest(operation surfaces.Operation, options commandOptions) (surfaces.Request, error) {
407431
now := time.Now().UTC()
408-
correlation := fmt.Sprintf("cli-%d-%d", os.Getpid(), now.UnixNano())
432+
correlation := options.correlationID
433+
if correlation == "" {
434+
correlation = fmt.Sprintf("cli-%d-%d", os.Getpid(), now.UnixNano())
435+
}
409436
goal := model.Goal{}
410437
if options.goalKind != "" || options.goalID != "" || options.deliveryID != "" {
411438
goal = model.Goal{ID: options.goalID, Kind: model.GoalKind(options.goalKind), DeliveryID: options.deliveryID}
@@ -431,6 +458,9 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface
431458
return surfaces.Request{
432459
SchemaVersion: surfaces.SchemaVersion, Operation: operation, Repository: options.repository, Host: options.host, CorrelationID: correlation,
433460
FlowID: flowID, Goal: goal, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters,
461+
Prescription: protocol.Prescription{SchemaVersion: protocol.PrescriptionSchemaVersion, ID: options.prescriptionID,
462+
TransitionID: catalog.TransitionID(options.transitionID), ExpectedStateRevision: options.expectedStateRevision,
463+
ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint},
434464
RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command,
435465
}, nil
436466
}
@@ -568,6 +598,15 @@ func renderResponse(response surfaces.Response, format string) error {
568598
fmt.Println("transition:", response.Decision.Transition.ID)
569599
}
570600
}
601+
if response.Prescription != nil {
602+
correlation := ""
603+
if response.Snapshot != nil {
604+
correlation = response.Snapshot.Invocation.Correlation
605+
}
606+
fmt.Printf("prescription=%s state_revision=%d program=%s snapshot=%s correlation=%s\n", response.Prescription.ID,
607+
response.Prescription.ExpectedStateRevision, response.Prescription.ExpectedProgramFingerprint,
608+
response.Prescription.ExpectedSnapshotFingerprint, correlation)
609+
}
571610
if response.Receipt != nil {
572611
fmt.Println("receipt:", response.Receipt.ID)
573612
}

boatstack/flow/standard/historical_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func snapshotFromFixture(t *testing.T, fixture historicalFixture) model.Snapshot
7979
InvokingPath: filepath.Join(t.TempDir(), "fixture", "repository"), Topology: model.Topology(facts["topology"]), Host: "corpus", Correlation: "correlation-" + fixture.Name,
8080
}
8181
observation := model.Observation{
82-
SchemaVersion: model.SnapshotSchemaVersion, Invocation: invocation,
82+
SchemaVersion: model.SnapshotSchemaVersion, StateRevision: 1, Invocation: invocation,
8383
Phase: model.Known(model.ProtocolPhase(facts["phase"]), evidence), Engagement: model.Known(model.EngagementState(facts["engagement"]), evidence),
8484
Delivery: model.Known(model.DeliveryState(facts["delivery"]), evidence), Workspace: model.Known(model.WorkspaceState(facts["workspace"]), evidence),
8585
Plan: model.Known(model.PlanState(facts["plan"]), evidence), Configuration: model.Known(model.ConfigurationState(facts["configuration"]), evidence),

boatstack/flow/standard/supervisor_parity_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ func snapshotFor(t *testing.T, phase model.ProtocolPhase, terminal model.Termina
2929
t.Helper()
3030
e := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(10, 0).UTC()}
3131
o := model.Observation{
32-
SchemaVersion: model.SnapshotSchemaVersion,
33-
Invocation: model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/f", ControllerID: "ctl", InvokingPath: filepath.Join(t.TempDir(), "repo"), RuntimeVersion: "runtime-version", RuntimePath: filepath.Join(t.TempDir(), "runtime"), RuntimeFingerprint: "runtime", Topology: model.TopologyEmbedded, Host: "cli", Correlation: "c"},
34-
Phase: model.Known(phase, e), Engagement: model.Known(model.EngagementActive, e), Delivery: model.Known(model.DeliveryActive, e),
32+
SchemaVersion: model.SnapshotSchemaVersion, StateRevision: 1,
33+
Invocation: model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/f", ControllerID: "ctl", InvokingPath: filepath.Join(t.TempDir(), "repo"), RuntimeVersion: "runtime-version", RuntimePath: filepath.Join(t.TempDir(), "runtime"), RuntimeFingerprint: "runtime", Topology: model.TopologyEmbedded, Host: "cli", Correlation: "c"},
34+
Phase: model.Known(phase, e), Engagement: model.Known(model.EngagementActive, e), Delivery: model.Known(model.DeliveryActive, e),
3535
Workspace: model.Known(model.WorkspaceActive, e), Plan: model.Known(model.PlanValid, e),
3636
Configuration: model.Known(model.ConfigurationVerified, e), Runtime: model.Known(model.RuntimeVerified, e),
3737
ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}}, e),

0 commit comments

Comments
 (0)