Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ Do not use `--repair` for user-owned or mixed changes. Move durable project cont

Use the installer for the target release in update mode. It downloads and verifies the target helper before treating the installed helper's `doctor` result as diagnostic, so a missing helper or stale owned hook cannot disable recovery. Run `repair-status --repo . --json` to inspect the secret-free classification. Malformed host JSON, partial interceptor markers, symlinks, and unverifiable user content remain blocking and are never overwritten.

If the ignored local install lock says `dev`, has an unknown source commit, is malformed, or is absent, rerun the same verified target installer with `BOATSTACK_REPAIR=1`. The target helper recovers the prior stable version from the repository's committed generated pin and repairs only verified Boatstack-owned state. Do not delete repository files or move detached state by hand. Detached operation receipts and runtime slots are validated against their external Boatstack ownership root, so a path under `Application Support/boatstack` is not treated as a repository escape.

## A tool call repeats or publication appears stuck

Run `.product-loop/bin/boatstack-helper operation-status --repo . --json`. `EXECUTING` means the exact call already has a live lease, so wait instead of launching it again. `RECONCILE_REQUIRED` means Boatstack did not observe completion; verify the reported Git, GitHub, file, browser, or MCP postcondition before retrying. A successful operation whose response was lost is recovered from that observation. Do not reset the task, repeat a denied push, or open another PR.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Detached update recovery

Boatstack updates can now recover from an invalid local development install lock by using the repository's committed stable pin as the prior release identity. Detached operation receipts and shared runtimes carry their external ownership boundary through validation, preventing the updater from rejecting its own controller state as a repository escape. Symlink and mixed-ownership checks remain fail closed.
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,57 @@ type InstallationRepairResult struct {
}

func installedVersion(repo string) (string, error) {
for _, candidate := range []string{
filepath.Join(repo, ".product-loop", "bin", "install.lock.json"),
filepath.Join(repo, ".product-loop", "generated.lock.json"),
var failures []string
type candidate struct {
label string
value []byte
err error
}
localPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
localValue, localErr := os.ReadFile(localPath)
committedValue, committedErr := exec.Command("git", "-C", repo, "show", "HEAD:.product-loop/generated.lock.json").Output()
for _, candidate := range []candidate{
{label: "install.lock.json", value: localValue, err: localErr},
{label: "committed generated.lock.json", value: committedValue, err: committedErr},
} {
value, err := os.ReadFile(candidate)
if err != nil {
if candidate.err != nil {
continue
}
var identity struct {
BoatstackVersion string `json:"boatstack_version"`
SourceCommit string `json:"source_commit"`
Runtime struct {
SourceCommit string `json:"source_commit"`
} `json:"runtime"`
}
if err := json.Unmarshal(candidate.value, &identity); err != nil {
failures = append(failures, candidate.label+" is malformed")
continue
}
if json.Unmarshal(value, &identity) == nil && strings.TrimSpace(identity.BoatstackVersion) != "" {
return normalizedVersion(identity.BoatstackVersion)
if strings.TrimSpace(identity.BoatstackVersion) == "" {
failures = append(failures, candidate.label+" has no version")
continue
}
sourceCommit := strings.TrimSpace(identity.SourceCommit)
if sourceCommit == "" {
sourceCommit = strings.TrimSpace(identity.Runtime.SourceCommit)
}
if sourceCommit == "" || strings.EqualFold(sourceCommit, "unknown") {
failures = append(failures, candidate.label+" has invalid source commit")
continue
}
version, err := normalizedVersion(identity.BoatstackVersion)
if err != nil {
failures = append(failures, candidate.label+" has invalid version")
continue
}
return version, nil
}
detail := strings.Join(failures, "; ")
if detail != "" {
detail = ": " + detail
}
return "", fmt.Errorf("installed Boatstack version cannot be established from owned provenance")
return "", fmt.Errorf("installed Boatstack version cannot be established from owned provenance%s", detail)
}

func updateDirection(installed, target string) (string, error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,84 @@ func TestRepairPreservesIntegrationFallbackWhenInstallLockIsMissing(t *testing.T
}
}

func TestRepairFallsBackToCommittedPinWhenLocalProvenanceIsInvalid(t *testing.T) {
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
for name, value := range map[string][]byte{
"development identity": []byte(`{"boatstack_version":"dev","source_commit":"unknown"}`),
"unknown source": []byte(`{"boatstack_version":"v0.4.0","source_commit":"unknown"}`),
"malformed identity": []byte("{\n"),
} {
t.Run(name, func(t *testing.T) {
repo, _ := updateInstalledRepo(t)
lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
if err := os.WriteFile(lockPath, value, 0o644); err != nil {
t.Fatal(err)
}
// Recovery authority comes from the committed pin, not a potentially
// drifted generated file in the worktree.
if err := os.WriteFile(filepath.Join(repo, ".product-loop", "generated.lock.json"), []byte("{\n"), 0o644); err != nil {
t.Fatal(err)
}
installed, err := installedVersion(repo)
if err != nil || installed != "v0.4.0" {
t.Fatalf("committed pin did not recover version identity: version=%q err=%v", installed, err)
}
config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
if err != nil {
t.Fatal(err)
}
result, err := ClassifyInstallationRepair(repo, config.Adapters, false)
if err != nil {
t.Fatal(err)
}
if result.VerificationStatus != "REPAIR_AVAILABLE" || result.InstalledVersion != "v0.4.0" {
t.Fatalf("invalid local provenance was not safely repairable: %#v", result)
}
})
}
}

func TestDetachedUpdateRepairsInvalidLocalProvenanceEndToEnd(t *testing.T) {
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
repo, _ := updateInstalledRepo(t)
t.Setenv(stateRootEnv, t.TempDir())
invalidateWorkspaceCache()
if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
t.Fatal(err)
}
runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
if err := os.WriteFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), []byte(`{"boatstack_version":"dev","source_commit":"unknown"}`), 0o644); err != nil {
t.Fatal(err)
}
Version = "v0.5.0"
SourceCommit = "update-test-0.5.0"
if err := RunUpdate(InitOptions{Repo: repo, Repair: true, Yes: true, Input: strings.NewReader(""), Output: &bytes.Buffer{}}); err != nil {
t.Fatal(err)
}
receipts, err := operationReceipts(repo)
if err != nil {
t.Fatal(err)
}
found := false
for _, receipt := range receipts {
if receipt.Kind == "install-update" && receipt.State == OperationSucceeded {
found = true
}
}
if !found {
t.Fatalf("detached repair did not persist a successful update operation: %#v", receipts)
}
directory, err := WorkspaceFor(repo).OperationDir()
if err != nil {
t.Fatal(err)
}
if strings.HasPrefix(directory, repo+string(filepath.Separator)) {
t.Fatalf("detached update receipt entered the repository: %s", directory)
}
}

