From 8845a2df74b8a7f3a5a2156d66acb3d35c1e5d77 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 20:59:38 +0100 Subject: [PATCH 1/2] fix: preserve active workspace lifecycle authority --- boatstack/autonomy_conformance_test.go | 2 +- boatstack/cmd/boatstack-helper/main.go | 11 +- boatstack/config_event_registry_test.go | 2 +- boatstack/delivery_reactivation_test.go | 7 +- boatstack/delivery_test.go | 2 + ...tached_external_config_conformance_test.go | 1 + .../detached_ownership_conformance_test.go | 1 + .../lifecycle_authority_conformance_test.go | 3 + boatstack/lifecycle_event_registry_test.go | 2 +- boatstack/paths.go | 31 +- boatstack/plan.go | 101 ++++-- boatstack/plan_test.go | 8 +- boatstack/planning.go | 28 +- boatstack/planning_test.go | 5 +- boatstack/pr.go | 1 + boatstack/pr_test.go | 1 + boatstack/readiness.go | 24 +- boatstack/readiness_conformance_test.go | 4 + .../visual_delivery_strengthening_test.go | 4 +- boatstack/visual_publisher_test.go | 2 +- boatstack/workspace.go | 263 +++++++++++++--- .../workspace_authority_conformance_test.go | 295 ++++++++++++++++++ boatstack/workspace_test.go | 16 +- .../workspace_transition_conformance_test.go | 2 + boatstack/worktree_activation_guard_test.go | 2 +- ...026-08-10-workspace-lifecycle-authority.md | 7 + 26 files changed, 721 insertions(+), 104 deletions(-) create mode 100644 boatstack/workspace_authority_conformance_test.go create mode 100644 release-notes/2026-08-10-workspace-lifecycle-authority.md diff --git a/boatstack/autonomy_conformance_test.go b/boatstack/autonomy_conformance_test.go index 80aa5d1..d3ee558 100644 --- a/boatstack/autonomy_conformance_test.go +++ b/boatstack/autonomy_conformance_test.go @@ -36,7 +36,7 @@ func TestAutonomyReceiptOverridesHumanPlanGateOnlyForExactPlan(t *testing.T) { } compiled := filepath.Join(root, "compiled") lockPath := filepath.Join(root, "plan.lock.json") - if err := ActivatePlan(ActivationOptions{PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil { + if err := ActivatePlan(ActivationOptions{Repo: root, PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil { t.Fatal(err) } value, err := os.ReadFile(lockPath) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 30d87be..f2a8c7d 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -497,18 +497,18 @@ func checkPlanCommand(arguments []string) int { if *plan == "" { return fail(fmt.Errorf("check-plan requires --plan")) } - check, err := boatstack.CheckPlan(*plan) + check, err := boatstack.CheckPlanForRepository(".", *plan) if err != nil { return fail(fmt.Errorf("invalid Markdown plan: %w", err)) } - baseline, err := boatstack.PlanningBaselineForPlan(*plan) + baseline, err := boatstack.PlanningBaselineForRepository(".", *plan) if err != nil { return fail(fmt.Errorf("cannot fingerprint the pre-activation product baseline: %w", err)) } readinessFingerprint := "" if version, _ := check.Plan["schema_version"].(float64); version >= 3 { - readiness, readinessErr := boatstack.CheckPlanReadiness(*plan) - repo, _ := boatstack.ResolveControllerRepository(filepath.Dir(*plan)) + readiness, readinessErr := boatstack.CheckPlanReadinessForRepository(".", *plan) + repo, _ := boatstack.ResolveRepository(".") if readinessErr != nil { boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, true, readinessErr.Error()) return fail(readinessErr) @@ -538,7 +538,7 @@ func checkSourcePlanCommand(arguments []string) int { func activatePlanCommand(arguments []string) int { flags := flag.NewFlagSet("activate-plan", flag.ContinueOnError) - options := boatstack.ActivationOptions{} + options := boatstack.ActivationOptions{Repo: "."} flags.StringVar(&options.PlanPath, "plan", "", "approved Markdown plan") flags.StringVar(&options.ApprovalPath, "approval", "", "Markdown approval receipt") flags.StringVar(&options.OutDir, "out-dir", "", "compiled artifact directory") @@ -635,6 +635,7 @@ func recordApprovalCommand(arguments []string) int { return fail(fmt.Errorf("record-approval requires --plan, --approved-by, --approved-at, and --fingerprint")) } if err := boatstack.RecordApproval(boatstack.ApprovalRecordOptions{ + Repo: ".", PlanPath: *plan, OutputPath: *output, ApprovedBy: *approvedBy, ApprovedAt: *approvedAt, Fingerprint: *fingerprint, BaselineDiffSHA256: *baselineDiffSHA256, ExpectedLifecycleSHA256: *expectedLifecycleSHA256, ExpectedPlanLockSHA256: *expectedPlanLockSHA256, diff --git a/boatstack/config_event_registry_test.go b/boatstack/config_event_registry_test.go index c29253f..0c39871 100644 --- a/boatstack/config_event_registry_test.go +++ b/boatstack/config_event_registry_test.go @@ -86,7 +86,7 @@ func TestConfigurationEventRegistryIsComplete(t *testing.T) { } sort.Strings(entries) digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) - const expected = "ed524f110b7ade6e3a5795c5f26ee20db87153a0c91865c8e7cd63a9ee133f0f" + const expected = "72481d05df0ef6d80a42b62f0e917ac173c436c970ed3f1aaaa8430aa41911a2" if digest != expected { _ = os.WriteFile(filepath.Join(t.TempDir(), "config-events.txt"), []byte(strings.Join(entries, "\n")+"\n"), 0o644) t.Fatalf("configuration event registry changed: got %s; classify the new or removed site and update the reviewed digest", digest) diff --git a/boatstack/delivery_reactivation_test.go b/boatstack/delivery_reactivation_test.go index bdcff18..7b94441 100644 --- a/boatstack/delivery_reactivation_test.go +++ b/boatstack/delivery_reactivation_test.go @@ -94,9 +94,9 @@ func TestValidateAmendmentPreservesProgressBoundaries(t *testing.T) { func TestReconcileAmendedDeliveryStatePreservesPrefixAndPointer(t *testing.T) { existing := publishedThenBuilding("MERGED") newSlices := []DeliverySlice{ - {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def - {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice - {ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice + {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def + {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice + {ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice } result := reconcileAmendedDeliveryState(existing, newSlices, "new-lock") @@ -169,6 +169,7 @@ func reactivateWithAmendedPlan(t *testing.T, repo, feature string, mutate func(p approvalPath := filepath.Join(dir, "approval.md") writeApprovalReceipt(t, approvalPath, check.Fingerprint) return ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: planPath, ApprovalPath: approvalPath, OutDir: filepath.Join(dir, "compiled"), diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go index b3b62e3..f816c51 100644 --- a/boatstack/delivery_test.go +++ b/boatstack/delivery_test.go @@ -134,6 +134,7 @@ func activateTwoSliceDeliveryConfigured(t *testing.T, maintainChangelog bool, co approvalPath := filepath.Join(directory, "approval.md") writeApprovalReceipt(t, approvalPath, check.Fingerprint) if err := ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: planPath, ApprovalPath: approvalPath, OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), }); err != nil { @@ -265,6 +266,7 @@ func TestDeliveryGateReceiptsBindTheActiveSliceAndAdvanceOnce(t *testing.T) { } directory := filepath.Join(repo, ".product-loop", "features", feature) if err := ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: filepath.Join(directory, "approval.md"), OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), diff --git a/boatstack/detached_external_config_conformance_test.go b/boatstack/detached_external_config_conformance_test.go index 0bf64cd..5a0dd0b 100644 --- a/boatstack/detached_external_config_conformance_test.go +++ b/boatstack/detached_external_config_conformance_test.go @@ -274,6 +274,7 @@ func TestDetachedConfigDriftBlocksMutationAndPublicationBypasses(t *testing.T) { }{ {name: "activation", run: func() error { return ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: planPath, OutDir: filepath.Join(ctx.FeatureDir("feature-one"), "compiled"), OutputPath: filepath.Join(ctx.FeatureDir("feature-one"), "plan.lock.json"), SourceCommit: "test", }) diff --git a/boatstack/detached_ownership_conformance_test.go b/boatstack/detached_ownership_conformance_test.go index 996051e..a19eba5 100644 --- a/boatstack/detached_ownership_conformance_test.go +++ b/boatstack/detached_ownership_conformance_test.go @@ -155,6 +155,7 @@ func TestDetachedActivationUsesCanonicalFeatureDirectory(t *testing.T) { t.Fatalf("resolved owner lost detached context: resolved=%s ctx=%+v err=%v", resolved, ctx, ctxErr) } err := ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: filepath.Join(directory, "plan.md"), OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: "test", }) diff --git a/boatstack/lifecycle_authority_conformance_test.go b/boatstack/lifecycle_authority_conformance_test.go index 5bb1be8..b0f2816 100644 --- a/boatstack/lifecycle_authority_conformance_test.go +++ b/boatstack/lifecycle_authority_conformance_test.go @@ -112,6 +112,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) { t.Fatal(err) } if err := RecordApproval(ApprovalRecordOptions{ + Repo: repo, PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-08-10T12:00:00Z", Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256, ExpectedLifecycleSHA256: drafted.Fingerprint, ExpectedPlanLockSHA256: drafted.PlanLockSHA256, @@ -137,6 +138,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) { t.Fatal(err) } if err := ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"), OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), @@ -148,6 +150,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) { t.Fatal(err) } if err := ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"), OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), diff --git a/boatstack/lifecycle_event_registry_test.go b/boatstack/lifecycle_event_registry_test.go index 0efe224..12a2308 100644 --- a/boatstack/lifecycle_event_registry_test.go +++ b/boatstack/lifecycle_event_registry_test.go @@ -136,7 +136,7 @@ func TestLifecycleEventRegistryIsComplete(t *testing.T) { } sort.Strings(entries) digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) - const expected = "2b6d3a0ba8b53513e744b478949da352e932aa2998b796f8077efaf286a14c0f" + const expected = "167ba36844018e8eefe5120416c1354714dbc888588a96369cc2c83457ce433b" if digest != expected { 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")) } diff --git a/boatstack/paths.go b/boatstack/paths.go index fdc1247..675ed06 100644 --- a/boatstack/paths.go +++ b/boatstack/paths.go @@ -3,6 +3,7 @@ package boatstack import ( "fmt" "path/filepath" + "sort" "strings" "sync" ) @@ -180,21 +181,49 @@ func ResolveControllerRepository(path string) (string, error) { if err != nil { return "", err } + var matches []string for repo := range registry.Repositories { ctx, ok, _ := detachedContextFor(repo) if !ok { continue } if pathWithin(ctx.ExportRoot(), path) { - return repo, nil + matches = append(matches, repo) } } + sort.Strings(matches) + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + return "", fmt.Errorf("controller path has multiple verified repository aliases; supply the invoking repository explicitly: %s (%s)", path, strings.Join(matches, ", ")) + } if repo, err := ResolveRepository(path); err == nil { return repo, nil } return "", fmt.Errorf("path is not owned by a repository or verified detached controller: %s", path) } +// ResolveControllerRepositoryFor validates a controller path against an +// explicit invoking repository. Detached controller roots are intentionally +// shared by aliases of one Git repository, so effectful operations must carry +// the caller's worktree identity forward instead of reconstructing it from the +// non-injective controller path. +func ResolveControllerRepositoryFor(repoPath, path string) (string, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return "", err + } + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return "", err + } + if !pathWithin(ctx.ExportRoot(), path) { + return "", fmt.Errorf("controller path is not owned by the invoking repository %s: %s", repo, path) + } + return repo, nil +} + var ( workspaceCacheMu sync.Mutex workspaceCache = map[string]WorkspaceContext{} diff --git a/boatstack/plan.go b/boatstack/plan.go index 3bff886..2f06cd4 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -287,7 +287,7 @@ func DiscoverSourcePlan(repo, explicit string) (string, error) { return filepath.ToSlash(relative), nil } -func SourcePlanForStructuredPlan(planPath string) (string, error) { +func sourcePlanForStructuredPlan(planPath, repo string) (string, error) { plan, err := LoadPlan(planPath) if err != nil { return "", err @@ -301,7 +301,10 @@ func SourcePlanForStructuredPlan(planPath string) (string, error) { if fileExists(planRelative) { return planRelative, nil } - if repo, repoErr := ResolveControllerRepository(filepath.Dir(planPath)); repoErr == nil { + if repo == "" { + repo, _ = ResolveControllerRepository(filepath.Dir(planPath)) + } + if repo != "" { repoRelative := filepath.Clean(filepath.Join(repo, sourcePlan)) if fileExists(repoRelative) { return repoRelative, nil @@ -320,6 +323,10 @@ func SourcePlanForStructuredPlan(planPath string) (string, error) { return filepath.Clean(sourcePlan), nil } +func SourcePlanForStructuredPlan(planPath string) (string, error) { + return sourcePlanForStructuredPlan(planPath, "") +} + func SpecForStructuredPlan(planPath string) (string, error) { plan, err := LoadPlan(planPath) if err != nil { @@ -361,12 +368,11 @@ type PlanCheck struct { Fingerprint string } -func CheckPlan(planPath string) (PlanCheck, error) { +func checkPlanForRepository(repoRoot, planPath string) (PlanCheck, error) { plan, err := LoadPlan(planPath) if err != nil { return PlanCheck{}, err } - repoRoot, _ := ResolveControllerRepository(filepath.Dir(planPath)) opts := &ValidatePlanOptions{ PlanPath: planPath, RepoRoot: repoRoot, @@ -374,7 +380,7 @@ func CheckPlan(planPath string) (PlanCheck, error) { if err := ValidatePlan(plan, opts); err != nil { return PlanCheck{}, err } - sourcePlan, err := SourcePlanForStructuredPlan(planPath) + sourcePlan, err := sourcePlanForStructuredPlan(planPath, repoRoot) if err != nil { return PlanCheck{}, err } @@ -419,8 +425,37 @@ func CheckPlan(planPath string) (PlanCheck, error) { }, nil } +func CheckPlan(planPath string) (PlanCheck, error) { + repoRoot, err := ResolveControllerRepository(filepath.Dir(planPath)) + if err != nil && strings.Contains(err.Error(), "multiple verified repository aliases") { + return PlanCheck{}, err + } + // Standalone Markdown validation remains path-only. Repository-dependent + // callers use CheckPlanForRepository; only the non-injective detached inverse + // is an error at this compatibility projection. + return checkPlanForRepository(repoRoot, planPath) +} + +// CheckPlanForRepository validates a plan while preserving the caller's +// explicit worktree identity across a shared detached controller root. +func CheckPlanForRepository(repoPath, planPath string) (PlanCheck, error) { + repoRoot, err := ResolveControllerRepositoryFor(repoPath, filepath.Dir(planPath)) + if err != nil { + return PlanCheck{}, err + } + return checkPlanForRepository(repoRoot, planPath) +} + func checkApprovalSourcePlan(options ApprovalOptions) error { - expected, err := SourcePlanForStructuredPlan(options.PlanPath) + repo := "" + var err error + if strings.TrimSpace(options.Repo) != "" { + repo, err = ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath)) + if err != nil { + return err + } + } + expected, err := sourcePlanForStructuredPlan(options.PlanPath, repo) if err != nil { return err } @@ -861,7 +896,7 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( if err != nil { return compiledArtifacts{}, err } - sourcePlan, err := SourcePlanForStructuredPlan(planPath) + sourcePlan, err := sourcePlanForStructuredPlan(planPath, repoRoot) if err != nil { return compiledArtifacts{}, err } @@ -920,7 +955,7 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( return compiledArtifacts{}, err } authority := "" - if check, checkErr := CheckPlan(planPath); checkErr == nil { + if check, checkErr := CheckPlanForRepository(repoRoot, planPath); checkErr == nil { authority = check.Fingerprint } scope := []string{relTasks, relMatrix, relEvidence, relJourney} @@ -963,6 +998,7 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( } type ApprovalOptions struct { + Repo string SourcePlanPath string SpecPath string PlanPath string @@ -1064,7 +1100,7 @@ func intValue(value any) int { return int(number) } -func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, error) { +func checkApprovalReceipt(path string, planCheck PlanCheck, repo string) (ApprovalReceipt, error) { receipt, err := LoadApprovalReceipt(path) if err != nil { return ApprovalReceipt{}, err @@ -1075,9 +1111,15 @@ func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, er if version, _ := planCheck.Plan["schema_version"].(float64); version >= 3 && receipt.SchemaVersion < 3 { return ApprovalReceipt{}, fmt.Errorf("legacy approval receipt has no readiness evidence; refresh approval against the current schema-v3 plan") } + if repo == "" { + repo, err = ResolveControllerRepository(filepath.Dir(planCheck.PlanPath)) + if err != nil { + return ApprovalReceipt{}, err + } + } if receipt.SchemaVersion == 3 { receipt.Readiness.PlanFingerprint = receipt.Fingerprint - current, readinessErr := CheckPlanReadiness(planCheck.PlanPath) + current, readinessErr := checkPlanReadiness(repo, planCheck.PlanPath) if readinessErr != nil { return ApprovalReceipt{}, readinessErr } @@ -1085,10 +1127,6 @@ func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, er return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: readiness fingerprint changed after approval") } } - repo, err := ResolveControllerRepository(filepath.Dir(planCheck.PlanPath)) - if err != nil { - return ApprovalReceipt{}, err - } baseline, err := productBaseline(repo, planCheck.PlanPath, planCheck.SourcePlanPath, planCheck.SpecPath, path) if err != nil { return ApprovalReceipt{}, err @@ -1103,7 +1141,12 @@ func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, er return receipt, nil } +func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, error) { + return checkApprovalReceipt(path, planCheck, "") +} + type ActivationOptions struct { + Repo string PlanPath string ApprovalPath string OutDir string @@ -1113,7 +1156,10 @@ type ActivationOptions struct { } func ActivatePlan(options ActivationOptions) error { - repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) + if strings.TrimSpace(options.Repo) == "" { + return fmt.Errorf("plan activation requires the invoking repository context") + } + repo, err := ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath)) if err != nil { return err } @@ -1121,7 +1167,15 @@ func ActivatePlan(options ActivationOptions) error { if err != nil { return err } - check, err := CheckPlan(options.PlanPath) + for _, owned := range []struct{ label, path string }{ + {"plan", options.PlanPath}, {"compiled output", options.OutDir}, {"plan lock", options.OutputPath}, + {"approval", options.ApprovalPath}, {"autonomy receipt", options.AutonomyPath}, + } { + if strings.TrimSpace(owned.path) != "" && !pathWithin(ctx.ExportRoot(), owned.path) { + return fmt.Errorf("%s path is outside the invoking repository's verified controller boundary: %s", owned.label, owned.path) + } + } + check, err := CheckPlanForRepository(repo, options.PlanPath) if err != nil { return err } @@ -1172,7 +1226,7 @@ func ActivatePlan(options ActivationOptions) error { if strings.TrimSpace(options.ApprovalPath) == "" { return fmt.Errorf("human_plan_approval requires --approval") } - receipt, err = CheckApprovalReceipt(options.ApprovalPath, check) + receipt, err = checkApprovalReceipt(options.ApprovalPath, check, repo) if err != nil { return err } @@ -1192,6 +1246,7 @@ func ActivatePlan(options ActivationOptions) error { } tasksPath := filepath.Join(options.OutDir, "tasks.json") approval := ApprovalOptions{ + Repo: repo, SourcePlanPath: check.SourcePlanPath, SpecPath: check.SpecPath, PlanPath: options.PlanPath, @@ -1209,7 +1264,7 @@ func ActivatePlan(options ActivationOptions) error { RunTarget: autonomy.Target, } if version, _ := check.Plan["schema_version"].(float64); version >= 3 && authorizationMode == "policy" { - approval.Readiness, err = CheckPlanReadiness(options.PlanPath) + approval.Readiness, err = checkPlanReadiness(repo, options.PlanPath) if err != nil { return err } @@ -1264,7 +1319,7 @@ func ActivatePlan(options ActivationOptions) error { return fmt.Errorf("pre-activation product baseline drifted before the plan lock could be created; expected paths %s, observed paths %s", strings.Join(baseline.ChangedPaths, ", "), strings.Join(currentBaseline.ChangedPaths, ", ")) } if approval.Readiness.Fingerprint != "" { - currentReadiness, readinessErr := CheckPlanReadiness(options.PlanPath) + currentReadiness, readinessErr := checkPlanReadiness(repo, options.PlanPath) if readinessErr != nil { return readinessErr } @@ -1513,7 +1568,13 @@ func CheckApprovalLock(options ApprovalOptions) error { if fingerprint, fingerprintErr := readinessFingerprint(storedReadiness); fingerprintErr != nil || fingerprint != storedReadiness.Fingerprint { mismatches = append(mismatches, "readiness_fingerprint") } - repo, repoErr := ResolveControllerRepository(filepath.Dir(options.PlanPath)) + repo := "" + var repoErr error + if strings.TrimSpace(options.Repo) != "" { + repo, repoErr = ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath)) + } else { + repo, repoErr = ResolveControllerRepository(filepath.Dir(options.PlanPath)) + } plan, planErr := LoadPlan(options.PlanPath) if repoErr != nil || planErr != nil { mismatches = append(mismatches, "journey_manifest") diff --git a/boatstack/plan_test.go b/boatstack/plan_test.go index 08900e7..a7ffba1 100644 --- a/boatstack/plan_test.go +++ b/boatstack/plan_test.go @@ -118,7 +118,7 @@ func TestMarkdownPlanActivationAndStaleness(t *testing.T) { t.Fatal(err) } writeApprovalReceipt(t, approval, check.Fingerprint) - options := ActivationOptions{PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lock, SourceCommit: "test"} + options := ActivationOptions{Repo: root, PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lock, SourceCommit: "test"} if err := ActivatePlan(options); err != nil { t.Fatal(err) } @@ -156,7 +156,7 @@ func TestPolicyActivationCreatesTypedLockWithoutApproval(t *testing.T) { runGit(t, root, "commit", "-m", "record policy-activated planning inputs") compiled := filepath.Join(root, "compiled") lockPath := filepath.Join(root, "plan.lock.json") - options := ActivationOptions{PlanPath: planPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"} + options := ActivationOptions{Repo: root, PlanPath: planPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"} if err := ActivatePlan(options); err != nil { t.Fatal(err) } @@ -218,7 +218,7 @@ func activatePolicyPlan(t *testing.T) (root, planPath, compiled, lock, feature s featureDir := filepath.Join(root, ".product-loop", "features", feature) compiled = filepath.Join(featureDir, "compiled") lock = filepath.Join(featureDir, "plan.lock.json") - options := ActivationOptions{PlanPath: planPath, OutDir: compiled, OutputPath: lock, SourceCommit: "test"} + options := ActivationOptions{Repo: root, PlanPath: planPath, OutDir: compiled, OutputPath: lock, SourceCommit: "test"} if err := ActivatePlan(options); err != nil { t.Fatal(err) } @@ -377,7 +377,7 @@ func TestReadOnlyCheckAndFailedActivationWriteNothing(t *testing.T) { approval := filepath.Join(root, "approval.md") compiled := filepath.Join(root, "compiled") lock := filepath.Join(root, "plan.lock.json") - activation := ActivationOptions{PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lock} + activation := ActivationOptions{Repo: root, PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lock} err = ActivatePlan(activation) if err == nil { t.Fatal("expected missing approval receipt to block") diff --git a/boatstack/planning.go b/boatstack/planning.go index 35cdbeb..e4eac4c 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -53,6 +53,7 @@ type PlanningWriteOptions struct { } type ApprovalRecordOptions struct { + Repo string PlanPath string OutputPath string ApprovedBy string @@ -173,11 +174,19 @@ func productBaseline(repo string, artifactPaths ...string) (PlanningBaseline, er } func PlanningBaselineForPlan(planPath string) (PlanningBaseline, error) { - check, err := CheckPlan(planPath) + repo, err := ResolveControllerRepository(filepath.Dir(planPath)) if err != nil { return PlanningBaseline{}, err } - repo, err := ResolveControllerRepository(filepath.Dir(planPath)) + return PlanningBaselineForRepository(repo, planPath) +} + +func PlanningBaselineForRepository(repoPath, planPath string) (PlanningBaseline, error) { + repo, err := ResolveControllerRepositoryFor(repoPath, filepath.Dir(planPath)) + if err != nil { + return PlanningBaseline{}, err + } + check, err := CheckPlanForRepository(repo, planPath) if err != nil { return PlanningBaseline{}, err } @@ -353,17 +362,20 @@ func RecordApproval(options ApprovalRecordOptions) error { if err != nil { return fmt.Errorf("approval timestamp must be RFC3339") } - check, err := CheckPlan(options.PlanPath) + if strings.TrimSpace(options.Repo) == "" { + return fmt.Errorf("approval requires the invoking repository context") + } + repo, err := ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath)) if err != nil { return err } - if options.Fingerprint != check.Fingerprint { - return fmt.Errorf("approval fingerprint does not match the current plan; the plan now fingerprints as %s — re-approve against that value (run check-plan to confirm)", check.Fingerprint) - } - repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) + check, err := CheckPlanForRepository(repo, options.PlanPath) if err != nil { return err } + if options.Fingerprint != check.Fingerprint { + return fmt.Errorf("approval fingerprint does not match the current plan; the plan now fingerprints as %s — re-approve against that value (run check-plan to confirm)", check.Fingerprint) + } feature := strings.TrimSpace(stringValue(check.Plan["feature_id"])) statePath, statePathErr := deliveryStatePath(repo, feature) if statePathErr != nil { @@ -440,7 +452,7 @@ func RecordApproval(options ApprovalRecordOptions) error { payloadValue["observation_id"] = strings.TrimSpace(options.ExpectedObservation) } if version, _ := check.Plan["schema_version"].(float64); version >= 3 { - readiness, readinessErr := CheckPlanReadiness(options.PlanPath) + readiness, readinessErr := checkPlanReadiness(repo, options.PlanPath) if readinessErr != nil { return readinessErr } diff --git a/boatstack/planning_test.go b/boatstack/planning_test.go index 7eefe1d..8a125ec 100644 --- a/boatstack/planning_test.go +++ b/boatstack/planning_test.go @@ -184,6 +184,7 @@ func TestRecordApprovalChecksFingerprintAndWritesOnlyReceipt(t *testing.T) { } approval := filepath.Join(root, "approval.md") if err := RecordApproval(ApprovalRecordOptions{ + Repo: root, PlanPath: planPath, OutputPath: approval, ApprovedBy: "Test Human", ApprovedAt: "2026-07-16T12:00:00Z", Fingerprint: "wrong", }); err == nil { @@ -193,6 +194,7 @@ func TestRecordApprovalChecksFingerprintAndWritesOnlyReceipt(t *testing.T) { t.Fatal("failed approval created a receipt") } if err := RecordApproval(ApprovalRecordOptions{ + Repo: root, PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-07-16T12:00:00Z", Fingerprint: check.Fingerprint, }); err != nil { @@ -236,6 +238,7 @@ func TestApprovalBindsAndPreservesExistingProductBaseline(t *testing.T) { } approval := filepath.Join(root, "approval.md") if err := RecordApproval(ApprovalRecordOptions{ + Repo: root, PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-07-16T12:00:00Z", Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256, }); err != nil { @@ -247,7 +250,7 @@ func TestApprovalBindsAndPreservesExistingProductBaseline(t *testing.T) { writeActivationConfig(t, root, true) compiled := filepath.Join(root, ".product-loop", "features", "feature-one", "compiled") lockPath := filepath.Join(root, ".product-loop", "features", "feature-one", "plan.lock.json") - if err := ActivatePlan(ActivationOptions{PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil { + if err := ActivatePlan(ActivationOptions{Repo: root, PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil { t.Fatalf("unchanged pre-existing product diff blocked activation: %v", err) } content, err := os.ReadFile(filepath.Join(root, "app.ts")) diff --git a/boatstack/pr.go b/boatstack/pr.go index d123817..9ce3bb8 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -677,6 +677,7 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro } tasksPath := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json") if err := CheckApprovalLock(ApprovalOptions{ + Repo: repo, SourcePlanPath: check.SourcePlanPath, SpecPath: check.SpecPath, PlanPath: planPath, diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go index 485631c..7b15057 100644 --- a/boatstack/pr_test.go +++ b/boatstack/pr_test.go @@ -238,6 +238,7 @@ func activateManagedFeatureLayout(t *testing.T, repo, feature string, compiled b outDir = filepath.Join(directory, "compiled") } if err := ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: approvalPath, OutDir: outDir, OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), diff --git a/boatstack/readiness.go b/boatstack/readiness.go index 07ece05..2b0af09 100644 --- a/boatstack/readiness.go +++ b/boatstack/readiness.go @@ -34,12 +34,8 @@ func readinessFingerprint(receipt ReadinessReceipt) (string, error) { return SHA256Bytes(canonical), nil } -func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) { - check, err := CheckPlan(planPath) - if err != nil { - return ReadinessReceipt{}, err - } - repo, err := ResolveControllerRepository(filepath.Dir(planPath)) +func checkPlanReadiness(repo, planPath string) (ReadinessReceipt, error) { + check, err := CheckPlanForRepository(repo, planPath) if err != nil { return ReadinessReceipt{}, err } @@ -91,6 +87,22 @@ func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) { return receipt, nil } +func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) { + repo, err := ResolveControllerRepository(filepath.Dir(planPath)) + if err != nil { + return ReadinessReceipt{}, err + } + return checkPlanReadiness(repo, planPath) +} + +func CheckPlanReadinessForRepository(repoPath, planPath string) (ReadinessReceipt, error) { + repo, err := ResolveControllerRepositoryFor(repoPath, filepath.Dir(planPath)) + if err != nil { + return ReadinessReceipt{}, err + } + return checkPlanReadiness(repo, planPath) +} + func checkJourneyCapabilities(repo string, plan map[string]any) error { decision, _ := plan["journey_evidence"].(map[string]any) if strings.ToLower(stringValue(decision["relevance"])) != "relevant" { diff --git a/boatstack/readiness_conformance_test.go b/boatstack/readiness_conformance_test.go index e3f48b0..0382dec 100644 --- a/boatstack/readiness_conformance_test.go +++ b/boatstack/readiness_conformance_test.go @@ -57,6 +57,7 @@ func TestApprovalAndActivationBindSameReadinessFingerprint(t *testing.T) { approvalPath := filepath.Join(dir, "approval.md") runGit(t, repo, "remote", "rename", "origin", "temporarily-unavailable") if err := RecordApproval(ApprovalRecordOptions{ + Repo: repo, PlanPath: planPath, OutputPath: approvalPath, ApprovedBy: "Test Human", ApprovedAt: "2026-07-29T12:00:00Z", Fingerprint: check.Fingerprint, }); err == nil { @@ -71,6 +72,7 @@ func TestApprovalAndActivationBindSameReadinessFingerprint(t *testing.T) { t.Fatal("unactivated legacy approval must not authorize a schema-v3 plan") } if err := RecordApproval(ApprovalRecordOptions{ + Repo: repo, PlanPath: planPath, OutputPath: approvalPath, ApprovedBy: "Test Human", ApprovedAt: "2026-07-29T12:00:00Z", Fingerprint: check.Fingerprint, }); err != nil { @@ -85,6 +87,7 @@ func TestApprovalAndActivationBindSameReadinessFingerprint(t *testing.T) { } lockPath := filepath.Join(dir, "plan.lock.json") if err := ActivatePlan(ActivationOptions{ + Repo: repo, PlanPath: planPath, ApprovalPath: approvalPath, OutDir: filepath.Join(dir, "compiled"), OutputPath: lockPath, }); err != nil { @@ -116,6 +119,7 @@ func TestApprovalAndActivationBindSameReadinessFingerprint(t *testing.T) { t.Fatal(err) } if err := CheckApprovalLock(ApprovalOptions{ + Repo: repo, SourcePlanPath: filepath.Join(dir, "source-plan.md"), SpecPath: filepath.Join(dir, "feature-spec.md"), PlanPath: planPath, TasksPath: filepath.Join(dir, "compiled", "tasks.json"), AuthorizationMode: "human", OutputPath: lockPath, diff --git a/boatstack/visual_delivery_strengthening_test.go b/boatstack/visual_delivery_strengthening_test.go index bd6bb50..888b72b 100644 --- a/boatstack/visual_delivery_strengthening_test.go +++ b/boatstack/visual_delivery_strengthening_test.go @@ -57,7 +57,9 @@ func TestHostedURLVerificationRejectsInvalidResponse(t *testing.T) { func TestHostedURLVerificationRejectsUnexpectedDomain(t *testing.T) { err := verifyHostedVisualURL(externalHostSpec{endpoint: "https://litterbox.catbox.moe/upload", label: "litter.catbox.moe"}, "https://example.com/image.png") - if err == nil || !strings.Contains(err.Error(), "unexpected domain") { t.Fatalf("unexpected host URL was accepted: %v", err) } + if err == nil || !strings.Contains(err.Error(), "unexpected domain") { + t.Fatalf("unexpected host URL was accepted: %v", err) + } } func TestExternalCommentIncludesJourneyContext(t *testing.T) { diff --git a/boatstack/visual_publisher_test.go b/boatstack/visual_publisher_test.go index b85bb4d..cc7906a 100644 --- a/boatstack/visual_publisher_test.go +++ b/boatstack/visual_publisher_test.go @@ -14,7 +14,7 @@ func TestOriginRepoSlugParsesSSHAndHTTPS(t *testing.T) { repo := t.TempDir() runGit(t, repo, "init", "-b", "main") cases := map[string]struct{ owner, name string }{ - "git@github.com:example-org/sample-app.git": {"example-org", "sample-app"}, + "git@github.com:example-org/sample-app.git": {"example-org", "sample-app"}, "https://github.com/operatorstack/boatstack.git": {"operatorstack", "boatstack"}, "https://github.com/operatorstack/boatstack": {"operatorstack", "boatstack"}, } diff --git a/boatstack/workspace.go b/boatstack/workspace.go index 59a49d3..c1723b2 100644 --- a/boatstack/workspace.go +++ b/boatstack/workspace.go @@ -221,12 +221,12 @@ func rollbackWorkspaceTransition(repo, branch, worktreePath string, transition w } } -func featurePackageFingerprint(directory string) (string, error) { +func featurePackageFingerprint(repo, directory string) (string, error) { planPath := filepath.Join(directory, "plan.md") if !fileExists(planPath) { return "", nil } - check, err := CheckPlan(planPath) + check, err := CheckPlanForRepository(repo, planPath) if err != nil { return "", err } @@ -353,9 +353,9 @@ func transferFeaturePackage(sourceRepo, destinationRepo, feature string, control if destination == "" || !dirExists(destination) { return "", nil } - return featurePackageFingerprint(destination) + return featurePackageFingerprint(destinationRepo, destination) } - sourceFingerprint, err := featurePackageFingerprint(source) + sourceFingerprint, err := featurePackageFingerprint(sourceRepo, source) if err != nil { return "", fmt.Errorf("source planning package is invalid: %w", err) } @@ -371,7 +371,7 @@ func transferFeaturePackage(sourceRepo, destinationRepo, feature string, control return sourceFingerprint, nil } if dirExists(destination) { - destinationFingerprint, fingerprintErr := featurePackageFingerprint(destination) + destinationFingerprint, fingerprintErr := featurePackageFingerprint(destinationRepo, destination) destinationDigest, digestErr := featurePackageDigest(destination) if fingerprintErr != nil || digestErr != nil || destinationFingerprint != sourceFingerprint || destinationDigest != sourceDigest { return "", fmt.Errorf("destination workspace contains a conflicting planning package") @@ -386,7 +386,7 @@ func transferFeaturePackage(sourceRepo, destinationRepo, feature string, control if err := workspacePackageCopy(source, destination); err != nil { return "", fmt.Errorf("copy planning package: %w", err) } - destinationFingerprint, err := featurePackageFingerprint(destination) + destinationFingerprint, err := featurePackageFingerprint(destinationRepo, destination) destinationDigest, digestErr := featurePackageDigest(destination) if err != nil || digestErr != nil || destinationFingerprint != sourceFingerprint || destinationDigest != sourceDigest { _ = os.RemoveAll(destination) @@ -584,19 +584,175 @@ func CutFeatureWorkspace(options WorkspaceCutOptions) (WorkspaceCut, error) { return result, nil } -// workspaceMergeStatus reports whether the branch's work has landed. It prefers -// the GitHub CLI's authoritative PR state and falls back to local ancestry when -// gh is unavailable, always reporting which source answered. -func workspaceMergeStatus(repo, branch, base string) (bool, string) { - if out, err := workspaceGh(repo, "pr", "view", branch, "--json", "state", "-q", ".state"); err == nil { - return strings.EqualFold(strings.TrimSpace(out), "MERGED"), "gh" +type workspaceLifecyclePhase string + +const ( + workspaceActive workspaceLifecyclePhase = "ACTIVE" + workspacePublished workspaceLifecyclePhase = "PUBLISHED" + workspaceLanded workspaceLifecyclePhase = "LANDED" + workspaceAbandoned workspaceLifecyclePhase = "ABANDONED" + workspaceAttentionRequired workspaceLifecyclePhase = "ATTENTION_REQUIRED" +) + +// workspaceLifecycleAssessment is the single authority boundary for workspace +// completion. Published and Landed are deliberately separate: Git ancestry may +// confirm landing only after a PR or completed managed delivery proves that the +// branch entered the publication lifecycle. +type workspaceLifecycleAssessment struct { + Phase workspaceLifecyclePhase + Source string + Published bool + Landed bool + Reason string +} + +func workspaceFeatureForBranch(branch string) string { + feature := strings.TrimPrefix(strings.TrimSpace(branch), "feat/") + if feature == branch || !featureSlugPattern.MatchString(feature) { + return "" + } + return feature +} + +func workspaceBranchLanded(repo, branch, base string) bool { + if strings.TrimSpace(branch) == "" { + return false } for _, target := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { if _, err := workspaceGit(repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, target); err == nil { - return true, "git" + return true } } - return false, "git" + return false +} + +func managedWorkspaceLifecycle(repo, branch, base string) (workspaceLifecycleAssessment, bool) { + feature := workspaceFeatureForBranch(branch) + if feature == "" { + return workspaceLifecycleAssessment{}, false + } + owner := repo + if worktree := worktreePathForBranch(repo, branch); worktree != "" { + owner = worktree + } + statePath, err := deliveryStatePath(owner, feature) + if err != nil || !fileExists(statePath) { + return workspaceLifecycleAssessment{}, false + } + state, err := CurrentDeliveryState(owner, feature) + if err != nil || !stateMatchesBranch(state, branch) { + return workspaceLifecycleAssessment{ + Phase: workspaceAttentionRequired, Source: "delivery", + Reason: "Managed delivery evidence is present but cannot be verified for this branch.", + }, true + } + if len(state.Slices) == 0 || state.ActiveIndex < len(state.Slices) { + return workspaceLifecycleAssessment{ + Phase: workspaceActive, Source: "delivery", + Reason: "Managed delivery work is still active.", + }, true + } + for _, slice := range state.Slices { + if slice.Status != StatusPublished || strings.TrimSpace(slice.PRURL) == "" { + return workspaceLifecycleAssessment{ + Phase: workspaceAttentionRequired, Source: "delivery", + Reason: "Completed delivery state lacks durable publication evidence.", + }, true + } + if strings.EqualFold(strings.TrimSpace(slice.PRState), "CLOSED") { + return workspaceLifecycleAssessment{ + Phase: workspaceAttentionRequired, Source: "delivery", Published: true, + Reason: "A published pull request closed without a verified merge.", + }, true + } + } + allLanded := true + for _, slice := range state.Slices { + if strings.EqualFold(strings.TrimSpace(slice.PRState), "MERGED") { + continue + } + head := strings.TrimSpace(slice.HeadBranch) + if head == "" { + head = branch + } + if !workspaceBranchLanded(repo, head, base) { + allLanded = false + break + } + } + if allLanded { + return workspaceLifecycleAssessment{ + Phase: workspaceLanded, Source: "git-after-publication", Published: true, Landed: true, + Reason: "Every published delivery branch is contained in the base branch.", + }, true + } + if resolveDeliveryTerminal(owner, feature) == TerminalMerged { + return workspaceLifecycleAssessment{ + Phase: workspaceActive, Source: "delivery", Published: true, + Reason: "Every delivery slice is published, but the configured merged terminal is unfinished.", + }, true + } + return workspaceLifecycleAssessment{ + Phase: workspacePublished, Source: "delivery", Published: true, + Reason: "Every delivery slice is published, but landing is not verified.", + }, true +} + +func assessWorkspaceLifecycle(repo, branch, base string, abandoned bool) workspaceLifecycleAssessment { + if abandoned { + return workspaceLifecycleAssessment{ + Phase: workspaceAbandoned, Source: "operator", Reason: "The operator explicitly abandoned this delivery.", + } + } + if managed, ok := managedWorkspaceLifecycle(repo, branch, base); ok { + return managed + } + if out, err := workspaceGh(repo, "pr", "view", branch, "--json", "state", "-q", ".state"); err == nil { + switch strings.ToUpper(strings.TrimSpace(out)) { + case "MERGED": + return workspaceLifecycleAssessment{ + Phase: workspaceLanded, Source: "gh", Published: true, Landed: true, + Reason: "GitHub reports the pull request merged.", + } + case "OPEN": + return workspaceLifecycleAssessment{ + Phase: workspacePublished, Source: "gh", Published: true, + Reason: "GitHub reports an open pull request.", + } + case "CLOSED": + return workspaceLifecycleAssessment{ + Phase: workspaceAttentionRequired, Source: "gh", Published: true, + Reason: "GitHub reports the pull request closed without a merge.", + } + default: + return workspaceLifecycleAssessment{ + Phase: workspaceAttentionRequired, Source: "gh", + Reason: "GitHub returned an unsupported pull request state.", + } + } + } + return workspaceLifecycleAssessment{ + Phase: workspaceActive, Source: "unpublished", + Reason: "No durable publication evidence exists; preserving the active workspace.", + } +} + +func (assessment workspaceLifecycleAssessment) cleanupEligible(cleanupAfter string) bool { + if assessment.Phase == workspaceAbandoned { + return true + } + if cleanupAfter == "ship" { + return assessment.Phase == workspacePublished || assessment.Phase == workspaceLanded + } + return assessment.Phase == workspaceLanded +} + +// workspaceMergeStatus is retained as the narrow merge projection used by +// existing callers and tests. It can no longer promote bare ancestry into merge +// authority: the full lifecycle assessment owns that decision. +func workspaceMergeStatus(repo, branch, base string) (bool, string) { + assessment := assessWorkspaceLifecycle(repo, branch, base, false) + return assessment.Landed, assessment.Source } // worktreePathForBranch returns the linked worktree path checked out on branch, @@ -664,7 +820,8 @@ type workspaceRemovalPlan struct { // unmerged-commit gates that govern removing a single feature workspace. It never // consults cleanup/reap mode or human confirmation and never mutates the // repository; callers own policy and confirmation. cleanupAfter="ship" permits -// removing an unmerged branch (used for merged-optional and abandoned workspaces). +// removing an unmerged branch after the lifecycle boundary has already proved +// publication (used for published and explicitly abandoned workspaces). func planWorkspaceRemoval(repo, base, branch, worktreePath, cleanupAfter string, merged, force bool) workspaceRemovalPlan { if branch == base { return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Refusing to clean up the base branch %q.", base)} @@ -682,16 +839,8 @@ func planWorkspaceRemoval(repo, base, branch, worktreePath, cleanupAfter string, return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Workspace %q has uncommitted changes; commit or discard them, or force cleanup.", branch)} } } - if !merged { - for _, target := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { - if _, err := workspaceGit(repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, target); err == nil { - merged = true - break - } - } - if !merged && cleanupAfter != "ship" { - return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Branch %q has commits not merged into %s; force cleanup to discard them.", branch, base)} - } + if !merged && cleanupAfter != "ship" { + return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Branch %q has commits not merged into %s; force cleanup to discard them.", branch, base)} } } return workspaceRemovalPlan{Removable: true, Status: "VERIFIED", Merged: merged} @@ -743,10 +892,11 @@ func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedCleanup(branch, "This repository has no Boatstack project installation."), nil } - policy, err := loadWorkspacePolicy(repo) + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if err != nil { return blockedCleanup(branch, "Boatstack could not read the workspace policy: "+err.Error()), nil } + policy := resolveWorkspace(config.Workspace) if policy.Cleanup == "off" && !options.Force { return blockedCleanup(branch, "Workspace cleanup is disabled (workspace.cleanup=off)."), nil } @@ -760,13 +910,29 @@ func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, } base := defaultPRBase(repo) - merged, source := workspaceMergeStatus(repo, branch, base) + abandoned := false + for _, feature := range config.Workflow.IgnoredDeliveries { + if branchForFeature(feature) == branch { + abandoned = true + break + } + } + lifecycle := assessWorkspaceLifecycle(repo, branch, base, abandoned) result := WorkspaceCleanup{ SchemaVersion: workspaceSchemaVersion, Branch: branch, Mode: policy.Mode, - Merged: merged, MergeSource: source, + Merged: lifecycle.Landed, MergeSource: lifecycle.Source, + } + if !options.Force && !lifecycle.cleanupEligible(policy.CleanupAfter) { + result.VerificationStatus = "BLOCKED" + result.Reason = lifecycle.Reason + return result, nil } - plan := planWorkspaceRemoval(repo, base, branch, worktreePath, policy.CleanupAfter, merged, options.Force) + cleanupAfter := policy.CleanupAfter + if lifecycle.Phase == workspaceAbandoned { + cleanupAfter = "ship" + } + plan := planWorkspaceRemoval(repo, base, branch, worktreePath, cleanupAfter, lifecycle.Landed, options.Force) if !plan.Removable { result.VerificationStatus = plan.Status result.Reason = plan.Reason @@ -881,7 +1047,7 @@ func samePath(a, b string) bool { // skipped or reclaimable without mutating the repository. It returns the skipped // candidates (for reporting) and the reclaimable subset (merged or abandoned, // excluding the base branch, the current worktree, and non-Boatstack worktrees). -func reclaimableScan(repo, base string, ignored []string) (skipped, reapable []WorkspaceReapItem) { +func reclaimableScan(repo, base, cleanupAfter string, ignored []string) (skipped, reapable []WorkspaceReapItem) { abandonedBranches := map[string]bool{} for _, slug := range ignored { if branch := branchForFeature(slug); branch != "" { @@ -904,14 +1070,14 @@ func reclaimableScan(repo, base string, ignored []string) (skipped, reapable []W skipped = append(skipped, item) continue } - merged, source := workspaceMergeStatus(repo, branch, base) abandoned := abandonedBranches[branch] - item.Merged = merged - item.MergeSource = source + lifecycle := assessWorkspaceLifecycle(repo, branch, base, abandoned) + item.Merged = lifecycle.Landed + item.MergeSource = lifecycle.Source item.Abandoned = abandoned - if !merged && !abandoned { + if !lifecycle.cleanupEligible(cleanupAfter) { item.Action = "skipped" - item.Reason = "Not merged and not abandoned; keeping the workspace." + item.Reason = lifecycle.Reason skipped = append(skipped, item) continue } @@ -933,7 +1099,8 @@ func CountReclaimableWorkspaces(repoPath string) int { if cfgErr != nil || !resolveWorkspace(config.Workspace).Enabled { return 0 } - _, reapable := reclaimableScan(repo, defaultPRBase(repo), config.Workflow.IgnoredDeliveries) + policy := resolveWorkspace(config.Workspace) + _, reapable := reclaimableScan(repo, defaultPRBase(repo), policy.CleanupAfter, config.Workflow.IgnoredDeliveries) return len(reapable) } @@ -971,7 +1138,7 @@ func ReapWorkspaces(options WorkspaceReapOptions) (WorkspaceReap, error) { } base := defaultPRBase(repo) - skipped, reapable := reclaimableScan(repo, base, config.Workflow.IgnoredDeliveries) + skipped, reapable := reclaimableScan(repo, base, policy.CleanupAfter, config.Workflow.IgnoredDeliveries) result.Candidates = append(result.Candidates, skipped...) result.ReclaimableCount = len(reapable) @@ -1066,18 +1233,24 @@ func FeatureWorkspaceStatus(repoPath, branch string) (WorkspaceStatus, error) { status.Reason = fmt.Sprintf("No workspace exists for branch %q.", branch) return status, nil } - base := defaultPRBase(repo) - status.Merged, status.MergeSource = workspaceMergeStatus(repo, branch, base) - policy, policyErr := loadWorkspacePolicy(repo) - requireMerged := true - if policyErr == nil { - requireMerged = policy.CleanupAfter != "ship" + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + abandoned := false + if configErr == nil { + for _, feature := range config.Workflow.IgnoredDeliveries { + if branchForFeature(feature) == branch { + abandoned = true + break + } + } } - status.CleanupDue = status.Merged || !requireMerged + base := defaultPRBase(repo) + lifecycle := assessWorkspaceLifecycle(repo, branch, base, abandoned) + status.Merged, status.MergeSource = lifecycle.Landed, lifecycle.Source + status.CleanupDue = configErr == nil && lifecycle.cleanupEligible(resolveWorkspace(config.Workspace).CleanupAfter) if status.CleanupDue { status.Reason = fmt.Sprintf("Workspace for %q is ready to clean up.", branch) } else { - status.Reason = fmt.Sprintf("Workspace for %q is still open (PR not merged).", branch) + status.Reason = lifecycle.Reason } return status, nil } diff --git a/boatstack/workspace_authority_conformance_test.go b/boatstack/workspace_authority_conformance_test.go new file mode 100644 index 0000000..9d8a4b1 --- /dev/null +++ b/boatstack/workspace_authority_conformance_test.go @@ -0,0 +1,295 @@ +package boatstack + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// control-law: uncertain-workspace-identity-or-lifecycle-preserves-resource +func TestDetachedUnpublishedCutRemainsActiveAndActivatesOnlyFromLinkedAlias(t *testing.T) { + repo := detachedTestRepo(t, "") + embeddedFeatureForDetach(t, repo, "feature-one", "") + config := testConfig() + config.Project.DefaultBranch = "main" + config.Workflow.HumanPlanApproval = false + config.Workspace = defaultWorkspace() + config.Adapters = nil + raw, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, sourceConfigName), raw, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "project.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, repo, "add", sourceConfigName, "plans/source.md") + workspaceGitDo(t, repo, "commit", "-m", "record detached workspace inputs") + addWorkspaceOrigin(t, repo) + + attached, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || attached.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v (%v)", attached, err) + } + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if err != nil || cut.VerificationStatus != "VERIFIED" || cut.DestinationRepo == repo { + t.Fatalf("cut: %+v (%v)", cut, err) + } + withWorkspaceGh(t, ghUnavailable()) + + status, err := FeatureWorkspaceStatus(repo, cut.Branch) + if err != nil { + t.Fatal(err) + } + if status.Merged || status.CleanupDue || status.MergeSource != "unpublished" { + t.Fatalf("unpublished cut collapsed into completion: %+v", status) + } + cleanup, err := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: cut.Branch, Confirm: true}) + if err != nil || cleanup.VerificationStatus != "BLOCKED" { + t.Fatalf("unpublished cleanup must block: %+v (%v)", cleanup, err) + } + reap, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Confirm: true}) + if err != nil || reap.ReapedCount != 0 || !dirExists(cut.WorktreePath) { + t.Fatalf("reap removed unpublished workspace: %+v (%v)", reap, err) + } + + directory := WorkspaceFor(cut.DestinationRepo).FeatureDir("feature-one") + planPath := filepath.Join(directory, "plan.md") + lockPath := filepath.Join(directory, "plan.lock.json") + compiled := filepath.Join(directory, "compiled") + if _, err := ResolveControllerRepository(directory); err == nil || !strings.Contains(err.Error(), "multiple verified repository aliases") { + t.Fatalf("ambiguous controller inverse selected an alias: %v", err) + } + mainOptions := ActivationOptions{Repo: repo, PlanPath: planPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"} + if err := ActivatePlan(mainOptions); err == nil || !strings.Contains(err.Error(), "cut worktree") { + t.Fatalf("main activation did not refuse with the worktree guard: %v", err) + } + if fileExists(lockPath) { + t.Fatal("refused main activation wrote a plan lock") + } + linkedOptions := mainOptions + linkedOptions.Repo = cut.DestinationRepo + if err := ActivatePlan(linkedOptions); err != nil { + t.Fatalf("linked detached alias did not activate: %v", err) + } + if !fileExists(lockPath) { + t.Fatal("linked activation did not write the plan lock") + } +} + +// control-law: cleanup-policy-never-substitutes-for-lifecycle-evidence +func TestCleanupAfterShipStillRequiresCompletedPublication(t *testing.T) { + policy := defaultWorkspace() + policy.CleanupAfter = "ship" + repo := workspaceRepo(t, policy) + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "ship-later"}) + if err != nil || cut.VerificationStatus != "VERIFIED" { + t.Fatalf("cut: %+v (%v)", cut, err) + } + withWorkspaceGh(t, ghUnavailable()) + status, err := FeatureWorkspaceStatus(repo, cut.Branch) + if err != nil || status.CleanupDue || status.Merged { + t.Fatalf("ship policy treated an unpublished cut as terminal: %+v (%v)", status, err) + } + cleanup, err := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: cut.Branch, Confirm: true}) + if err != nil || cleanup.VerificationStatus != "BLOCKED" || !dirExists(cut.WorktreePath) { + t.Fatalf("ship cleanup removed unpublished work: %+v (%v)", cleanup, err) + } + if count := CountReclaimableWorkspaces(repo); count != 0 { + t.Fatalf("ship reap exposed %d unpublished workspace(s)", count) + } +} + +// control-law: active-managed-delivery-outranks-branch-pr-projections +func TestPartialManagedDeliveryRemainsActiveEvenWhenGitHubReportsMerged(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "partial"}) + if err != nil || cut.VerificationStatus != "VERIFIED" { + t.Fatalf("cut: %+v (%v)", cut, err) + } + directory := WorkspaceFor(cut.WorktreePath).FeatureDir("partial") + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(directory, "plan.lock.json") + if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + t.Fatal(err) + } + lockHash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(cut.WorktreePath, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "partial", PlanLockHash: lockHash, ActiveIndex: 1, + Slices: []DeliverySlice{ + {ID: "first", Title: "First", Status: StatusPublished, HeadBranch: cut.Branch, PRURL: "https://example.invalid/pr/1", PRState: "MERGED"}, + {ID: "second", Title: "Second", Status: StatusBuild, HeadBranch: cut.Branch}, + }, + }); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("MERGED")) + status, err := FeatureWorkspaceStatus(repo, cut.Branch) + if err != nil || status.Merged || status.CleanupDue || status.MergeSource != "delivery" { + t.Fatalf("partial delivery collapsed into completion: %+v (%v)", status, err) + } +} + +// control-law: closed-unmerged-publication-is-preservation-only +func TestClosedUnmergedWorkspaceRequiresAttention(t *testing.T) { + policy := defaultWorkspace() + policy.CleanupAfter = "ship" + repo := workspaceRepo(t, policy) + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "closed"}) + if err != nil || cut.VerificationStatus != "VERIFIED" { + t.Fatalf("cut: %+v (%v)", cut, err) + } + withWorkspaceGh(t, ghState("CLOSED")) + status, err := FeatureWorkspaceStatus(repo, cut.Branch) + if err != nil || status.Merged || status.CleanupDue || status.MergeSource != "gh" { + t.Fatalf("closed-unmerged workspace became cleanup-ready: %+v (%v)", status, err) + } +} + +// control-law: managed-terminal-goal-controls-lifecycle-completion +func TestMergedTerminalKeepsPublishedManagedDeliveryActive(t *testing.T) { + policy := defaultWorkspace() + policy.CleanupAfter = "ship" + repo := workspaceRepo(t, policy) + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + if err != nil { + t.Fatal(err) + } + config.Delivery = &DeliveryPolicy{Terminal: string(TerminalMerged)} + raw, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(WorkspaceFor(repo).ProjectConfigPath(), raw, 0o644); err != nil { + t.Fatal(err) + } + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "merged-goal"}) + if err != nil || cut.VerificationStatus != "VERIFIED" { + t.Fatalf("cut: %+v (%v)", cut, err) + } + if err := os.WriteFile(filepath.Join(cut.WorktreePath, "managed-change.txt"), []byte("work\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, cut.WorktreePath, "add", "managed-change.txt") + workspaceGitDo(t, cut.WorktreePath, "commit", "-m", "managed change") + writeCompletedDelivery(t, cut.WorktreePath, "merged-goal", cut.Branch) + state, err := CurrentDeliveryState(cut.WorktreePath, "merged-goal") + if err != nil { + t.Fatal(err) + } + state.Goal = string(TerminalMerged) + state.Slices[0].PRState = "OPEN" + if err := saveDeliveryState(cut.WorktreePath, state); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("MERGED")) + status, err := FeatureWorkspaceStatus(repo, cut.Branch) + if err != nil || status.Merged || status.CleanupDue || status.MergeSource != "delivery" { + t.Fatalf("published slice escaped the configured merged terminal: %+v (%v)", status, err) + } +} + +func directCallers(t *testing.T, filename, callee string) []string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), filename, nil, 0) + if err != nil { + t.Fatal(err) + } + callers := []string{} + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + found := false + ast.Inspect(function.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + identifier, ok := call.Fun.(*ast.Ident) + if ok && identifier.Name == callee { + found = true + } + return true + }) + if found { + callers = append(callers, function.Name.Name) + } + } + sort.Strings(callers) + return callers +} + +func requireCallerInventory(t *testing.T, filename, callee string, expected []string) { + t.Helper() + actual := directCallers(t, filename, callee) + sort.Strings(expected) + if strings.Join(actual, "\x00") != strings.Join(expected, "\x00") { + t.Fatalf("%s callers changed without authority review: got %v want %v", callee, actual, expected) + } +} + +func repositoryCallers(t *testing.T, callee string) []string { + t.Helper() + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + callers := []string{} + for _, filename := range files { + if strings.HasSuffix(filename, "_test.go") { + continue + } + for _, caller := range directCallers(t, filename, callee) { + callers = append(callers, filename+":"+caller) + } + } + sort.Strings(callers) + return callers +} + +func requireRepositoryCallerInventory(t *testing.T, callee string, expected []string) { + t.Helper() + actual := repositoryCallers(t, callee) + sort.Strings(expected) + if strings.Join(actual, "\x00") != strings.Join(expected, "\x00") { + t.Fatalf("%s repository callers changed without authority review: got %v want %v", callee, actual, expected) + } +} + +// control-law: workspace-authority-boundaries-cover-every-static-consumer +func TestWorkspaceAuthoritySurfaceInventory(t *testing.T) { + requireCallerInventory(t, "workspace.go", "assessWorkspaceLifecycle", []string{ + "CleanupFeatureWorkspace", "FeatureWorkspaceStatus", "reclaimableScan", "workspaceMergeStatus", + }) + requireCallerInventory(t, "workspace.go", "workspaceBranchLanded", []string{"managedWorkspaceLifecycle"}) + requireCallerInventory(t, "plan.go", "ResolveControllerRepositoryFor", []string{ + "ActivatePlan", "CheckPlanForRepository", "CheckApprovalLock", "checkApprovalSourcePlan", + }) + requireCallerInventory(t, "planning.go", "ResolveControllerRepositoryFor", []string{ + "PlanningBaselineForRepository", "RecordApproval", + }) + requireCallerInventory(t, "readiness.go", "ResolveControllerRepositoryFor", []string{"CheckPlanReadinessForRepository"}) + requireRepositoryCallerInventory(t, "ResolveControllerRepositoryFor", []string{ + "plan.go:ActivatePlan", "plan.go:CheckApprovalLock", "plan.go:CheckPlanForRepository", "plan.go:checkApprovalSourcePlan", + "planning.go:PlanningBaselineForRepository", "planning.go:RecordApproval", + "readiness.go:CheckPlanReadinessForRepository", + }) + requireRepositoryCallerInventory(t, "ResolveControllerRepository", []string{ + "plan.go:CheckApprovalLock", "plan.go:CheckPlan", "plan.go:checkApprovalReceipt", "plan.go:compilePlanFiles", "plan.go:sourcePlanForStructuredPlan", + "planning.go:PlanningBaselineForPlan", "readiness.go:CheckPlanReadiness", + }) +} diff --git a/boatstack/workspace_test.go b/boatstack/workspace_test.go index ae850ec..73c97f0 100644 --- a/boatstack/workspace_test.go +++ b/boatstack/workspace_test.go @@ -186,10 +186,16 @@ func TestWorkspaceMergeStatusPrefersGh(t *testing.T) { func TestWorkspaceMergeStatusFallsBackToGit(t *testing.T) { repo := workspaceRepo(t, defaultWorkspace()) withWorkspaceGh(t, ghUnavailable()) - // Merged: branch is an ancestor of main. + // Bare ancestry is not publication evidence: a freshly cut branch at main + // remains active and cannot become cleanup-ready. workspaceGitDo(t, repo, "branch", "feat/landed") - if merged, source := workspaceMergeStatus(repo, "feat/landed", "main"); !merged || source != "git" { - t.Fatalf("git ancestry merged not detected: merged=%v source=%s", merged, source) + if merged, source := workspaceMergeStatus(repo, "feat/landed", "main"); merged || source != "unpublished" { + t.Fatalf("bare ancestry created false completion: merged=%v source=%s", merged, source) + } + // Once durable delivery publication exists, ancestry may confirm landing. + writeCompletedDelivery(t, repo, "landed", "feat/landed") + if merged, source := workspaceMergeStatus(repo, "feat/landed", "main"); !merged || source != "git-after-publication" { + t.Fatalf("published ancestry did not confirm landing: merged=%v source=%s", merged, source) } // Not merged: branch has a commit main does not contain. workspaceGitDo(t, repo, "switch", "-c", "feat/ahead") @@ -211,7 +217,7 @@ func TestCleanupBlocksWhenNotMerged(t *testing.T) { } withWorkspaceGh(t, ghState("OPEN")) result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/open-feature", Confirm: true}) - if result.VerificationStatus != "BLOCKED" || result.Merged || !strings.Contains(result.Reason, "not merged") { + if result.VerificationStatus != "BLOCKED" || result.Merged || !strings.Contains(result.Reason, "open pull request") { t.Fatalf("expected not-merged block: %+v", result) } if !branchExists(repo, "feat/open-feature") { @@ -406,7 +412,7 @@ func writeCompletedDelivery(t *testing.T, repo, feature, headBranch string) { if err := saveDeliveryState(repo, DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: hash, ActiveIndex: 1, - Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "PUBLISHED", HeadBranch: headBranch}}, + Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "PUBLISHED", HeadBranch: headBranch, PRURL: "https://example.invalid/pr/1"}}, }); err != nil { t.Fatal(err) } diff --git a/boatstack/workspace_transition_conformance_test.go b/boatstack/workspace_transition_conformance_test.go index 442db11..d17d3b2 100644 --- a/boatstack/workspace_transition_conformance_test.go +++ b/boatstack/workspace_transition_conformance_test.go @@ -180,6 +180,7 @@ func TestWorkspaceTransitionPrecedesSchema3ApprovalAndActivation(t *testing.T) { } approvalPath := filepath.Join(filepath.Dir(destinationPlan), "approval.md") if err := RecordApproval(ApprovalRecordOptions{ + Repo: result.DestinationRepo, PlanPath: destinationPlan, OutputPath: approvalPath, ApprovedBy: "Test Human", ApprovedAt: "2026-08-09T12:00:00Z", Fingerprint: check.Fingerprint, }); err != nil { @@ -190,6 +191,7 @@ func TestWorkspaceTransitionPrecedesSchema3ApprovalAndActivation(t *testing.T) { t.Fatalf("approval did not bind destination readiness: %+v (%v)", receipt, err) } if err := ActivatePlan(ActivationOptions{ + Repo: result.DestinationRepo, PlanPath: destinationPlan, ApprovalPath: approvalPath, OutDir: filepath.Join(filepath.Dir(destinationPlan), "compiled"), OutputPath: filepath.Join(filepath.Dir(destinationPlan), "plan.lock.json"), diff --git a/boatstack/worktree_activation_guard_test.go b/boatstack/worktree_activation_guard_test.go index 997ff7c..6ebc7a5 100644 --- a/boatstack/worktree_activation_guard_test.go +++ b/boatstack/worktree_activation_guard_test.go @@ -98,7 +98,7 @@ func TestActivatePlanBlockedFromMainWorktreeAfterCut(t *testing.T) { } lock := filepath.Join(root, "plan.lock.json") - options := ActivationOptions{PlanPath: planPath, OutDir: filepath.Join(root, "compiled"), OutputPath: lock, SourceCommit: "test"} + options := ActivationOptions{Repo: root, PlanPath: planPath, OutDir: filepath.Join(root, "compiled"), OutputPath: lock, SourceCommit: "test"} err = ActivatePlan(options) if err == nil || !strings.Contains(err.Error(), "cut worktree") { t.Fatalf("expected activation blocked from the main worktree, got %v", err) diff --git a/release-notes/2026-08-10-workspace-lifecycle-authority.md b/release-notes/2026-08-10-workspace-lifecycle-authority.md new file mode 100644 index 0000000..e617e81 --- /dev/null +++ b/release-notes/2026-08-10-workspace-lifecycle-authority.md @@ -0,0 +1,7 @@ +### Preserve unpublished workspaces until their identity and lifecycle are proven + +A newly cut workspace can legitimately point at the same commit as the default branch before any feature work is committed or published. Boatstack previously treated that Git ancestry as proof that the workspace had already merged, exposed it as cleanup-ready, and could then lose the invoking linked-worktree identity when detached controller state was shared by several worktree aliases. This projection-induced lifecycle inversion created a contradictory loop: cleanup was offered before delivery began while activation refused as though it were running from the main worktree. + +Workspace completion now has one authority boundary. Managed delivery state takes precedence while work remains active, GitHub supplies publication state for unmanaged branches, and Git ancestry can confirm landing only after durable publication evidence exists. Cleanup and reaping preserve unpublished, closed-unmerged, inconsistent, and unavailable states unless the operator explicitly abandons or force-removes the workspace. The `ship` cleanup policy now means after completed publication, not immediately after cutting a branch. + +Detached plan operations also carry the invoking repository identity through approval, readiness, compilation, lock verification, and activation. A shared controller path with multiple aliases no longer selects whichever repository happens to appear first; path-only resolution reports the ambiguity, while an explicitly bound linked worktree activates normally and the existing main-worktree guard continues to refuse split-brain activation. From 26cb69d79128117d8a1e183a34b67b84572e7ad2 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 21:15:02 +0100 Subject: [PATCH 2/2] test: ignore transient git maintenance lock --- .../detached_external_config_conformance_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/boatstack/detached_external_config_conformance_test.go b/boatstack/detached_external_config_conformance_test.go index 5a0dd0b..aea2954 100644 --- a/boatstack/detached_external_config_conformance_test.go +++ b/boatstack/detached_external_config_conformance_test.go @@ -24,13 +24,19 @@ func filesystemSnapshot(t *testing.T, root string) string { t.Helper() entries := []string{} err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + relative, relativeErr := filepath.Rel(root, path) + if relativeErr != nil { + return relativeErr + } + // git maintenance creates and removes this advisory lock independently of + // the operation under test. It is not repository content and may disappear + // between WalkDir reading the directory and lstatting the entry on macOS. + if filepath.ToSlash(relative) == ".git/objects/maintenance.lock" { + return nil + } if walkErr != nil { return walkErr } - relative, err := filepath.Rel(root, path) - if err != nil { - return err - } info, err := entry.Info() if err != nil { return err