Skip to content

Commit ccbd523

Browse files
authored
Make delivery lifecycle transitions authoritative (#181)
* fix: make delivery lifecycle transitions authoritative * fix: render planning commands for one shell
1 parent be37f76 commit ccbd523

26 files changed

Lines changed: 1002 additions & 77 deletions

boatstack/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ Do not branch the workflow on model brand, price, or a guessed capability tier.
175175

176176
## Repair from ordinary conversation
177177

178-
Before any product edit or explicit `repair`, run `recovery-status` with the exact requested change and observed source stage. It resolves active work and published work associated with the current branch or recorded PR. Automatically use repair for ordinary CI failures, review findings, denied publication, problems, and modifications even when the user does not name Boatstack or a slash command. Active work resumes through `record-change`; a published parent returns `CORRECTIVE_CHILD_REQUIRED` and a deterministic child id. Never ask the user to manually repeat a denied push or PR mutation.
178+
Before any product edit or explicit `repair`, run `recovery-status` with the exact requested change and observed source stage. It resolves active work and published work associated with the current branch or recorded PR. Automatically use repair for ordinary CI failures, review findings, denied publication, problems, and modifications even when the user does not name Boatstack or a slash command. Active work resumes through `record-change`; a published parent returns `CORRECTIVE_CHILD_REQUIRED` and a deterministic child id. If active work enters `AMENDMENT_REQUIRED` or `PLAN_INVALID`, treat that composite state as authoritative over the slice status. Save the accepted amendment as a durable source plan, obtain a fresh lifecycle-bound `flow bootstrap` prescription for the same feature, execute only its returned envelope, then require normal approval and activation before delivery resumes. Never ask the user to manually repeat a denied push or PR mutation.
179179

180180
If Cursor reports `MainThreadShellExec not initialized`, the host failed before Boatstack's hook process started. Keep the hook fail-closed and make **Developer: Reload Window** the primary recovery, then retry the operation. Recommend the verified installer only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper/runtime.
181181

boatstack/bootstrap.go

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import (
99

1010
const bootstrapPrescriptionSchemaVersion = 1
1111

12+
// Tests may replace this seam. Nil selects the production installation health
13+
// check immediately before a bootstrap prescription is rendered.
14+
var bootstrapInstallationHealth func(string) error
15+
1216
type BootstrapShell string
1317

1418
const (
@@ -45,6 +49,10 @@ type BootstrapPrescription struct {
4549
Artifact string `json:"artifact"`
4650
ArtifactPath string `json:"artifact_path"`
4751
DocumentSHA256 string `json:"document_sha256"`
52+
LifecycleState string `json:"lifecycle_state,omitempty"`
53+
LifecycleSHA256 string `json:"lifecycle_sha256,omitempty"`
54+
ObservationID string `json:"observation_id,omitempty"`
55+
PreviousPlanLock string `json:"previous_plan_lock_sha256,omitempty"`
4856
Shell BootstrapShell `json:"shell"`
4957
Argv []string `json:"argv"`
5058
PlanningEnvelope string `json:"planning_envelope"`
@@ -62,22 +70,23 @@ func normalizedPlanningDocument(document []byte) ([]byte, error) {
6270
return value, nil
6371
}
6472

65-
func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, feature string) (string, error) {
73+
func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, feature string) (string, *LifecycleSnapshot, error) {
6674
directory := workspace.FeatureDir(feature)
6775
info, err := os.Lstat(directory)
6876
if os.IsNotExist(err) {
69-
return "CREATE_CANDIDATE", nil
77+
return "CREATE_CANDIDATE", nil, nil
7078
}
7179
if err != nil {
72-
return "", err
80+
return "", nil, err
7381
}
7482
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
75-
return "", fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature)
83+
return "", nil, fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature)
7684
}
7785
statePath, stateErr := deliveryStatePath(repo, feature)
7886
if stateErr != nil {
79-
return "", stateErr
87+
return "", nil, stateErr
8088
}
89+
managed := false
8190
for _, path := range []string{
8291
statePath,
8392
filepath.Join(directory, "plan.lock.json"),
@@ -86,24 +95,35 @@ func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, featur
8695
filepath.Join(directory, "autonomy.md"),
8796
} {
8897
if fileExists(path) {
89-
return "", fmt.Errorf("feature %s already carries managed authority; use flow next --feature %s", feature, feature)
98+
managed = true
99+
break
100+
}
101+
}
102+
if managed {
103+
snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, feature)
104+
if snapshotErr != nil {
105+
return "", nil, fmt.Errorf("feature %s carries managed authority that cannot be verified: %w", feature, snapshotErr)
106+
}
107+
if !amendmentLifecycleState(snapshot.State) {
108+
return "", nil, fmt.Errorf("feature %s already carries managed authority; use flow next --feature %s", feature, feature)
90109
}
110+
return "AMEND_ACTIVE", &snapshot, nil
91111
}
92112
entries, err := os.ReadDir(directory)
93113
if err != nil {
94-
return "", err
114+
return "", nil, err
95115
}
96116
for _, entry := range entries {
97117
if entry.IsDir() || !planningArtifacts[entry.Name()] {
98-
return "", fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature)
118+
return "", nil, fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature)
99119
}
100120
}
101121
if fileExists(filepath.Join(directory, "plan.md")) {
102122
if _, err := CheckPlan(filepath.Join(directory, "plan.md")); err != nil {
103-
return "", fmt.Errorf("feature %s has an invalid saved plan; run recovery-status before bootstrapping: %w", feature, err)
123+
return "", nil, fmt.Errorf("feature %s has an invalid saved plan; run recovery-status before bootstrapping: %w", feature, err)
104124
}
105125
}
106-
return "RESUME_CANDIDATE", nil
126+
return "RESUME_CANDIDATE", nil, nil
107127
}
108128

