From 89278fe24c226f96d13d77a0e047a39a55b1b1b5 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 2 Aug 2026 11:49:42 +0100 Subject: [PATCH] fix(boatstack): unify detached artifact ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .../2026-08-02-detached-ownership-boundary.md | 5 + .../product-engineering-loop/attach.go | 42 ++- .../cmd/boatstack-helper/main.go | 11 +- .../product-engineering-loop/context.go | 2 +- .../product-engineering-loop/delivery.go | 14 +- .../detached_migration.go | 268 ++++++++++++++++++ .../detached_ownership_conformance_test.go | 172 +++++++++++ .../product-engineering-loop/flow_control.go | 2 +- .../product-engineering-loop/flow_tasks.go | 2 +- .../product-engineering-loop/init.go | 12 + .../insight_conformance_test.go | 2 +- .../product-engineering-loop/journey.go | 2 +- .../product-engineering-loop/mutation.go | 63 +++- .../product-engineering-loop/next.go | 28 +- .../product-engineering-loop/paths.go | 59 ++++ .../product-engineering-loop/plan.go | 62 ++-- .../product-engineering-loop/planning.go | 16 +- .../product-engineering-loop/pr.go | 76 ++--- .../product-engineering-loop/readiness.go | 2 +- .../product-engineering-loop/recovery.go | 4 +- .../references/artifacts.md | 21 ++ .../product-engineering-loop/runtime.go | 2 + .../product-engineering-loop/safety.go | 2 +- .../statemap_conformance_test.go | 26 +- 24 files changed, 779 insertions(+), 116 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-detached-ownership-boundary.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/detached_migration.go create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/detached_ownership_conformance_test.go diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-detached-ownership-boundary.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-detached-ownership-boundary.md new file mode 100644 index 000000000..8bccd6b8e --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-detached-ownership-boundary.md @@ -0,0 +1,5 @@ +### Detached workflows use one controller root + +Detached Boatstack workflows now read, write, and verify generated feature state under the same external controller root. + +Use `boatstack-helper attach --repo . --force` once to import a valid older embedded open-feature package. Boatstack verifies fingerprints and copies it atomically. It stops on conflicting packages or stale receipts and does not delete the embedded source. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/attach.go b/labs/12-product-engineering-loop/product-engineering-loop/attach.go index 7d47a0534..fbd43c8e2 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/attach.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/attach.go @@ -21,14 +21,15 @@ type AttachOptions struct { // AttachResult is the deterministic outcome of an attach request. type AttachResult struct { - SchemaVersion int `json:"schema_version"` - VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED - Mode string `json:"mode,omitempty"` - RepoID string `json:"repo_id,omitempty"` - RepoRoot string `json:"repo_root,omitempty"` - ControlRoot string `json:"control_root,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - Reason string `json:"reason"` + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED + Mode string `json:"mode,omitempty"` + RepoID string `json:"repo_id,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + ControlRoot string `json:"control_root,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + Reason string `json:"reason"` + FeatureMigrations []DetachedFeatureMigration `json:"feature_migrations,omitempty"` } func blockedAttach(reason string) AttachResult { @@ -62,12 +63,22 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { ctx := detachedContextFromIdentity(stateRoot, identity) - // Synthesize configuration from the repository (test command, default branch, - // context) exactly as embedded init does. - config := defaultConfig(root, detectTestCommand(root)) - rawConfig, err := MarshalJSON(config) + // Prefer the repository's declared source configuration during explicit + // reattachment. Falling back to discovery is valid only when no source exists. + configPath := filepath.Join(root, sourceConfigName) + config, rawConfig, err := LoadConfig(configPath) + if os.IsNotExist(err) { + config = defaultConfig(root, detectTestCommand(root)) + rawConfig, err = MarshalJSON(config) + } if err != nil { - return blockedAttach(err.Error()), nil + return blockedAttach("Boatstack could not load the repository source configuration: " + err.Error()), nil + } + imports, migrationResults, migrationErr := planDetachedFeatureImports(root, ctx) + if migrationErr != nil { + result := blockedAttach("Boatstack refused detached feature migration: " + migrationErr.Error()) + result.FeatureMigrations = migrationResults + return result, nil } // Generate the controller bundle and write it under the external control root. @@ -86,6 +97,10 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { if err := os.WriteFile(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil { return blockedAttach(err.Error()), nil } + migrationResults, err = applyDetachedFeatureImports(imports, migrationResults) + if err != nil { + return blockedAttach("Boatstack could not import embedded feature state: " + err.Error()), nil + } // Write the binding and index it in the registry. binding := DetachedBinding{ @@ -132,6 +147,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: identity.WorktreeID, + FeatureMigrations: migrationResults, Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.", }, nil } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index 3daf0b1e2..efb033d24 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -459,7 +459,7 @@ func checkPlanCommand(arguments []string) int { readinessFingerprint := "" if version, _ := check.Plan["schema_version"].(float64); version >= 3 { readiness, readinessErr := boatstack.CheckPlanReadiness(*plan) - repo, _ := boatstack.ResolveRepository(filepath.Dir(*plan)) + repo, _ := boatstack.ResolveControllerRepository(filepath.Dir(*plan)) if readinessErr != nil { boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, true, readinessErr.Error()) return fail(readinessErr) @@ -1102,7 +1102,16 @@ func doctorCommand(arguments []string) int { if err := boatstack.DoctorRepairHint(boatstack.Doctor(*repo)); err != nil { return fail(err) } + root, err := boatstack.ResolveRepository(*repo) + if err != nil { + return fail(err) + } + ctx, err := boatstack.ResolveWorkspaceContext(root) + if err != nil { + return fail(err) + } fmt.Printf("PASS: Boatstack %s installation and generated adapters are healthy\n", boatstack.Version) + fmt.Printf("SUPERVISION_MODE=%s\nCONTROLLER_ROOT=%s\nHEALTH=VERIFIED\n", ctx.Mode, ctx.ExportRoot()) hosts, err := boatstack.DoctorHookHosts(*repo) if err != nil { return fail(err) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/context.go b/labs/12-product-engineering-loop/product-engineering-loop/context.go index 87522ef67..5402e1cbe 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/context.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/context.go @@ -37,7 +37,7 @@ func ProjectOperatorContext(repoPath, operation, host string) (OperatorContext, } out := OperatorContext{ SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), - RepoRoot: root, Operation: operation, Host: host, + RepoRoot: root, ControlRoot: root, Operation: operation, Host: host, } if ctx, ok, verifyErr := detachedContextFor(root); verifyErr != nil { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go index 2fac766a7..ae2f1d347 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go @@ -560,7 +560,7 @@ func archiveDeliveryReceipt(repo, feature, sliceID, gate, observationID string) } func appendChangeObservation(repo string, observation ChangeObservation) error { - path := filepath.Join(repo, ".product-loop", "features", observation.Feature, "changes.md") + path := filepath.Join(WorkspaceFor(repo).FeatureDir(observation.Feature), "changes.md") existing, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return err @@ -577,7 +577,7 @@ func appendChangeObservation(repo string, observation ChangeObservation) error { } func nextChangeObservationID(repo, feature string, fallback int) string { - path := filepath.Join(repo, ".product-loop", "features", feature, "changes.md") + path := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "changes.md") value, err := os.ReadFile(path) if err != nil { return fmt.Sprintf("CHG-%03d", fallback) @@ -625,7 +625,7 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio evidenceHash := SHA256Bytes([]byte(strings.TrimSpace(options.Evidence))) mechanismHash := SHA256Bytes([]byte(strings.TrimSpace(options.Mechanism))) if repairClass { - changePath := filepath.Join(repo, ".product-loop", "features", options.Feature, "changes.md") + changePath := filepath.Join(WorkspaceFor(repo).FeatureDir(options.Feature), "changes.md") if prior, readErr := os.ReadFile(changePath); readErr == nil { for _, block := range strings.Split(string(prior), "\n## ") { if strings.Contains(block, "- Classification: `"+classification+"`") && @@ -905,7 +905,7 @@ func resolveAddressableSliceByBranch(state DeliveryState, branch string) (int, D } func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error { - lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json") + lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.lock.json") lockHash, err := SHA256File(lockPath) if err != nil { return fmt.Errorf("managed delivery requires its current plan lock: %w", err) @@ -1109,7 +1109,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error } evidencePath := strings.TrimSpace(options.EvidencePath) if evidencePath == "" { - evidencePath = featureEvidencePath(filepath.Join(repo, ".product-loop", "features", options.Feature)) + evidencePath = featureEvidencePath(WorkspaceFor(repo).FeatureDir(options.Feature)) } else if !filepath.IsAbs(evidencePath) { evidencePath = filepath.Join(repo, evidencePath) } @@ -1129,7 +1129,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if recorded := deliveryEvidenceGateStatus(string(evidenceValue), gateLabel, slice.ID, explicit); recorded != status { return DeliveryGateReceipt{}, fmt.Errorf("evidence ledger must mark the %s gate for delivery slice %s as %s; found %q", gate, slice.ID, status, recorded) } - relEvidence, err := repositoryRelativePath(repo, evidencePath) + relEvidence, err := repositoryRelativePath(WorkspaceFor(repo).ExportRoot(), evidencePath) if err != nil { return DeliveryGateReceipt{}, err } @@ -1451,7 +1451,7 @@ type DiscardDeliveryResult struct { // orphan, so discard-delivery must clear it. It refuses a dir carrying a // plan.lock.json (a registered, live feature) so it never touches active work. func discardOrphanFeatureArtifacts(repo, feature string) (DiscardDeliveryResult, bool, error) { - dir := filepath.Join(repo, ".product-loop", "features", feature) + dir := WorkspaceFor(repo).FeatureDir(feature) info, statErr := os.Stat(dir) if os.IsNotExist(statErr) { return DiscardDeliveryResult{}, false, nil diff --git a/labs/12-product-engineering-loop/product-engineering-loop/detached_migration.go b/labs/12-product-engineering-loop/product-engineering-loop/detached_migration.go new file mode 100644 index 000000000..39a67950b --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/detached_migration.go @@ -0,0 +1,268 @@ +package boatstack + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// DetachedFeatureMigration reports one embedded feature package considered by +// explicit attachment repair. Status is IMPORTED, UNCHANGED, CONFLICTING, or +// REJECTED; the vocabulary is stable for host adapters. +type DetachedFeatureMigration struct { + Feature string `json:"feature"` + Status string `json:"status"` + Reason string `json:"reason"` +} + +type detachedFeatureImport struct { + feature string + source string + target string +} + +var detachedImportBeforeRename func(source, temporary, target string) error + +func directoryFingerprint(root string) (string, error) { + info, err := os.Lstat(root) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", fmt.Errorf("feature package root is not a real directory: %s", root) + } + parts := []string{} + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == root { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("feature package contains a symlink: %s", relative) + } + if entry.IsDir() { + parts = append(parts, filepath.ToSlash(relative)+"/") + return nil + } + if !entry.Type().IsRegular() { + return fmt.Errorf("feature package contains a non-regular file: %s", relative) + } + hash, err := SHA256File(path) + if err != nil { + return err + } + parts = append(parts, filepath.ToSlash(relative)+"\x00"+hash) + return nil + }) + if err != nil { + return "", err + } + sort.Strings(parts) + return SHA256Bytes([]byte(joinNUL(parts))), nil +} + +func joinNUL(values []string) string { + result := "" + for index, value := range values { + if index > 0 { + result += "\x00" + } + result += value + } + return result +} + +func validateEmbeddedFeaturePackage(repo, directory, feature string) error { + check, err := CheckPlan(filepath.Join(directory, "plan.md")) + if err != nil { + return err + } + if stringValue(check.Plan["feature_id"]) != feature { + return fmt.Errorf("plan feature_id does not match directory") + } + if path := filepath.Join(directory, "approval.md"); fileExists(path) { + receipt, loadErr := LoadApprovalReceipt(path) + if loadErr != nil || receipt.Fingerprint != check.Fingerprint { + return fmt.Errorf("approval receipt fingerprint is invalid or stale") + } + } + if path := filepath.Join(directory, "autonomy.md"); fileExists(path) { + value, loadErr := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true) + if loadErr != nil { + return loadErr + } + data, marshalErr := MarshalJSON(value) + if marshalErr != nil { + return marshalErr + } + var receipt AutonomyReceipt + if decodeErr := DecodeJSON("autonomy receipt", path, data, &receipt); decodeErr != nil { + return decodeErr + } + fingerprint, fingerprintErr := autonomyFingerprint(receipt) + if fingerprintErr != nil || fingerprint != receipt.Fingerprint || receipt.Feature != feature || receipt.PlanFingerprint != check.Fingerprint { + return fmt.Errorf("autonomy receipt fingerprint is invalid or stale") + } + } + return nil +} + +func planDetachedFeatureImports(repo string, ctx WorkspaceContext) ([]detachedFeatureImport, []DetachedFeatureMigration, error) { + sourceRoot := filepath.Join(repo, productLoopDirName, "features") + entries, err := os.ReadDir(sourceRoot) + if os.IsNotExist(err) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + candidates := []string{} + for _, entry := range entries { + if entry.IsDir() && featureSlugPattern.MatchString(entry.Name()) && fileExists(filepath.Join(sourceRoot, entry.Name(), "plan.md")) { + candidates = append(candidates, entry.Name()) + } + } + selected := detachedOpenFeatureCandidates(repo, candidates) + imports := []detachedFeatureImport{} + results := []DetachedFeatureMigration{} + blocked := false + for _, entry := range entries { + feature := entry.Name() + if !selected[feature] { + continue + } + source := filepath.Join(sourceRoot, feature) + target := ctx.FeatureDir(feature) + if err := validateEmbeddedFeaturePackage(repo, source, feature); err != nil { + results = append(results, DetachedFeatureMigration{Feature: feature, Status: "REJECTED", Reason: err.Error()}) + blocked = true + continue + } + sourceHash, err := directoryFingerprint(source) + if err != nil { + return nil, results, err + } + if pathExists(target) { + targetHash, targetErr := directoryFingerprint(target) + if targetErr != nil { + return nil, results, targetErr + } + if sourceHash == targetHash { + results = append(results, DetachedFeatureMigration{Feature: feature, Status: "UNCHANGED", Reason: "Embedded and detached packages are byte-identical."}) + continue + } + results = append(results, DetachedFeatureMigration{Feature: feature, Status: "CONFLICTING", Reason: "Embedded and detached packages differ; Boatstack will not choose by recency."}) + blocked = true + continue + } + imports = append(imports, detachedFeatureImport{feature: feature, source: source, target: target}) + } + if blocked { + return nil, results, fmt.Errorf("embedded feature migration requires conflict or receipt repair") + } + return imports, results, nil +} + +// detachedOpenFeatureCandidates excludes historical packages. The current +// feature branch is authoritative when it names an embedded package; otherwise +// one active delivery or the sole package can be recovered without ambiguity. +func detachedOpenFeatureCandidates(repo string, candidates []string) map[string]bool { + selected := map[string]bool{} + branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + for _, prefix := range []string{"feat/", "fix/", "chore/", "ci/"} { + feature := strings.TrimPrefix(branch, prefix) + if feature == branch { + continue + } + for _, candidate := range candidates { + if candidate == feature { + selected[candidate] = true + return selected + } + } + } + active, _, err := scanManagedDeliveries(repo) + if err == nil && len(active) == 1 { + for _, candidate := range candidates { + if candidate == active[0] { + selected[candidate] = true + return selected + } + } + } + if len(candidates) == 1 { + selected[candidates[0]] = true + } + return selected +} + +func pathExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func copyDirectoryAtomic(source, target string) error { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + temporary, err := os.MkdirTemp(filepath.Dir(target), ".boatstack-feature-import-*") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + err = filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil || relative == "." { + return err + } + destination := filepath.Join(temporary, relative) + if entry.IsDir() { + return os.MkdirAll(destination, 0o755) + } + value, err := os.ReadFile(path) + if err != nil { + return err + } + return atomicWriteMode(destination, value, 0o644) + }) + if err != nil { + return err + } + before, err := directoryFingerprint(source) + if err != nil { + return err + } + after, err := directoryFingerprint(temporary) + if err != nil || before != after { + return fmt.Errorf("copied feature package failed fingerprint verification") + } + if detachedImportBeforeRename != nil { + if err := detachedImportBeforeRename(source, temporary, target); err != nil { + return err + } + } + return os.Rename(temporary, target) +} + +func applyDetachedFeatureImports(imports []detachedFeatureImport, results []DetachedFeatureMigration) ([]DetachedFeatureMigration, error) { + for _, planned := range imports { + if err := copyDirectoryAtomic(planned.source, planned.target); err != nil { + return results, err + } + results = append(results, DetachedFeatureMigration{Feature: planned.feature, Status: "IMPORTED", Reason: "Validated embedded package was atomically imported into detached controller state."}) + } + sort.Slice(results, func(i, j int) bool { return results[i].Feature < results[j].Feature }) + return results, nil +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/detached_ownership_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/detached_ownership_conformance_test.go new file mode 100644 index 000000000..102a52a86 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/detached_ownership_conformance_test.go @@ -0,0 +1,172 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func embeddedFeatureForDetach(t *testing.T, repo, feature string, approvalFingerprint string) string { + t.Helper() + config := testConfig() + config.Workflow.HumanPlanApproval = false + // Host activation files remain repository-owned. They are orthogonal to the + // detached generated-state invariant exercised by these fixtures. + 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.MkdirAll(filepath.Join(repo, productLoopDirName), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "project.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, "plans"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "plans", "source.md"), []byte("# Durable source plan\n"), 0o644); err != nil { + t.Fatal(err) + } + directory := filepath.Join(repo, productLoopDirName, "features", feature) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + plan := validPlan() + plan["feature_id"] = feature + plan["source_plan_path"] = "../../../plans/source.md" + writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true) + if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Accepted specification\n"), 0o644); err != nil { + t.Fatal(err) + } + if approvalFingerprint != "" { + writeApprovalReceipt(t, filepath.Join(directory, "approval.md"), approvalFingerprint) + } + return directory +} + +func TestDetachedOpenFeatureCandidatesIgnoreHistoricalPackagesOnMain(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/history.git") + selected := detachedOpenFeatureCandidates(repo, []string{"old-one", "old-two"}) + if len(selected) != 0 { + t.Fatalf("historical packages were selected on main: %v", selected) + } +} + +// control-law: detached-generated-state-has-one-resolved-owner +func TestDetachedAttachImportsFeatureAndIgnoresEmbeddedDrift(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/import.git") + source := embeddedFeatureForDetach(t, repo, "feature-one", "") + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + if len(result.FeatureMigrations) != 1 || result.FeatureMigrations[0].Status != "IMPORTED" { + t.Fatalf("migration result: %+v", result.FeatureMigrations) + } + target := WorkspaceFor(repo).FeatureDir("feature-one") + if strings.HasPrefix(target, repo+string(filepath.Separator)) || !fileExists(filepath.Join(target, "plan.md")) { + t.Fatalf("feature was not imported outside the repository: %s", target) + } + if err := os.WriteFile(filepath.Join(source, "embedded-only.md"), []byte("ignored\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "project.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := Doctor(repo); err == nil || !strings.Contains(err.Error(), "runtime provenance") { + t.Fatalf("doctor did not pass detached generated-state verification before the fixture's intentionally absent runtime: %v", err) + } + status, err := ResolveNext(repo, "") + if err != nil || status.Feature != "feature-one" || status.ObservedStage != "POLICY_READY" { + t.Fatalf("next did not use detached feature package: %+v %v", status, err) + } +} + +// control-law: detached-import-never-chooses-by-recency +func TestDetachedReattachBlocksConflictingFeaturePackages(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/conflict.git") + source := embeddedFeatureForDetach(t, repo, "feature-one", "") + first, _ := AttachDetached(AttachOptions{Repo: repo}) + if first.VerificationStatus != "VERIFIED" { + t.Fatalf("first attach: %+v", first) + } + identical, identicalErr := AttachDetached(AttachOptions{Repo: repo, Force: true}) + if identicalErr != nil || identical.VerificationStatus != "VERIFIED" || len(identical.FeatureMigrations) != 1 || identical.FeatureMigrations[0].Status != "UNCHANGED" { + t.Fatalf("identical packages were not preserved: %+v %v", identical, identicalErr) + } + if err := os.WriteFile(filepath.Join(source, "questions.md"), []byte("# changed later\n"), 0o644); err != nil { + t.Fatal(err) + } + second, err := AttachDetached(AttachOptions{Repo: repo, Force: true}) + if err != nil || second.VerificationStatus != "BLOCKED" || len(second.FeatureMigrations) != 1 || second.FeatureMigrations[0].Status != "CONFLICTING" { + t.Fatalf("conflict was not fail-closed: %+v %v", second, err) + } +} + +// control-law: detached-import-requires-current-receipt-fingerprints +func TestDetachedAttachRejectsStaleApprovalReceipt(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/stale.git") + embeddedFeatureForDetach(t, repo, "feature-one", "wrong") + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || result.VerificationStatus != "BLOCKED" || len(result.FeatureMigrations) != 1 || result.FeatureMigrations[0].Status != "REJECTED" { + t.Fatalf("stale receipt was imported: %+v %v", result, err) + } +} + +// control-law: detached-import-is-atomic-before-directory-promotion +func TestDetachedImportInterruptionLeavesNoPartialTarget(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/interrupted.git") + embeddedFeatureForDetach(t, repo, "feature-one", "") + old := detachedImportBeforeRename + detachedImportBeforeRename = func(_, _, _ string) error { return fmt.Errorf("injected interruption") } + t.Cleanup(func() { detachedImportBeforeRename = old }) + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || result.VerificationStatus != "BLOCKED" { + t.Fatalf("interrupted attach: %+v %v", result, err) + } + identity, _ := repoIdentity(repo) + ctx := detachedContextFromIdentity(filepath.Join(os.Getenv(stateRootEnv), "boatstack"), identity) + if fileExists(ctx.FeatureDir("feature-one")) { + t.Fatal("interrupted import exposed a partial feature directory") + } +} + +// control-law: detached-activation-writes-and-verifies-one-feature-root +func TestDetachedActivationUsesCanonicalFeatureDirectory(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/activate.git") + embeddedFeatureForDetach(t, repo, "feature-one", "") + result, _ := AttachDetached(AttachOptions{Repo: repo}) + if result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v", result) + } + directory := WorkspaceFor(repo).FeatureDir("feature-one") + if resolved, resolveErr := ResolveControllerRepository(directory); resolveErr != nil || canonicalizeExistingAncestor(resolved) != canonicalizeExistingAncestor(repo) { + t.Fatalf("detached feature owner mismatch: directory=%s resolved=%s repo=%s err=%v", directory, resolved, repo, resolveErr) + } + resolved, _ := ResolveControllerRepository(directory) + if ctx, ctxErr := ResolveWorkspaceContext(resolved); ctxErr != nil || ctx.Mode != SupervisionDetached { + t.Fatalf("resolved owner lost detached context: resolved=%s ctx=%+v err=%v", resolved, ctx, ctxErr) + } + err := ActivatePlan(ActivationOptions{ + PlanPath: filepath.Join(directory, "plan.md"), OutDir: filepath.Join(directory, "compiled"), + OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: "test", + }) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"compiled/tasks.json", "compiled/journey-oracles.json", "plan.lock.json"} { + if !fileExists(filepath.Join(directory, filepath.FromSlash(path))) { + t.Errorf("missing detached activation artifact %s", path) + } + if fileExists(filepath.Join(repo, productLoopDirName, "features", "feature-one", filepath.FromSlash(path))) { + t.Errorf("activation artifact leaked into embedded package: %s", path) + } + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go index 0e90b21b5..154df84c3 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go @@ -375,7 +375,7 @@ func prescribeCommand(repo, feature string, status NextStatus, transition delive // planningFeatureDir is the single joined form of a feature's planning // directory used by the prescription layer and the solution-set enumerator. func planningFeatureDir(repo, feature string) string { - return filepath.Join(repo, ".product-loop", "features", feature) + return WorkspaceFor(repo).FeatureDir(feature) } func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, string) { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_tasks.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_tasks.go index 9a72ffefa..cfd35b83a 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_tasks.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_tasks.go @@ -74,7 +74,7 @@ func FlowTasksForActiveSlice(repo, feature string) (FlowTasks, error) { // the graph is absent or malformed, so the caller stays Unresolved rather than // ordering a guess. func readCompiledTasks(repo, feature string) ([]FlowTask, bool) { - directory := filepath.Join(repo, ".product-loop", "features", feature) + directory := WorkspaceFor(repo).FeatureDir(feature) tasksPath := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json") raw, err := os.ReadFile(tasksPath) if err != nil { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/init.go b/labs/12-product-engineering-loop/product-engineering-loop/init.go index d5f047cbd..3c86c9d8a 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/init.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/init.go @@ -599,6 +599,18 @@ func RunInit(options InitOptions) (returnErr error) { if writeErr != nil { return writeErr } + // An attached repository still receives the reviewed embedded update package, + // but its active controller reads the detached projection. Refresh that same + // bundle under the resolved export root before any smoke check; feature state + // is outside the bundle key set and remains untouched. + if ctx := WorkspaceFor(repo); ctx.Mode == SupervisionDetached { + if err := writeExport(ctx.ExportRoot(), bundle.Files, nil); err != nil { + return fmt.Errorf("refresh detached controller bundle: %w", err) + } + if err := atomicWriteMode(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil { + return fmt.Errorf("refresh detached source configuration: %w", err) + } + } if err := initCheckpoint("export-written"); err != nil { return fmt.Errorf("initialization checkpoint export-written: %w", err) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go index 7f93575bb..c051c17a3 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go @@ -209,7 +209,7 @@ func TestInsightCaptureRejectsStaleInputAndSupportsEmbeddedMode(t *testing.T) { func installInsightDelivery(t *testing.T, repo string, ctx WorkspaceContext, feature string, terminal DeliveryTerminal) { t.Helper() - directory := filepath.Join(repo, ".product-loop", "features", feature) + directory := ctx.FeatureDir(feature) if err := os.MkdirAll(directory, 0o755); err != nil { t.Fatal(err) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/journey.go b/labs/12-product-engineering-loop/product-engineering-loop/journey.go index 512e26eff..9a84eb879 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/journey.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/journey.go @@ -66,7 +66,7 @@ func CompileJourneyManifest(plan map[string]any) ([]byte, error) { } func journeyManifestPath(repo, feature string) string { - directory := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "features", feature) + directory := WorkspaceFor(repo).FeatureDir(feature) return featureArtifactPath(directory, filepath.Join("compiled", "journey-oracles.json"), "journey-oracles.json") } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/mutation.go b/labs/12-product-engineering-loop/product-engineering-loop/mutation.go index fe944b822..ade065621 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/mutation.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/mutation.go @@ -114,6 +114,7 @@ type MutationReceipt struct { Changes []MutationFileChange `json:"changes"` Authority string `json:"authority_sha256,omitempty"` RecordedAt string `json:"recorded_at"` + Root string `json:"root,omitempty"` } func mutationDirectory(repo string) (string, error) { @@ -238,12 +239,15 @@ func currentImage(native string) (string, bool, error) { // an identical proposal replays and a different proposal is a distinct mutation. // It deliberately excludes the transient Authority.Observed value so a rejected // authority check does not fork the identity of the corrected retry. -func mutationIdentity(m MutationSet, ops []resolvedOperation) string { +func mutationIdentity(m MutationSet, ops []resolvedOperation, root string) string { parts := make([]string, 0, len(ops)) for _, op := range ops { parts = append(parts, op.rel+"\x1f"+op.candidateHash+"\x1f"+m.Base[op.rel]) } sort.Strings(parts) + if root != "" { + parts = append(parts, "root\x1f"+root) + } fingerprint := SHA256Bytes([]byte(strings.Join(parts, "\x1e"))) target := SHA256Bytes([]byte(strings.Join(sortedScope(m.Scope), "\x1e"))) return operationID("mutation\x00"+strings.TrimSpace(m.Kind), target, fingerprint) @@ -264,7 +268,7 @@ type resolvedOperation struct { absent bool } -func (m MutationSet) resolve(repo string) ([]resolvedOperation, error) { +func (m MutationSet) resolve(root string) ([]resolvedOperation, error) { if strings.TrimSpace(m.Protocol) != MutationProtocol { return nil, fmt.Errorf("mutation protocol must be %s", MutationProtocol) } @@ -292,11 +296,11 @@ func (m MutationSet) resolve(repo string) ([]resolvedOperation, error) { return nil, fmt.Errorf("mutation names %s more than once", rel) } seen[rel] = true - native, err := resolveRepositoryRelativePath(repo, rel) + native, err := resolveRepositoryRelativePath(root, rel) if err != nil { return nil, err } - if err := rejectSymlinkComponents(repo, native); err != nil { + if err := rejectSymlinkComponents(root, native); err != nil { return nil, err } if op.Absent { @@ -324,11 +328,35 @@ func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) { if err != nil { return MutationReceipt{}, err } - ops, err := m.resolve(repo) + return applyMutationAt(repo, repo, "", m) +} + +// ApplyControllerMutation promotes controller-owned artifacts beneath the +// active WorkspaceContext export root while retaining receipts in the owning +// repository's Git-common ledger. +func ApplyControllerMutation(repoPath string, m MutationSet) (MutationReceipt, error) { + repo, err := ResolveRepository(repoPath) if err != nil { return MutationReceipt{}, err } - id := mutationIdentity(m, ops) + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return MutationReceipt{}, err + } + root := ctx.ExportRoot() + rootMarker := "" + if filepath.Clean(root) != filepath.Clean(repo) { + rootMarker = root + } + return applyMutationAt(repo, root, rootMarker, m) +} + +func applyMutationAt(repo, root, rootMarker string, m MutationSet) (MutationReceipt, error) { + ops, err := m.resolve(root) + if err != nil { + return MutationReceipt{}, err + } + id := mutationIdentity(m, ops, rootMarker) authorityHash := SHA256Bytes([]byte(m.Authority.Expected)) var result MutationReceipt @@ -349,6 +377,7 @@ func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) { SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, Kind: m.Kind, Status: "REJECTED", Reason: "outdated supervisor authority", Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + Root: rootMarker, } return ErrMutationOutdatedAuthority } @@ -367,6 +396,7 @@ func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) { SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, Kind: m.Kind, Status: "REJECTED", Reason: "stale base artifact: " + op.rel, Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + Root: rootMarker, } return ErrMutationStaleBase } @@ -383,6 +413,7 @@ func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) { SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, Kind: m.Kind, Status: "REJECTED", Reason: "invalid candidate: " + checkErr.Error(), Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + Root: rootMarker, } return fmt.Errorf("%w: %v", ErrMutationInvalidCandidate, checkErr) } @@ -447,6 +478,7 @@ func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) { SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, Kind: m.Kind, Status: "ROLLED_BACK", Reason: "post-write verification failed: " + checkErr.Error(), Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + Root: rootMarker, } return fmt.Errorf("%w: %v", ErrMutationVerificationFailed, checkErr) } @@ -456,6 +488,7 @@ func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) { SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, Kind: m.Kind, Status: "APPLIED", Scope: sortedScope(m.Scope), Changes: changes, Authority: authorityHash, RecordedAt: operationTimestamp(), + Root: rootMarker, } return saveMutationReceipt(repo, result) }) @@ -506,8 +539,16 @@ func rollbackMutation(promoted []promotedChange) { // on-disk truth, which is the precondition for treating a repeat call as a // no-op replay rather than a fresh mutation. func receiptStillApplied(repo string, receipt MutationReceipt) bool { + root := repo + if receipt.Root != "" { + ctx, err := ResolveWorkspaceContext(repo) + if err != nil || filepath.Clean(receipt.Root) != filepath.Clean(ctx.ExportRoot()) { + return false + } + root = receipt.Root + } for _, change := range receipt.Changes { - native, err := resolveRepositoryRelativePath(repo, change.Path) + native, err := resolveRepositoryRelativePath(root, change.Path) if err != nil { return false } @@ -582,7 +623,13 @@ func UndoMutation(repoPath, mutationID string) (MutationReceipt, error) { Base: base, Operations: ops, } - undone, applyErr := ApplyMutation(repo, inverse) + var undone MutationReceipt + var applyErr error + if receipt.Root != "" { + undone, applyErr = ApplyControllerMutation(repo, inverse) + } else { + undone, applyErr = ApplyMutation(repo, inverse) + } if errors.Is(applyErr, ErrMutationStaleBase) { return undone, fmt.Errorf("%w: %s diverged from its recorded post-image", ErrMutationConflict, receipt.MutationID) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index ffcf9bbd3..f601448ef 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -16,6 +16,8 @@ const nextStatusSchemaVersion = 2 // adapters may present them as context, but they are not workflow evidence. type NextStatus struct { SchemaVersion int `json:"schema_version"` + SupervisionMode string `json:"supervision_mode"` + ControllerRoot string `json:"controller_root"` VerificationStatus string `json:"verification_status"` Feature string `json:"feature,omitempty"` ActiveSlice string `json:"active_slice,omitempty"` @@ -46,7 +48,7 @@ func decorateAutonomyStatus(repo string, status NextStatus) NextStatus { if status.Feature == "" { return status } - path := filepath.Join(repo, ".product-loop", "features", status.Feature, "autonomy.md") + path := filepath.Join(WorkspaceFor(repo).FeatureDir(status.Feature), "autonomy.md") value, err := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true) if err != nil { return status @@ -72,7 +74,7 @@ func blockedNextStatus(stage, operation, reason string, ambiguity ...string) Nex } func featurePlanCandidates(repo string) ([]string, error) { - root := filepath.Join(repo, ".product-loop", "features") + root := WorkspaceFor(repo).FeatureRoot() entries, err := os.ReadDir(root) if os.IsNotExist(err) { return nil, nil @@ -110,7 +112,7 @@ func featurePlanCandidates(repo string) ([]string, error) { } func orphanedFeatureArtifacts(repo string) ([]string, error) { - root := filepath.Join(repo, ".product-loop", "features") + root := WorkspaceFor(repo).FeatureRoot() entries, err := os.ReadDir(root) if os.IsNotExist(err) { return nil, nil @@ -154,7 +156,7 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { status.NextOperation = "review-gate" status.Reason = "The active delivery slice has current test evidence and still requires review." case StatusReviewPassed: - previewPath := filepath.Join(repo, ".product-loop", "features", feature, "pr.md") + previewPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "pr.md") if preview, previewErr := ParsePRPreview(previewPath); previewErr == nil && preview.Feature == feature && preview.SliceID == slice.ID { status.ObservedStage = "PR_PREVIEW" status.Reason = "A reviewer-ready PR preview exists for the reviewed active slice and must be reconfirmed through the ship gate." @@ -307,11 +309,23 @@ func completedManagedStates(repo string) ([]DeliveryState, error) { // ResolveNext performs bounded, read-only state inspection. Published states // use the recorded PR identity when GitHub is available; conversation and // process history are never treated as evidence. -func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { +func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr error) { repo, err := ResolveRepository(repoPath) if err != nil { return NextStatus{}, err } + defer func() { + ctx, ok, verifyErr := detachedContextFor(repo) + if verifyErr != nil { + result.SupervisionMode = string(SupervisionDetached) + return + } + if !ok { + ctx = embeddedWorkspace(repo) + } + result.SupervisionMode = string(ctx.Mode) + result.ControllerRoot = ctx.ExportRoot() + }() base := NextStatus{SchemaVersion: nextStatusSchemaVersion} if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { base.VerificationStatus = "UNVERIFIED" @@ -411,7 +425,7 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { } if len(candidates) == 1 { feature := candidates[0] - directory := filepath.Join(repo, ".product-loop", "features", feature) + directory := WorkspaceFor(repo).FeatureDir(feature) base.VerificationStatus = "VERIFIED" base.Feature = feature policyReady := !config.Workflow.HumanPlanApproval @@ -521,6 +535,8 @@ func FormatNextStatus(status NextStatus) string { parts := []string{ "Boatstack stage: " + status.ObservedStage, "Verification: " + status.VerificationStatus, + "Supervision: " + status.SupervisionMode, + "Controller root: " + status.ControllerRoot, } if status.Feature != "" { parts = append(parts, "Feature: "+status.Feature) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/paths.go b/labs/12-product-engineering-loop/product-engineering-loop/paths.go index 6f587a6a7..1551334a2 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/paths.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/paths.go @@ -3,6 +3,7 @@ package boatstack import ( "fmt" "path/filepath" + "strings" "sync" ) @@ -161,6 +162,41 @@ func embeddedWorkspace(repo string) WorkspaceContext { return WorkspaceContext{Mode: SupervisionEmbedded, RepoRoot: repo, controlRoot: repo} } +func pathWithin(root, target string) bool { + root = canonicalizeExistingAncestor(root) + target = canonicalizeExistingAncestor(target) + relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(target)) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +// ResolveControllerRepository maps either a product path or a detached +// controller path back to the repository whose identity owns it. This is the +// inverse boundary required by plan validation after FeatureDir moves outside +// the Git worktree. +func ResolveControllerRepository(path string) (string, error) { + stateRoot, err := detachedStateRoot() + if err != nil { + return "", err + } + registry, err := loadRegistry(stateRoot) + if err != nil { + return "", err + } + for repo := range registry.Repositories { + ctx, ok, verifyErr := detachedContextFor(repo) + if !ok || verifyErr != nil { + continue + } + if pathWithin(ctx.ExportRoot(), path) { + return repo, nil + } + } + 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) +} + var ( workspaceCacheMu sync.Mutex workspaceCache = map[string]WorkspaceContext{} @@ -191,6 +227,29 @@ func (w WorkspaceContext) GeneratedRoot() string { return filepath.Join(w.configBase(), productLoopDirName) } +// ExportRoot is the base beneath which generated bundle paths are materialized. +// Bundle keys include .product-loop and host-adapter directories, so callers +// must pass this root — never RepoRoot — to export write/check operations. +func (w WorkspaceContext) ExportRoot() string { + return w.configBase() +} + +// FeatureRoot owns generated planning and delivery artifacts. Source plans are +// product inputs and remain at their declared repository paths; everything +// compiled from them lives below this controller-owned root. +func (w WorkspaceContext) FeatureRoot() string { + return filepath.Join(w.GeneratedRoot(), "features") +} + +// FeatureDir returns one validated feature package directory. Invalid slugs +// return an empty path so no caller can accidentally escape the ownership root. +func (w WorkspaceContext) FeatureDir(feature string) string { + if !featureSlugPattern.MatchString(feature) { + return "" + } + return filepath.Join(w.FeatureRoot(), feature) +} + // ProjectConfigPath is the generated runtime configuration copy that runtime // operations read. Embedded: /.product-loop/project.json. func (w WorkspaceContext) ProjectConfigPath() string { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/plan.go b/labs/12-product-engineering-loop/product-engineering-loop/plan.go index 176d3aa77..6f597c85d 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan.go @@ -295,7 +295,25 @@ func SourcePlanForStructuredPlan(planPath string) (string, error) { return "", fmt.Errorf("source_plan_path is required") } if !filepath.IsAbs(sourcePlan) { - sourcePlan = filepath.Join(filepath.Dir(planPath), sourcePlan) + planRelative := filepath.Clean(filepath.Join(filepath.Dir(planPath), sourcePlan)) + if fileExists(planRelative) { + return planRelative, nil + } + if repo, repoErr := ResolveControllerRepository(filepath.Dir(planPath)); repoErr == nil { + repoRelative := filepath.Clean(filepath.Join(repo, sourcePlan)) + if fileExists(repoRelative) { + return repoRelative, nil + } + // Packages imported from the embedded layout retain their original + // relative source-plan reference. Resolve it against the virtual + // embedded feature directory without rewriting fingerprinted bytes. + feature := stringValue(plan["feature_id"]) + legacyRelative := filepath.Clean(filepath.Join(repo, productLoopDirName, "features", feature, sourcePlan)) + if fileExists(legacyRelative) { + return legacyRelative, nil + } + } + sourcePlan = planRelative } return filepath.Clean(sourcePlan), nil } @@ -346,7 +364,7 @@ func CheckPlan(planPath string) (PlanCheck, error) { if err != nil { return PlanCheck{}, err } - repoRoot, _ := ResolveRepository(filepath.Dir(planPath)) + repoRoot, _ := ResolveControllerRepository(filepath.Dir(planPath)) opts := &ValidatePlanOptions{ PlanPath: planPath, RepoRoot: repoRoot, @@ -790,7 +808,7 @@ func canonicalizeExistingAncestor(path string) string { } func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error { - repoRoot, err := ResolveRepository(filepath.Dir(planPath)) + repoRoot, err := ResolveControllerRepository(filepath.Dir(planPath)) if err != nil { return err } @@ -812,7 +830,7 @@ func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error { Operations: artifacts.ops, PostCheck: artifacts.postCheck, } - if _, err := ApplyMutation(repoRoot, mutation); err != nil { + if _, err := ApplyControllerMutation(repoRoot, mutation); err != nil { return err } return nil @@ -875,19 +893,24 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( // directory (and one or more of its parents) may not exist yet, so resolve the // deepest existing ancestor and rejoin the not-yet-created remainder. absOut = canonicalizeExistingAncestor(absOut) - relTasks, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "tasks.json")) + workspace, err := ResolveWorkspaceContext(repoRoot) if err != nil { return compiledArtifacts{}, err } - relMatrix, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "test-matrix.json")) + artifactRoot := workspace.ExportRoot() + relTasks, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "tasks.json")) if err != nil { return compiledArtifacts{}, err } - relEvidence, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "evidence.md")) + relMatrix, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "test-matrix.json")) if err != nil { return compiledArtifacts{}, err } - relJourney, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "journey-oracles.json")) + relEvidence, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "evidence.md")) + if err != nil { + return compiledArtifacts{}, err + } + relJourney, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "journey-oracles.json")) if err != nil { return compiledArtifacts{}, err } @@ -898,13 +921,13 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( scope := []string{relTasks, relMatrix, relEvidence, relJourney} base := map[string]string{} for _, rel := range scope { - if hash, hashErr := SHA256File(filepath.Join(repoRoot, filepath.FromSlash(rel))); hashErr == nil { + if hash, hashErr := SHA256File(filepath.Join(artifactRoot, filepath.FromSlash(rel))); hashErr == nil { base[rel] = hash } } postCheck := func() error { for _, rel := range []string{relTasks, relMatrix, relJourney} { - value, readErr := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(rel))) + value, readErr := os.ReadFile(filepath.Join(artifactRoot, filepath.FromSlash(rel))) if readErr != nil { return readErr } @@ -912,7 +935,7 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( return validateErr } } - info, statErr := os.Stat(filepath.Join(repoRoot, filepath.FromSlash(relEvidence))) + info, statErr := os.Stat(filepath.Join(artifactRoot, filepath.FromSlash(relEvidence))) if statErr != nil || info.Size() == 0 { return fmt.Errorf("promoted evidence ledger is missing or empty") } @@ -1051,7 +1074,7 @@ func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, er return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: readiness fingerprint changed after approval") } } - repo, err := ResolveRepository(filepath.Dir(planCheck.PlanPath)) + repo, err := ResolveControllerRepository(filepath.Dir(planCheck.PlanPath)) if err != nil { return ApprovalReceipt{}, err } @@ -1083,7 +1106,7 @@ func ActivatePlan(options ActivationOptions) error { if err != nil { return err } - repo, err := ResolveRepository(filepath.Dir(options.PlanPath)) + repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) if err != nil { return err } @@ -1218,7 +1241,7 @@ func ActivatePlan(options ActivationOptions) error { return fmt.Errorf("pre-activation readiness drifted before the immutable plan lock could be created") } } - if _, err := ApplyMutation(repo, mutation); err != nil { + if _, err := ApplyControllerMutation(repo, mutation); err != nil { return err } return initializeDeliveryState(repo, stringValue(check.Plan["feature_id"]), options.PlanPath, options.OutputPath) @@ -1244,7 +1267,12 @@ func activationMutation(repoRoot string, options ActivationOptions, structuredPl return MutationSet{}, err } absLock = canonicalizeExistingAncestor(absLock) - relLock, err := repositoryRelativePath(repoRoot, absLock) + workspace, err := ResolveWorkspaceContext(repoRoot) + if err != nil { + return MutationSet{}, err + } + artifactRoot := workspace.ExportRoot() + relLock, err := repositoryRelativePath(artifactRoot, absLock) if err != nil { return MutationSet{}, err } @@ -1253,7 +1281,7 @@ func activationMutation(repoRoot string, options ActivationOptions, structuredPl for rel, hash := range artifacts.base { base[rel] = hash } - if hash, hashErr := SHA256File(filepath.Join(repoRoot, filepath.FromSlash(relLock))); hashErr == nil { + if hash, hashErr := SHA256File(filepath.Join(artifactRoot, filepath.FromSlash(relLock))); hashErr == nil { base[relLock] = hash } ops := append(append([]MutationOperation{}, artifacts.ops...), MutationOperation{Path: relLock, Candidate: lockBytes}) @@ -1454,7 +1482,7 @@ func CheckApprovalLock(options ApprovalOptions) error { if fingerprint, fingerprintErr := readinessFingerprint(storedReadiness); fingerprintErr != nil || fingerprint != storedReadiness.Fingerprint { mismatches = append(mismatches, "readiness_fingerprint") } - repo, repoErr := ResolveRepository(filepath.Dir(options.PlanPath)) + 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/labs/12-product-engineering-loop/product-engineering-loop/planning.go b/labs/12-product-engineering-loop/product-engineering-loop/planning.go index 813812789..97cc3a004 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/planning.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/planning.go @@ -163,7 +163,7 @@ func PlanningBaselineForPlan(planPath string) (PlanningBaseline, error) { if err != nil { return PlanningBaseline{}, err } - repo, err := ResolveRepository(filepath.Dir(planPath)) + repo, err := ResolveControllerRepository(filepath.Dir(planPath)) if err != nil { return PlanningBaseline{}, err } @@ -238,14 +238,18 @@ func WritePlanningArtifact(options PlanningWriteOptions) (string, error) { if err != nil { return "", err } - destination := filepath.Join(repo, ".product-loop", "features", options.Feature, options.Artifact) - if err := rejectSymlinkComponents(repo, destination); err != nil { + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return "", err + } + destination := filepath.Join(ctx.FeatureDir(options.Feature), options.Artifact) + if err := rejectSymlinkComponents(ctx.ExportRoot(), destination); err != nil { return "", err } if err := atomicWrite(destination, options.Content); err != nil { return "", err } - relative, err := filepath.Rel(repo, destination) + relative, err := filepath.Rel(ctx.ExportRoot(), destination) if err != nil { return "", err } @@ -267,7 +271,7 @@ func RecordApproval(options ApprovalRecordOptions) error { 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 := ResolveRepository(filepath.Dir(options.PlanPath)) + repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) if err != nil { return err } @@ -360,7 +364,7 @@ func Doctor(repoPath string) error { if err != nil { return err } - if err := CheckExport(repo, bundle.Files); err != nil { + if err := CheckExport(WorkspaceFor(repo).ExportRoot(), bundle.Files); err != nil { return err } // Best-effort hygiene: drop the orphaned clone-shared operation ledger left by diff --git a/labs/12-product-engineering-loop/product-engineering-loop/pr.go b/labs/12-product-engineering-loop/product-engineering-loop/pr.go index b2c1484c4..b910f0b2d 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/pr.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/pr.go @@ -36,44 +36,44 @@ type PRSource struct { } type PRContext struct { - SchemaVersion int `json:"schema_version"` - Mode string `json:"mode"` - Feature string `json:"feature,omitempty"` - SliceID string `json:"slice_id,omitempty"` - SliceIndex int `json:"slice_index,omitempty"` - TotalSlices int `json:"total_slices,omitempty"` - BaseBranch string `json:"base_branch"` - HeadBranch string `json:"head_branch"` - BaseCommit string `json:"base_commit"` - MergeBaseCommit string `json:"merge_base_commit"` - HeadCommit string `json:"head_commit"` - ProductDiffSHA256 string `json:"product_diff_sha256"` - ContextFingerprint string `json:"context_fingerprint"` - ChangedFiles []string `json:"changed_files"` - Commits []string `json:"commits"` - DiffStat string `json:"diff_stat"` - ContextPaths []string `json:"context_paths,omitempty"` - ProjectCommands map[string]string `json:"project_commands,omitempty"` - HighRiskFiles []string `json:"high_risk_files,omitempty"` - GateStatus map[string]string `json:"gate_status,omitempty"` - SafetyStatus string `json:"safety_status"` - SafetyFindings []SafetyFinding `json:"safety_findings,omitempty"` - PRVisualEvidencePolicy string `json:"pr_visual_evidence_policy"` - PRVisualEvidenceStatus string `json:"pr_visual_evidence_status"` - PRVisualEvidenceCount int `json:"pr_visual_evidence_count"` - PRVisualEvidenceFingerprint string `json:"pr_visual_evidence_fingerprint"` - PRVisualEvidenceRelevance string `json:"pr_visual_evidence_relevance"` - PRVisualEvidenceSource string `json:"pr_visual_evidence_source"` + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Feature string `json:"feature,omitempty"` + SliceID string `json:"slice_id,omitempty"` + SliceIndex int `json:"slice_index,omitempty"` + TotalSlices int `json:"total_slices,omitempty"` + BaseBranch string `json:"base_branch"` + HeadBranch string `json:"head_branch"` + BaseCommit string `json:"base_commit"` + MergeBaseCommit string `json:"merge_base_commit"` + HeadCommit string `json:"head_commit"` + ProductDiffSHA256 string `json:"product_diff_sha256"` + ContextFingerprint string `json:"context_fingerprint"` + ChangedFiles []string `json:"changed_files"` + Commits []string `json:"commits"` + DiffStat string `json:"diff_stat"` + ContextPaths []string `json:"context_paths,omitempty"` + ProjectCommands map[string]string `json:"project_commands,omitempty"` + HighRiskFiles []string `json:"high_risk_files,omitempty"` + GateStatus map[string]string `json:"gate_status,omitempty"` + SafetyStatus string `json:"safety_status"` + SafetyFindings []SafetyFinding `json:"safety_findings,omitempty"` + PRVisualEvidencePolicy string `json:"pr_visual_evidence_policy"` + PRVisualEvidenceStatus string `json:"pr_visual_evidence_status"` + PRVisualEvidenceCount int `json:"pr_visual_evidence_count"` + PRVisualEvidenceFingerprint string `json:"pr_visual_evidence_fingerprint"` + PRVisualEvidenceRelevance string `json:"pr_visual_evidence_relevance"` + PRVisualEvidenceSource string `json:"pr_visual_evidence_source"` // PRVisualEvidencePolicySource is "configured", or "plan-escalated" when // a plan-approved visual decision lifts suggest to require semantics. - PRVisualEvidencePolicySource string `json:"pr_visual_evidence_policy_source,omitempty"` + PRVisualEvidencePolicySource string `json:"pr_visual_evidence_policy_source,omitempty"` // PRVisualEvidenceCaptureDetail explains why automatic capture could not // produce current evidence. Deliberately outside the context fingerprint: // a flaky harness message must not destabilize preview equality. - PRVisualEvidenceCaptureDetail string `json:"pr_visual_evidence_capture_detail,omitempty"` - PRVisualEvidence *PRVisualEvidenceManifest `json:"pr_visual_evidence,omitempty"` - Sources []PRSource `json:"sources,omitempty"` - PreviewPath string `json:"preview_path"` + PRVisualEvidenceCaptureDetail string `json:"pr_visual_evidence_capture_detail,omitempty"` + PRVisualEvidence *PRVisualEvidenceManifest `json:"pr_visual_evidence,omitempty"` + Sources []PRSource `json:"sources,omitempty"` + PreviewPath string `json:"preview_path"` } type PRPreview struct { @@ -95,7 +95,7 @@ type PRPreview struct { } func planVisualDecision(repo, feature string) (string, string, []PRVisualScenario, error) { - plan, err := LoadPlan(filepath.Join(repo, ".product-loop", "features", feature, "plan.md")) + plan, err := LoadPlan(filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.md")) if err != nil { return "unresolved", "managed-plan", nil, err } @@ -559,7 +559,7 @@ func featureEvidencePath(featureDir string) string { } func managedPRSources(repo, feature string) ([]PRSource, map[string]string, error) { - directory := filepath.Join(repo, ".product-loop", "features", feature) + directory := WorkspaceFor(repo).FeatureDir(feature) planPath := filepath.Join(directory, "plan.md") approvalPath := filepath.Join(directory, "approval.md") lockPath := filepath.Join(directory, "plan.lock.json") @@ -1199,7 +1199,7 @@ func PublishPR(options PRPublishOptions) (string, error) { if context.Feature == "" { return "", fmt.Errorf("autonomous publication requires a managed feature") } - planPath := filepath.Join(repo, ".product-loop", "features", context.Feature, "plan.md") + planPath := filepath.Join(WorkspaceFor(repo).FeatureDir(context.Feature), "plan.md") check, checkErr := CheckPlan(planPath) if checkErr != nil { return "", checkErr @@ -1356,7 +1356,7 @@ func PublishPR(options PRPublishOptions) (string, error) { } func extractSystemicBoundaries(repo, feature string) error { - lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json") + lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.lock.json") value, err := os.ReadFile(lockPath) if err != nil { return nil // if it doesn't exist, ignore @@ -1369,7 +1369,7 @@ func extractSystemicBoundaries(repo, feature string) error { if !ok || len(boundaries) == 0 { return nil } - outPath := filepath.Join(repo, ".product-loop", "verified-boundaries.md") + outPath := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "verified-boundaries.md") f, err := os.OpenFile(outPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err diff --git a/labs/12-product-engineering-loop/product-engineering-loop/readiness.go b/labs/12-product-engineering-loop/product-engineering-loop/readiness.go index e1360fcac..616a5b233 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/readiness.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/readiness.go @@ -39,7 +39,7 @@ func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) { if err != nil { return ReadinessReceipt{}, err } - repo, err := ResolveRepository(filepath.Dir(planPath)) + repo, err := ResolveControllerRepository(filepath.Dir(planPath)) if err != nil { return ReadinessReceipt{}, err } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go index f8bf39949..95e5e19b6 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go @@ -552,7 +552,7 @@ func RepairState(repoPath, feature string) (RepairStateResult, error) { } eligible := []string{} for _, candidate := range candidates { - if _, checkErr := CheckPlan(filepath.Join(repo, ".product-loop", "features", candidate, "plan.md")); checkErr != nil { + if _, checkErr := CheckPlan(filepath.Join(WorkspaceFor(repo).FeatureDir(candidate), "plan.md")); checkErr != nil { eligible = append(eligible, candidate) } } @@ -578,7 +578,7 @@ func RepairState(repoPath, feature string) (RepairStateResult, error) { return RepairStateResult{}, fmt.Errorf("invalid feature slug: %q", feature) } - directory := filepath.Join(repo, ".product-loop", "features", feature) + directory := WorkspaceFor(repo).FeatureDir(feature) planPath := filepath.Join(directory, "plan.md") if !fileExists(planPath) { return refusedRepairState(feature, "no plan.md exists for this feature; nothing to repair"), nil diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md index f62d1d820..cb42c8ffa 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md @@ -159,6 +159,27 @@ clone, `external` outside the repository (Detached Supervision). | detached-registry | detached | external | attach, detach | | detached-repositories | detached | external | attach, detach, activate | +In detached mode, `WorkspaceContext` remaps the controller bundle and every +feature package to the external repository control root. Source plans stay at +their declared repository paths. Installation, update, hydration, host-hook, +and managed-worktree paths remain repository-owned. + +Direct `.product-loop` literals are frozen by a conformance inventory. Each +production file is classified as one of: canonical owner, controller bundle or +syntax, embedded installation, product-diff syntax, policy syntax, repository +workspace, or user guidance. A new unclassified literal fails the test. Runtime +controller reads and writes must use `WorkspaceContext.GeneratedRoot`, +`FeatureRoot`, or `FeatureDir`. + +## Detached feature reattachment + +Run `boatstack-helper attach --repo . --force` to reattach an older embedded +open-feature package. Boatstack verifies the plan and approval or autonomy +fingerprints, copies the package atomically, and verifies the copied hash. The +machine result is `IMPORTED`, `UNCHANGED`, `CONFLICTING`, or `REJECTED`. +Conflicts and stale receipts fail closed. Boatstack never chooses by recency and +never deletes the embedded source package. + ## Templates Copy only the templates required for the current slice from `assets/templates/`. Do not create empty ceremony. The feature spec, question ledger, test plan, gap ledger, and evidence ledger are the usual minimum for material product work. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go index 7954ecb24..75f421129 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go @@ -163,6 +163,8 @@ func SHA256File(path string) (string, error) { } func repositoryRelativePath(repo, target string) (string, error) { + repo = canonicalizeExistingAncestor(repo) + target = canonicalizeExistingAncestor(target) relative, err := filepath.Rel(repo, target) if err != nil { return "", fmt.Errorf("cannot make path repository-relative: %w", err) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety.go b/labs/12-product-engineering-loop/product-engineering-loop/safety.go index f4f8cf969..7f5725fec 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety.go @@ -487,7 +487,7 @@ func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) { return SafetyFinding{}, false } if len(candidates) == 1 && status.ObservedStage != "AMBIGUOUS" { - planPath := filepath.Join(repo, ".product-loop", "features", candidates[0], "plan.md") + planPath := filepath.Join(WorkspaceFor(repo).FeatureDir(candidates[0]), "plan.md") check, checkErr := CheckPlan(planPath) if checkErr != nil { return SafetyFinding{ diff --git a/labs/12-product-engineering-loop/product-engineering-loop/statemap_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/statemap_conformance_test.go index 78074eea7..e37dabf9f 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/statemap_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/statemap_conformance_test.go @@ -152,18 +152,19 @@ func TestGuardClassifiersMatchDeclaredOwnership(t *testing.T) { // extending this allowlist — the frozen inventory of declaring files. Growth // pressure should flow toward WorkspaceContext/statemap, not new literals. func TestProductLoopLiteralsStayInDeclaredFiles(t *testing.T) { - allowed := map[string]bool{ - "activation.go": true, "delivery.go": true, "export.go": true, - "flow_control.go": true, "flow_tasks.go": true, "hooks.go": true, - "init.go": true, "installation_repair.go": true, "mutation_undo.go": true, - "next.go": true, "paths.go": true, "planning.go": true, "pr.go": true, - "recovery.go": true, "runtime_cache.go": true, "safety.go": true, - "update.go": true, "update_publication.go": true, - "workspace.go": true, + allowed := map[string]string{ + "activation.go": "controller-syntax", "delivery.go": "controller-syntax", "export.go": "controller-bundle", + "hooks.go": "embedded-installation", + "init.go": "embedded-installation", "installation_repair.go": "embedded-installation", "mutation_undo.go": "controller-syntax", + "paths.go": "canonical-owner", "planning.go": "product-diff-syntax", "pr.go": "product-diff-syntax", + "recovery.go": "product-diff-syntax", "runtime_cache.go": "embedded-installation", "safety.go": "policy-syntax", + "update.go": "embedded-installation", "update_publication.go": "embedded-installation", + "workspace.go": "repository-workspace", // denial.go names .product-loop/features/ only in user-facing denial // copy (the owned-channel guidance), never as a joined path. - "denial.go": true, + "denial.go": "user-guidance", } + validClass := map[string]bool{"canonical-owner": true, "controller-bundle": true, "controller-syntax": true, "embedded-installation": true, "policy-syntax": true, "product-diff-syntax": true, "repository-workspace": true, "user-guidance": true} fset := token.NewFileSet() entries, err := os.ReadDir(".") @@ -191,7 +192,7 @@ func TestProductLoopLiteralsStayInDeclaredFiles(t *testing.T) { if err != nil { return true } - if strings.Contains(value, ".product-loop") && !allowed[name] { + if strings.Contains(value, ".product-loop") && allowed[name] == "" { offenders[name] = true } return true @@ -210,7 +211,10 @@ func TestProductLoopLiteralsStayInDeclaredFiles(t *testing.T) { } // Reverse check: a file on the allowlist that no longer carries a literal is // stale — shrink the list so the freeze stays honest. - for name := range allowed { + for name, class := range allowed { + if !validClass[class] { + t.Errorf("allowlist entry %s has unknown ownership class %q", name, class) + } content, err := os.ReadFile(name) if err != nil { t.Fatalf("allowlisted file %s unreadable: %v", name, err)