func TestRepairReconstructsCorruptGeneratedProvenance(t *testing.T) {
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,24 +133,22 @@ func pruneLegacyOperationLedger(repo string) {
_ = os.RemoveAll(legacy)
}

func operationPath(repo, operationID string) (string, error) {
func operationOwnedPath(repo, operationID string) (controllerPath, error) {
id, err := safeCacheSegment(operationID, "operation id")
if err != nil {
return "", err
}
directory, err := operationDirectory(repo)
if err != nil {
return "", err
return controllerPath{}, err
}
path := filepath.Join(directory, id+".json")
gitDir, err := worktreeGitDir(repo)
ctx := WorkspaceFor(repo)
directory, err := ctx.OperationDir()
if err != nil {
return "", err
return controllerPath{}, err
}
if err := rejectSymlinkComponents(gitDir, path); err != nil {
return "", err
}
return path, nil
return ctx.worktreeOwnedPath(filepath.Join(directory, id+".json"))
}

func operationPath(repo, operationID string) (string, error) {
owned, err := operationOwnedPath(repo, operationID)
return owned.path, err
}

func operationID(kind, target, fingerprint string) string {
Expand Down Expand Up @@ -223,18 +221,15 @@ func saveOperation(repo string, receipt OperationReceipt) error {
}

func withOperationLock(repo, id string, apply func() error) error {
path, err := operationPath(repo, id)
path, err := operationOwnedPath(repo, id)
if err != nil {
return err
}
lock := strings.TrimSuffix(path, ".json") + ".lock"
gitDir, err := worktreeGitDir(repo)
lockPath, err := path.Sibling(strings.TrimSuffix(filepath.Base(path.path), ".json") + ".lock")
if err != nil {
return err
}
if err := rejectSymlinkComponents(gitDir, lock); err != nil {
return err
}
lock := lockPath.path
if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil {
return err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -174,6 +175,69 @@ func preparedOperation(t *testing.T, repo, fingerprint, retryClass string, attem
return receipt
}

// control-law: controller-effects-use-the-owning-storage-boundary
func TestDetachedOperationLifecycleUsesExternalOwnedBoundary(t *testing.T) {
repo := detachedTestRepo(t, "https://github.com/acme/detached-operations.git")
if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
t.Fatal(err)
}
receipt := preparedOperation(t, repo, "detached-package", "ATOMIC_LOCAL", 1)
if receipt.State != OperationAuthorized {
t.Fatalf("operation was not authorized: %+v", receipt)
}
path, err := operationPath(repo, receipt.OperationID)
if err != nil {
t.Fatal(err)
}
ctx := WorkspaceFor(repo)
directory, err := ctx.OperationDir()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(path, directory+string(filepath.Separator)) || strings.HasPrefix(path, repo+string(filepath.Separator)) {
t.Fatalf("detached operation escaped its external ledger: %s", path)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("detached operation receipt was not written: %v", err)
}
gitDir, err := worktreeGitDir(repo)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(gitDir, "boatstack", "operations", "v2", filepath.Base(path))); !os.IsNotExist(err) {
t.Fatalf("detached receipt also entered the Git directory: %v", err)
}
}

// control-law: detached-owned-boundaries-reject-symlink-escapes
func TestDetachedOperationRejectsSymlinkedControllerPath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on Windows")
}
repo := detachedTestRepo(t, "https://github.com/acme/detached-operation-symlink.git")
if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
t.Fatal(err)
}
base, err := WorkspaceFor(repo).worktreeControlDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(base, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(t.TempDir(), filepath.Join(base, "operations")); err != nil {
t.Fatal(err)
}
_, err = PrepareOperation(OperationPrepareOptions{
Repo: repo, Kind: "test-write", Target: "artifact.json", PackageFingerprint: "escape",
AuthorizationFingerprint: "approved", RetryClass: "ATOMIC_LOCAL", MaxAttempts: 1,
ExpectedPostcondition: "artifact exists",
})
if err == nil || !strings.Contains(err.Error(), "symlinked path") {
t.Fatalf("symlinked detached operation path was not rejected: %v", err)
}
}

func TestOperationLifecycleAndReplayProtection(t *testing.T) {
repo := operationTestRepo(t)
receipt := preparedOperation(t, repo, "package-a", "ATOMIC_LOCAL", 2)
Expand Down
54 changes: 54 additions & 0 deletions labs/12-product-engineering-loop/product-engineering-loop/paths.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package boatstack

import (
"fmt"
"path/filepath"
"sync"
)
Expand Down Expand Up @@ -61,6 +62,59 @@ type WorkspaceContext struct {
sharedControlRoot string
}

// controllerPath carries a Boatstack-owned path together with the boundary that
// owns it. Effectful callers validate this value instead of independently
// choosing a repository, Git, or detached-state root.
type controllerPath struct {
path string
root string
}

func newControllerPath(root, target string) (controllerPath, error) {
if root == "" || target == "" {
return controllerPath{}, fmt.Errorf("controller path ownership is incomplete")
}
owned := controllerPath{path: filepath.Clean(target), root: filepath.Clean(root)}
if err := owned.Validate(); err != nil {
return controllerPath{}, err
}
return owned, nil
}

func (p controllerPath) Validate() error {
return rejectSymlinkComponents(p.root, p.path)
}

// Sibling derives another target without losing the owning boundary.
func (p controllerPath) Sibling(name string) (controllerPath, error) {
if filepath.Base(name) != name || name == "." || name == ".." {
return controllerPath{}, fmt.Errorf("invalid controller path name: %s", name)
}
return newControllerPath(p.root, filepath.Join(filepath.Dir(p.path), name))
}

func (w WorkspaceContext) worktreeOwnedPath(target string) (controllerPath, error) {
if w.Mode == SupervisionDetached {
return newControllerPath(w.sharedControlRoot, target)
}
root, err := worktreeGitDir(w.RepoRoot)
if err != nil {
return controllerPath{}, err
}
return newControllerPath(root, target)
}

func (w WorkspaceContext) sharedOwnedPath(target string) (controllerPath, error) {
if w.Mode == SupervisionDetached {
return newControllerPath(w.sharedControlRoot, target)
}
root, err := gitCommonDir(w.RepoRoot)
if err != nil {
return controllerPath{}, err
}
return newControllerPath(root, target)
}

// WorkspaceFor returns the resolver for a repository. It consults the external
// attachment registry and returns a detached context when the repository is
// attached and its binding verifies; otherwise it returns the embedded layout.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The `root-cause` operation operationalizes this taxonomy for a single bug: it cl
| Review miss | Defect found after same-agent review | Independent reviewer; risk checklist; mechanical enforcement | Expensive review everywhere |
| Scope drift | Diff no longer maps to approved outcomes | Re-scope; split PR; update spec with approval | Hiding product changes in implementation |
| Update self-lockout | An installed helper, stale hook event, or damaged owned receipt blocks its own updater | Let the verified target helper classify state; migrate exact provenance automatically or offer fingerprinted `--repair` | Reinstalling blindly, overwriting user settings, or treating `--repair` as downgrade authority |
| Controller-root split | A detached controller path is resolved under external state, then an effectful caller independently validates it against the repository or Git directory and rejects its own owned path as an escape | Carry the target and its owning boundary as one typed value; derive child paths from it; make every effect validate that value; test embedded/detached and worktree/shared storage classes | Broadening the boundary to bypass validation, or letting another caller reconstruct the root independently |
| Recovery provenance self-dependency | A damaged local install lock is the first candidate used to decide whether that same lock may be repaired, so a development or malformed identity blocks the verified target helper before recovery begins | Treat the local lock as evidence when valid; otherwise derive the prior stable identity from the committed generated pin and let the verified target helper classify the exact owned repair | Trusting an uncommitted generated lock, inferring an arbitrary version, or overwriting mixed/user-owned state |
| Ownership projection contradiction | Update admission classifies a path as Boatstack-owned, then final validation rejects the controller's own bounded mutation | Build one semantic ownership projection before execution; reuse it for admission, mutation, final verification, staging, and preview | Path-only allowlists accepting user content or independently maintained validators disagreeing after a side effect |
| Security/tenancy | Trust boundary or data scope violated | Specialist review; invariant test; deny-by-default guard | Generic prompt mistaken for enforcement |
| Integration/deploy | Local pass but runtime fails | Environment parity; canary; health checks; rollback | Treating staging as identical to production |
Expand Down
Loading
Loading