109129
func bootstrapProgram(workspace WorkspaceContext, shell BootstrapShell) string {
@@ -113,15 +133,23 @@ func bootstrapProgram(workspace WorkspaceContext, shell BootstrapShell) string {
113133
return workspace.LauncherPath(shell == BootstrapShellPowerShell)
114134
}
115135

116-
func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string) []string {
117-
return []string{
136+
func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string, lifecycle *LifecycleSnapshot) []string {
137+
argv := []string{
118138
program, "planning-write",
119139
"--repo", repo,
120140
"--feature", feature,
121141
"--artifact", artifact,
122142
"--source-plan", sourcePlan,
123143
"--source-plan-sha256", sourceSHA,
124144
}
145+
if lifecycle != nil {
146+
argv = append(argv,
147+
"--expected-lifecycle-sha256", lifecycle.Fingerprint,
148+
"--expected-plan-lock-sha256", lifecycle.PlanLockSHA256,
149+
"--expected-observation", lifecycle.ObservationID,
150+
)
151+
}
152+
return argv
125153
}
126154

127155
func posixPlanningEnvelopeFor(argv []string, document []byte) string {
@@ -176,7 +204,11 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription,
176204
if err != nil {
177205
return BootstrapPrescription{}, err
178206
}
179-
if err := CheckInstallationHealth(repo); err != nil {
207+
healthCheck := CheckInstallationHealth
208+
if bootstrapInstallationHealth != nil {
209+
healthCheck = bootstrapInstallationHealth
210+
}
211+
if err := healthCheck(repo); err != nil {
180212
return BootstrapPrescription{}, fmt.Errorf("bootstrap requires a healthy Boatstack installation: %w", DoctorRepairHint(err))
181213
}
182214
workspace, err := ResolveWorkspaceContext(repo)
@@ -198,12 +230,12 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription,
198230
if err != nil {
199231
return BootstrapPrescription{}, err
200232
}
201-
disposition, err := bootstrapFeatureDisposition(repo, workspace, options.Feature)
233+
disposition, lifecycle, err := bootstrapFeatureDisposition(repo, workspace, options.Feature)
202234
if err != nil {
203235
return BootstrapPrescription{}, err
204236
}
205237
program := bootstrapProgram(workspace, options.Shell)
206-
argv := planningArgv(program, repo, options.Feature, options.Artifact, sourcePlan, sourceSHA)
238+
argv := planningArgv(program, repo, options.Feature, options.Artifact, sourcePlan, sourceSHA, lifecycle)
207239
if options.Shell == BootstrapShellPOSIX {
208240
// Git Bash accepts Windows drive paths in slash form. Keep the typed argv
209241
// identical to the bytes rendered for that shell.
@@ -219,7 +251,7 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription,
219251
if err != nil {
220252
return BootstrapPrescription{}, err
221253
}
222-
return BootstrapPrescription{
254+
prescription := BootstrapPrescription{
223255
SchemaVersion: bootstrapPrescriptionSchemaVersion, VerificationStatus: "VERIFIED",
224256
Disposition: disposition, SupervisionMode: workspace.Mode,
225257
Repository: repo, RepositoryID: workspace.RepoID, WorktreeID: workspace.WorktreeID,
@@ -228,5 +260,12 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription,
228260
Artifact: options.Artifact, ArtifactPath: filepath.Join(workspace.FeatureDir(options.Feature), options.Artifact),
229261
DocumentSHA256: SHA256Bytes(document), Shell: options.Shell, Argv: argv,
230262
PlanningEnvelope: envelope,
231-
}, nil
263+
}
264+
if lifecycle != nil {
265+
prescription.LifecycleState = string(lifecycle.State)
266+
prescription.LifecycleSHA256 = lifecycle.Fingerprint
267+
prescription.ObservationID = lifecycle.ObservationID
268+
prescription.PreviousPlanLock = lifecycle.PlanLockSHA256
269+
}
270+
return prescription, nil
232271
}

boatstack/cmd/boatstack-helper/coverage_conformance_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ var nonDeliveryVerbs = map[string]bool{
4747
// Planning phase, before a plan is activated into a delivery.
4848
"check-source-plan": true,
4949
"check-plan": true,
50-
"planning-write": true,
51-
"record-approval": true,
5250
"record-autonomy": true,
5351
// Read-only status / diagnostics (observe helpers, not modeled transitions).
5452
"repair-status": true,

boatstack/cmd/boatstack-helper/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,9 @@ func planningWriteCommand(arguments []string) int {
581581
artifact := flags.String("artifact", "", "known Markdown planning artifact name")
582582
sourcePlan := flags.String("source-plan", "", "in-repo source plan bound by flow bootstrap")
583583
sourcePlanSHA256 := flags.String("source-plan-sha256", "", "source-plan digest bound by flow bootstrap")
584+
expectedLifecycleSHA256 := flags.String("expected-lifecycle-sha256", "", "active-delivery lifecycle fingerprint bound by flow bootstrap")
585+
expectedPlanLockSHA256 := flags.String("expected-plan-lock-sha256", "", "active plan-lock digest bound by flow bootstrap")
586+
expectedObservation := flags.String("expected-observation", "", "active amendment observation bound by flow bootstrap")
584587
if err := flags.Parse(arguments); err != nil {
585588
return 2
586589
}
@@ -594,6 +597,9 @@ func planningWriteCommand(arguments []string) int {
594597
path, err := boatstack.WritePlanningArtifact(boatstack.PlanningWriteOptions{
595598
Repo: *repo, Feature: *feature, Artifact: *artifact, Content: content,
596599
SourcePlan: *sourcePlan, SourcePlanSHA256: *sourcePlanSHA256,
600+
ExpectedLifecycleSHA256: *expectedLifecycleSHA256,
601+
ExpectedPlanLockSHA256: *expectedPlanLockSHA256,
602+
ExpectedObservation: *expectedObservation,
597603
})
598604
if err != nil {
599605
return fail(err)
@@ -610,6 +616,9 @@ func recordApprovalCommand(arguments []string) int {
610616
approvedAt := flags.String("approved-at", "", "RFC3339 approval timestamp")
611617
fingerprint := flags.String("fingerprint", "", "exact fingerprint displayed before approval")
612618
baselineDiffSHA256 := flags.String("baseline-diff-sha256", "", "exact product baseline fingerprint displayed before approval; omit only when clean")
619+
expectedLifecycleSHA256 := flags.String("expected-lifecycle-sha256", "", "exact active lifecycle fingerprint displayed before amendment approval")
620+
expectedPlanLockSHA256 := flags.String("expected-plan-lock-sha256", "", "exact prior plan-lock fingerprint displayed before amendment approval")
621+
expectedObservation := flags.String("expected-observation", "", "exact active amendment observation displayed before approval")
613622
if err := flags.Parse(arguments); err != nil {
614623
return 2
615624
}
@@ -619,6 +628,8 @@ func recordApprovalCommand(arguments []string) int {
619628
if err := boatstack.RecordApproval(boatstack.ApprovalRecordOptions{
620629
PlanPath: *plan, OutputPath: *output, ApprovedBy: *approvedBy,
621630
ApprovedAt: *approvedAt, Fingerprint: *fingerprint, BaselineDiffSHA256: *baselineDiffSHA256,
631+
ExpectedLifecycleSHA256: *expectedLifecycleSHA256, ExpectedPlanLockSHA256: *expectedPlanLockSHA256,
632+
ExpectedObservation: *expectedObservation,
622633
}); err != nil {
623634
return fail(err)
624635
}

boatstack/config_event_registry_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func TestConfigurationEventRegistryIsComplete(t *testing.T) {
8080
}
8181
sort.Strings(entries)
8282
digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
83-
const expected = "dbccf0d0263b056656e1626a56966b0c9dea5b67187b8e0f1980cd5745f4d6c5"
83+
const expected = "dfcbdc50fdfbda0d505ce7b04f55f6289622adad74a8fd01fc5fb13e6a00979b"
8484
if digest != expected {
8585
_ = os.WriteFile(filepath.Join(t.TempDir(), "config-events.txt"), []byte(strings.Join(entries, "\n")+"\n"), 0o644)
8686
t.Fatalf("configuration event registry changed: got %s; classify the new or removed site and update the reviewed digest", digest)

boatstack/deliverycontrol_parity_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ var realDeliveryHandlers = map[string]any{
3131
"CheckDeliveryReadyForShip": CheckDeliveryReadyForShip,
3232
"ResolveNext": ResolveNext,
3333
"ResolveRecovery": ResolveRecovery,
34+
"WritePlanningArtifact": WritePlanningArtifact,
35+
"RecordApproval": RecordApproval,
3436
}
3537

3638
func TestRegistryHandlerRefsAreRealFunctions(t *testing.T) {

0 commit comments

Comments
 (0)