Skip to content

Commit c5b5e10

Browse files
authored
Preserve active workspaces until lifecycle authority is proven (#185)
* fix: preserve active workspace lifecycle authority * test: ignore transient git maintenance lock
1 parent 8973dd8 commit c5b5e10

26 files changed

Lines changed: 731 additions & 108 deletions

boatstack/autonomy_conformance_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestAutonomyReceiptOverridesHumanPlanGateOnlyForExactPlan(t *testing.T) {
3636
}
3737
compiled := filepath.Join(root, "compiled")
3838
lockPath := filepath.Join(root, "plan.lock.json")
39-
if err := ActivatePlan(ActivationOptions{PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil {
39+
if err := ActivatePlan(ActivationOptions{Repo: root, PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil {
4040
t.Fatal(err)
4141
}
4242
value, err := os.ReadFile(lockPath)

boatstack/cmd/boatstack-helper/main.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -497,18 +497,18 @@ func checkPlanCommand(arguments []string) int {
497497
if *plan == "" {
498498
return fail(fmt.Errorf("check-plan requires --plan"))
499499
}
500-
check, err := boatstack.CheckPlan(*plan)
500+
check, err := boatstack.CheckPlanForRepository(".", *plan)
501501
if err != nil {
502502
return fail(fmt.Errorf("invalid Markdown plan: %w", err))
503503
}
504-
baseline, err := boatstack.PlanningBaselineForPlan(*plan)
504+
baseline, err := boatstack.PlanningBaselineForRepository(".", *plan)
505505
if err != nil {
506506
return fail(fmt.Errorf("cannot fingerprint the pre-activation product baseline: %w", err))
507507
}
508508
readinessFingerprint := ""
509509
if version, _ := check.Plan["schema_version"].(float64); version >= 3 {
510-
readiness, readinessErr := boatstack.CheckPlanReadiness(*plan)
511-
repo, _ := boatstack.ResolveControllerRepository(filepath.Dir(*plan))
510+
readiness, readinessErr := boatstack.CheckPlanReadinessForRepository(".", *plan)
511+
repo, _ := boatstack.ResolveRepository(".")
512512
if readinessErr != nil {
513513
boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, true, readinessErr.Error())
514514
return fail(readinessErr)
@@ -538,7 +538,7 @@ func checkSourcePlanCommand(arguments []string) int {
538538

539539
func activatePlanCommand(arguments []string) int {
540540
flags := flag.NewFlagSet("activate-plan", flag.ContinueOnError)
541-
options := boatstack.ActivationOptions{}
541+
options := boatstack.ActivationOptions{Repo: "."}
542542
flags.StringVar(&options.PlanPath, "plan", "", "approved Markdown plan")
543543
flags.StringVar(&options.ApprovalPath, "approval", "", "Markdown approval receipt")
544544
flags.StringVar(&options.OutDir, "out-dir", "", "compiled artifact directory")
@@ -635,6 +635,7 @@ func recordApprovalCommand(arguments []string) int {
635635
return fail(fmt.Errorf("record-approval requires --plan, --approved-by, --approved-at, and --fingerprint"))
636636
}
637637
if err := boatstack.RecordApproval(boatstack.ApprovalRecordOptions{
638+
Repo: ".",
638639
PlanPath: *plan, OutputPath: *output, ApprovedBy: *approvedBy,
639640
ApprovedAt: *approvedAt, Fingerprint: *fingerprint, BaselineDiffSHA256: *baselineDiffSHA256,
640641
ExpectedLifecycleSHA256: *expectedLifecycleSHA256, ExpectedPlanLockSHA256: *expectedPlanLockSHA256,

boatstack/config_event_registry_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func TestConfigurationEventRegistryIsComplete(t *testing.T) {
8686
}
8787
sort.Strings(entries)
8888
digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
89-
const expected = "ed524f110b7ade6e3a5795c5f26ee20db87153a0c91865c8e7cd63a9ee133f0f"
89+
const expected = "72481d05df0ef6d80a42b62f0e917ac173c436c970ed3f1aaaa8430aa41911a2"
9090
if digest != expected {
9191
_ = os.WriteFile(filepath.Join(t.TempDir(), "config-events.txt"), []byte(strings.Join(entries, "\n")+"\n"), 0o644)
9292
t.Fatalf("configuration event registry changed: got %s; classify the new or removed site and update the reviewed digest", digest)

boatstack/delivery_reactivation_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ func TestValidateAmendmentPreservesProgressBoundaries(t *testing.T) {
9494
func TestReconcileAmendedDeliveryStatePreservesPrefixAndPointer(t *testing.T) {
9595
existing := publishedThenBuilding("MERGED")
9696
newSlices := []DeliverySlice{
97-
{ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def
98-
{ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice
99-
{ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice
97+
{ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def
98+
{ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice
99+
{ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice
100100
}
101101

102102
result := reconcileAmendedDeliveryState(existing, newSlices, "new-lock")
@@ -169,6 +169,7 @@ func reactivateWithAmendedPlan(t *testing.T, repo, feature string, mutate func(p
169169
approvalPath := filepath.Join(dir, "approval.md")
170170
writeApprovalReceipt(t, approvalPath, check.Fingerprint)
171171
return ActivatePlan(ActivationOptions{
172+
Repo: repo,
172173
PlanPath: planPath,
173174
ApprovalPath: approvalPath,
174175
OutDir: filepath.Join(dir, "compiled"),

boatstack/delivery_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ func activateTwoSliceDeliveryConfigured(t *testing.T, maintainChangelog bool, co
134134
approvalPath := filepath.Join(directory, "approval.md")
135135
writeApprovalReceipt(t, approvalPath, check.Fingerprint)
136136
if err := ActivatePlan(ActivationOptions{
137+
Repo: repo,
137138
PlanPath: planPath, ApprovalPath: approvalPath, OutDir: filepath.Join(directory, "compiled"),
138139
OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
139140
}); err != nil {
@@ -265,6 +266,7 @@ func TestDeliveryGateReceiptsBindTheActiveSliceAndAdvanceOnce(t *testing.T) {
265266
}
266267
directory := filepath.Join(repo, ".product-loop", "features", feature)
267268
if err := ActivatePlan(ActivationOptions{
269+
Repo: repo,
268270
PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: filepath.Join(directory, "approval.md"),
269271
OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
270272
SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),

boatstack/detached_external_config_conformance_test.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,19 @@ func filesystemSnapshot(t *testing.T, root string) string {
2424
t.Helper()
2525
entries := []string{}
2626
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
27+
relative, relativeErr := filepath.Rel(root, path)
28+
if relativeErr != nil {
29+
return relativeErr
30+
}
31+
// git maintenance creates and removes this advisory lock independently of
32+
// the operation under test. It is not repository content and may disappear
33+
// between WalkDir reading the directory and lstatting the entry on macOS.
34+
if filepath.ToSlash(relative) == ".git/objects/maintenance.lock" {
35+
return nil
36+
}
2737
if walkErr != nil {
2838
return walkErr
2939
}
30-
relative, err := filepath.Rel(root, path)
31-
if err != nil {
32-
return err
33-
}
3440
info, err := entry.Info()
3541
if err != nil {
3642
return err
@@ -274,6 +280,7 @@ func TestDetachedConfigDriftBlocksMutationAndPublicationBypasses(t *testing.T) {
274280
}{
275281
{name: "activation", run: func() error {
276282
return ActivatePlan(ActivationOptions{
283+
Repo: repo,
277284
PlanPath: planPath, OutDir: filepath.Join(ctx.FeatureDir("feature-one"), "compiled"),
278285
OutputPath: filepath.Join(ctx.FeatureDir("feature-one"), "plan.lock.json"), SourceCommit: "test",
279286
})

boatstack/detached_ownership_conformance_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ func TestDetachedActivationUsesCanonicalFeatureDirectory(t *testing.T) {
155155
t.Fatalf("resolved owner lost detached context: resolved=%s ctx=%+v err=%v", resolved, ctx, ctxErr)
156156
}
157157
err := ActivatePlan(ActivationOptions{
158+
Repo: repo,
158159
PlanPath: filepath.Join(directory, "plan.md"), OutDir: filepath.Join(directory, "compiled"),
159160
OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: "test",
160161
})

boatstack/lifecycle_authority_conformance_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) {
112112
t.Fatal(err)
113113
}
114114
if err := RecordApproval(ApprovalRecordOptions{
115+
Repo: repo,
115116
PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-08-10T12:00:00Z",
116117
Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256,
117118
ExpectedLifecycleSHA256: drafted.Fingerprint, ExpectedPlanLockSHA256: drafted.PlanLockSHA256,
@@ -137,6 +138,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) {
137138
t.Fatal(err)
138139
}
139140
if err := ActivatePlan(ActivationOptions{
141+
Repo: repo,
140142
PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"),
141143
OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
142144
SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
@@ -148,6 +150,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) {
148150
t.Fatal(err)
149151
}
150152
if err := ActivatePlan(ActivationOptions{
153+
Repo: repo,
151154
PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"),
152155
OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
153156
SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),

boatstack/lifecycle_event_registry_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ func TestLifecycleEventRegistryIsComplete(t *testing.T) {
136136
}
137137
sort.Strings(entries)
138138
digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
139-
const expected = "2b6d3a0ba8b53513e744b478949da352e932aa2998b796f8077efaf286a14c0f"
139+
const expected = "167ba36844018e8eefe5120416c1354714dbc888588a96369cc2c83457ce433b"
140140
if digest != expected {
141141
t.Fatalf("lifecycle event registry changed: got %s; classify the new or removed site and update the reviewed digest\n%s", digest, strings.Join(entries, "\n"))
142142
}

boatstack/paths.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package boatstack
33
import (
44
"fmt"
55
"path/filepath"
6+
"sort"
67
"strings"
78
"sync"
89
)
@@ -180,21 +181,49 @@ func ResolveControllerRepository(path string) (string, error) {
180181
if err != nil {
181182
return "", err
182183
}
184+
var matches []string
183185
for repo := range registry.Repositories {
184186
ctx, ok, _ := detachedContextFor(repo)
185187
if !ok {
186188
continue
187189
}
188190
if pathWithin(ctx.ExportRoot(), path) {
189-
return repo, nil
191+
matches = append(matches, repo)
190192
}
191193
}
194+
sort.Strings(matches)
195+
if len(matches) == 1 {
196+
return matches[0], nil
197+
}
198+
if len(matches) > 1 {
199+
return "", fmt.Errorf("controller path has multiple verified repository aliases; supply the invoking repository explicitly: %s (%s)", path, strings.Join(matches, ", "))
200+
}
192201
if repo, err := ResolveRepository(path); err == nil {
193202
return repo, nil
194203
}
195204
return "", fmt.Errorf("path is not owned by a repository or verified detached controller: %s", path)
196205
}
197206

207+
// ResolveControllerRepositoryFor validates a controller path against an
208+
// explicit invoking repository. Detached controller roots are intentionally
209+
// shared by aliases of one Git repository, so effectful operations must carry
210+
// the caller's worktree identity forward instead of reconstructing it from the
211+
// non-injective controller path.
212+
func ResolveControllerRepositoryFor(repoPath, path string) (string, error) {
213+
repo, err := ResolveRepository(repoPath)
214+
if err != nil {
215+
return "", err
216+
}
217+
ctx, err := ResolveWorkspaceContext(repo)
218+
if err != nil {
219+
return "", err
220+
}
221+
if !pathWithin(ctx.ExportRoot(), path) {
222+
return "", fmt.Errorf("controller path is not owned by the invoking repository %s: %s", repo, path)
223+
}
224+
return repo, nil
225+
}
226+
198227
var (
199228
workspaceCacheMu sync.Mutex
200229
workspaceCache = map[string]WorkspaceContext{}

0 commit comments

Comments
 (0)