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
46 changes: 32 additions & 14 deletions .github/tests/test_detached_supervision.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ def run_helper(
def helper_json(self, *args: object, cwd: Path | None = None) -> dict:
return json.loads(self.run_helper(*args, cwd=cwd).stdout)

def apply_prescribed(
self, transition: str, *args: object, cwd: Path | None = None
) -> dict:
resolved = self.helper_json(
"next", "--transition", transition, *args, cwd=cwd
)
prescription = resolved["prescription"]
correlation = resolved["snapshot"]["invocation"]["correlation_id"]
return self.helper_json(
"apply", "--transition", transition, *args,
"--correlation", correlation,
"--prescription-id", prescription["id"],
"--expected-state-revision", prescription["expected_state_revision"],
"--expected-program-fingerprint", prescription["expected_program_fingerprint"],
"--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"],
cwd=cwd,
)

def porcelain(self, repository: Path | None = None) -> str:
repository = repository or self.repo
return subprocess.run(
Expand Down Expand Up @@ -198,13 +216,13 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No
),
)

self.helper_json(
"apply", "--repo", self.repo, "--transition", "goal.configure",
self.apply_prescribed(
"goal.configure", "--repo", self.repo,
*self.goal_flags(), "--human", "contract",
"--param", "goal_kind=approved-plan", "--param", "delivery_id=bootstrap",
)
self.helper_json(
"apply", "--repo", self.repo, "--transition", "engagement.begin",
self.apply_prescribed(
"engagement.begin", "--repo", self.repo,
*self.goal_flags(), "--repository-authority",
)
ordinary = self.helper_json(
Expand Down Expand Up @@ -249,14 +267,14 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
"init", "--repo", self.repo, *goal, *flow, "--human", "contract",
"--param", f"config_path={config}",
)
self.helper_json(
"apply", "--repo", self.repo, "--transition", "goal.configure",
self.apply_prescribed(
"goal.configure", "--repo", self.repo,
*goal, *flow, "--human", "contract",
"--param", "goal_kind=open-or-updated-pr",
"--param", "delivery_id=codex-driver-authority-triggers",
)
self.helper_json(
"apply", "--repo", self.repo, "--transition", "engagement.begin",
self.apply_prescribed(
"engagement.begin", "--repo", self.repo,
*goal, *flow, "--repository-authority",
)

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

configured = self.helper_json(
"apply", "--repo", self.repo, "--transition", "goal.configure",
configured = self.apply_prescribed(
"goal.configure", "--repo", self.repo,
*goal, *flow, *actor,
"--param", "goal_kind=open-or-updated-pr",
"--param", "delivery_id=preserve-repository-authority-context",
Expand All @@ -392,19 +410,19 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali
self.assertEqual(engagement["decision"]["kind"], "PRESCRIBED")
self.assertEqual(engagement["decision"]["transition"]["id"], "engagement.begin")

engaged_process = self.run_helper(
"apply", "--repo", self.repo, "--transition", "engagement.begin",
engaged = self.apply_prescribed(
"engagement.begin", "--repo", self.repo,
*goal, *flow, *actor, "--repository-authority",
)
engaged = json.loads(engaged_process.stdout)
self.assertEqual(engaged["receipt"]["transition_id"], "engagement.begin")
self.assertEqual(engaged["receipt"]["flow_id"], flow[1])
self.assertEqual(
{receipt["class"] for receipt in engaged["admission"]["authority"]["receipts"]},
{"human", "repository-policy"},
)
engaged_output = json.dumps(engaged)
for field in ('"admission"', '"receipt"', '"snapshot"', '"target_fingerprint"', '"recovery"'):
self.assertIn(field, engaged_process.stdout)
self.assertIn(field, engaged_output)

plan = self.helper_json(
"next", "--repo", self.repo, *goal, *flow, *actor,
Expand Down
40 changes: 32 additions & 8 deletions .github/tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ def run_helper(
self.helper, *args, env=env, stdin=stdin, expected=expected
)

def apply_prescribed(
self,
binary: Path,
transition: str,
*args: object,
cwd: Path | None = None,
env: dict[str, str] | None = None,
) -> dict:
resolved = json.loads(
self.run_command(
binary, "next", "--transition", transition, *args,
cwd=cwd, env=env,
).stdout
)
prescription = resolved["prescription"]
correlation = resolved["snapshot"]["invocation"]["correlation_id"]
applied = self.run_command(
binary, "apply", "--transition", transition, *args,
"--correlation", correlation,
"--prescription-id", prescription["id"],
"--expected-state-revision", prescription["expected_state_revision"],
"--expected-program-fingerprint", prescription["expected_program_fingerprint"],
"--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"],
cwd=cwd, env=env,
)
return json.loads(applied.stdout)

def init_repository(self, root: Path) -> None:
self.run_command("git", "init", "-b", "main", cwd=root)
self.run_command("git", "config", "user.name", "Boatstack Test", cwd=root)
Expand Down Expand Up @@ -494,15 +521,13 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
"--goal-id", "bootstrap", "--goal-kind", "approved-plan",
"--delivery", "bootstrap",
)
self.run_command(
launcher, "apply", "--repo", repository,
"--transition", "goal.configure", *goal,
self.apply_prescribed(
launcher, "goal.configure", "--repo", repository, *goal,
"--human", "contract", "--param", "goal_kind=approved-plan",
"--param", "delivery_id=bootstrap", env=env,
)
self.run_command(
launcher, "apply", "--repo", repository,
"--transition", "engagement.begin", *goal,
self.apply_prescribed(
launcher, "engagement.begin", "--repo", repository, *goal,
"--repository-authority", env=env,
)
ordinary = json.loads(
Expand Down Expand Up @@ -594,7 +619,7 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No
prior_program = candidate_status["snapshot"]["recorded_program_fingerprint"]
split_reconciliation = self.run_command(
self.helper,
"apply",
"next",
"--repo",
repository,
"--transition",
Expand All @@ -612,7 +637,6 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No
"--param",
"accept_obligation_change=true",
env=env,
expected=1,
)
self.assertIn(
"catalog reconciliation cannot activate a different runtime",
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ the complete contract and the historical failure synthesis.
The [Control Program ABI](docs/architecture/control-program-abi.md) defines the
strict repository source, canonical fingerprint, compatibility gate, and
program-qualified transition identity used by complete user-facing Flows.
The [prescription transaction boundary](docs/architecture/prescription-transactions.md)
defines the exact durable-state and executable-program compare-and-swap contract
between resolution and effects.

## Install

Expand Down Expand Up @@ -72,12 +75,17 @@ See [Getting started](docs/getting-started.md) and
```sh
boatstack status --repo . --format json
boatstack catalog --format json
boatstack apply --repo . --transition <stable-id> --format json
boatstack next --repo . --goal-id <goal> --goal-kind <kind> --delivery <delivery> --format json
boatstack apply --repo . --transition <stable-id> --flow <flow> \
--prescription-id <id> --expected-state-revision <revision> \
--expected-program-fingerprint <sha256> \
--expected-snapshot-fingerprint <sha256> --format json
```

- `status`, `next`, `doctor`, `catalog`, and `events` are read-only.
- `apply` and `recover` request stable transition IDs from the 63-event
executable catalog.
- `apply` and `recover` consume a stable transition ID plus the exact
prescription returned by `next`; stale state or program identity causes zero
effects and requires re-resolution.
- Friendly aliases such as `plan-create`, `plan-approve`,
`workspace-cut`, `record-test`, and `publish-pr` map to those IDs.
- `guard` is the shared safety-hook query. It blocks high-confidence
Expand Down
89 changes: 64 additions & 25 deletions boatstack/cmd/boatstack-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
"github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
"github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
"github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor"
boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime"
"github.com/operatorstack/boatstack/boatstack/internal/surfaces"
)
Expand All @@ -37,22 +36,27 @@ func (s *stringList) Set(value string) error {
}

type commandOptions struct {
repository string
format string
goalID string
goalKind string
deliveryID string
flowID string
transitionID string
idempotencyKey string
humanActor string
repositoryPolicy bool
acceptProgramChange bool
parameters stringList
authorityReceipts stringList
follow bool
host string
command string
repository string
format string
goalID string
goalKind string
deliveryID string
flowID string
transitionID string
correlationID string
prescriptionID string
expectedStateRevision uint64
expectedProgramFingerprint string
expectedSnapshotFingerprint string
idempotencyKey string
humanActor string
repositoryPolicy bool
acceptProgramChange bool
parameters stringList
authorityReceipts stringList
follow bool
host string
command string
}

func main() {
Expand Down Expand Up @@ -101,13 +105,27 @@ func run(arguments []string) error {
if err != nil {
return err
}
response, handleErr := kernel.Handle(context.Background(), request)
if command == "update" && options.acceptProgramChange && handleErr != nil && response.ProgramChange != nil && response.Decision != nil &&
response.Decision.Kind == supervisor.DecisionUnresolved && response.Decision.Reason == supervisor.ReasonProgramDrift {
request.TransitionID = "installation.reconcile-update"
request.Parameters = append(request.Parameters, protocol.Parameter{Name: "accept_obligation_change", Value: "true"}).Canonical()
response, handleErr = kernel.Handle(context.Background(), request)
if (operation == surfaces.OperationApply || operation == surfaces.OperationRecover) && request.Prescription.ID == "" && command != "apply" && command != "recover" {
resolveRequest := request
resolveRequest.Operation = surfaces.OperationResolve
resolveRequest.FlowID = ""
resolveRequest.Prescription = protocol.Prescription{}
resolved, resolveErr := kernel.Handle(context.Background(), resolveRequest)
if resolveErr != nil || resolved.Prescription == nil {
if renderErr := renderResponse(resolved, options.format); renderErr != nil {
return renderErr
}
if resolveErr == nil {
if resolved.Decision != nil && resolved.Decision.Reason != "" {
return errors.New(resolved.Decision.Reason)
}
return fmt.Errorf("transition %q was not prescribed", request.TransitionID)
}
return resolveErr
}
request.Prescription = *resolved.Prescription
}
response, handleErr := kernel.Handle(context.Background(), request)
if command == "events" && options.follow {
if options.format != "jsonl" {
return fmt.Errorf("events --follow requires --format jsonl")
Expand Down Expand Up @@ -229,6 +247,11 @@ func parseOptions(command string, arguments []string, transition catalog.Transit
flags.StringVar(&options.deliveryID, "delivery", options.deliveryID, "delivery identity")
flags.StringVar(&options.flowID, "flow", "", "flow identity")
flags.StringVar(&options.transitionID, "transition", options.transitionID, "stable semantic transition id")
flags.StringVar(&options.correlationID, "correlation", "", "command-scoped correlation identity from resolution")
flags.StringVar(&options.prescriptionID, "prescription-id", "", "exact prescription identity from resolution")
flags.Uint64Var(&options.expectedStateRevision, "expected-state-revision", 0, "exact durable state revision observed during resolution")
flags.StringVar(&options.expectedProgramFingerprint, "expected-program-fingerprint", "", "exact executable control-program fingerprint observed during resolution")
flags.StringVar(&options.expectedSnapshotFingerprint, "expected-snapshot-fingerprint", "", "exact admission-relevant snapshot fingerprint observed during resolution")
flags.StringVar(&options.idempotencyKey, "idempotency-key", "", "exact prior admission idempotency key for safe replay")
flags.StringVar(&options.humanActor, "human", "", "explicit command-scoped human authority actor")
flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from the V2 project configuration")
Expand All @@ -253,10 +276,11 @@ func parseOptions(command string, arguments []string, transition catalog.Transit
if err := populateRuntimeParameters(&options); err != nil {
return commandOptions{}, err
}
if command == "reconcile-update" {
if command == "reconcile-update" || (command == "update" && options.acceptProgramChange) {
if !options.acceptProgramChange {
return commandOptions{}, fmt.Errorf("reconcile-update requires explicit --accept-program-change")
}
options.transitionID = "installation.reconcile-update"
options.parameters = append(options.parameters, "accept_obligation_change=true")
}
case "correct-pr":
Expand Down Expand Up @@ -405,7 +429,10 @@ func buildRevision() string {

func buildRequest(operation surfaces.Operation, options commandOptions) (surfaces.Request, error) {
now := time.Now().UTC()
correlation := fmt.Sprintf("cli-%d-%d", os.Getpid(), now.UnixNano())
correlation := options.correlationID
if correlation == "" {
correlation = fmt.Sprintf("cli-%d-%d", os.Getpid(), now.UnixNano())
}
goal := model.Goal{}
if options.goalKind != "" || options.goalID != "" || options.deliveryID != "" {
goal = model.Goal{ID: options.goalID, Kind: model.GoalKind(options.goalKind), DeliveryID: options.deliveryID}
Expand All @@ -431,6 +458,9 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface
return surfaces.Request{
SchemaVersion: surfaces.SchemaVersion, Operation: operation, Repository: options.repository, Host: options.host, CorrelationID: correlation,
FlowID: flowID, Goal: goal, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters,
Prescription: protocol.Prescription{SchemaVersion: protocol.PrescriptionSchemaVersion, ID: options.prescriptionID,
TransitionID: catalog.TransitionID(options.transitionID), ExpectedStateRevision: options.expectedStateRevision,
ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint},
RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command,
}, nil
}
Expand Down Expand Up @@ -568,6 +598,15 @@ func renderResponse(response surfaces.Response, format string) error {
fmt.Println("transition:", response.Decision.Transition.ID)
}
}
if response.Prescription != nil {
correlation := ""
if response.Snapshot != nil {
correlation = response.Snapshot.Invocation.Correlation
}
fmt.Printf("prescription=%s state_revision=%d program=%s snapshot=%s correlation=%s\n", response.Prescription.ID,
response.Prescription.ExpectedStateRevision, response.Prescription.ExpectedProgramFingerprint,
response.Prescription.ExpectedSnapshotFingerprint, correlation)
}
if response.Receipt != nil {
fmt.Println("receipt:", response.Receipt.ID)
}
Expand Down
2 changes: 1 addition & 1 deletion boatstack/flow/standard/historical_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func snapshotFromFixture(t *testing.T, fixture historicalFixture) model.Snapshot
InvokingPath: filepath.Join(t.TempDir(), "fixture", "repository"), Topology: model.Topology(facts["topology"]), Host: "corpus", Correlation: "correlation-" + fixture.Name,
}
observation := model.Observation{
SchemaVersion: model.SnapshotSchemaVersion, Invocation: invocation,
SchemaVersion: model.SnapshotSchemaVersion, StateRevision: 1, Invocation: invocation,
Phase: model.Known(model.ProtocolPhase(facts["phase"]), evidence), Engagement: model.Known(model.EngagementState(facts["engagement"]), evidence),
Delivery: model.Known(model.DeliveryState(facts["delivery"]), evidence), Workspace: model.Known(model.WorkspaceState(facts["workspace"]), evidence),
Plan: model.Known(model.PlanState(facts["plan"]), evidence), Configuration: model.Known(model.ConfigurationState(facts["configuration"]), evidence),
Expand Down
6 changes: 3 additions & 3 deletions boatstack/flow/standard/supervisor_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ func snapshotFor(t *testing.T, phase model.ProtocolPhase, terminal model.Termina
t.Helper()
e := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(10, 0).UTC()}
o := model.Observation{
SchemaVersion: model.SnapshotSchemaVersion,
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"},
Phase: model.Known(phase, e), Engagement: model.Known(model.EngagementActive, e), Delivery: model.Known(model.DeliveryActive, e),
SchemaVersion: model.SnapshotSchemaVersion, StateRevision: 1,
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"},
Phase: model.Known(phase, e), Engagement: model.Known(model.EngagementActive, e), Delivery: model.Known(model.DeliveryActive, e),
Workspace: model.Known(model.WorkspaceActive, e), Plan: model.Known(model.PlanValid, e),
Configuration: model.Known(model.ConfigurationVerified, e), Runtime: model.Known(model.RuntimeVerified, e),
ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}}, e),
Expand Down
Loading