carlos / platform Public

docs: plan for the #69/#70 db-path fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8efdf651cbd49dc79a01e6bf38e78582df654240 parent c6e9ae4
1 file changed, +380 −0
  • docs/superpowers/plans/2026-08-06-issues-69-70-db-path.md +380 −0
diff --git a/docs/superpowers/plans/2026-08-06-issues-69-70-db-path.md b/docs/superpowers/plans/2026-08-06-issues-69-70-db-path.md
new file mode 100644
index 000000000..17708b6b5
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-06-issues-69-70-db-path.md
@@ -0,0 +1,380 @@
+# Issues #69 + #70: db-path staleness and relative-path failure — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix the two defects found in vitogo's first tito deploy (2026-08-06): a re-`add` that changes only `-db` never reaches a running edge (#69), and a relative `-db` on an agent-exec route fails every wake as edge 503s (#70).
+
+**Architecture:** Both root causes are now diagnosed and are NOT what the issue titles guess. (#69) `registry.Upsert` **does** overwrite `db_path` in the DB — the defect is `Registry.Fingerprint()` (the edge's cheap change poll), which sums weighted lengths of `status/socket/addr/version/hibernate/channel/backing/tunnel` but not `db_path`, so a db-path-only rewrite never triggers a route reload and the activator keeps waking the host with the stale cached path. (#70) exec children have no `STATE_DIRECTORY` and the activator/child both consume `rt.DBPath` verbatim (Restore/Seed/Start `--db`/Replicate), so a relative path resolves against an unwritable cwd; every mint path except `carlos add -db` already produces absolute paths, so the fix is a loud add-time refusal at the one door that can introduce the misconfiguration (issue #70's suggested fix 2). We also make `add` say `updated` vs `added` so an upsert-over-existing is visible to the operator (#69's operator-honesty half).
+
+**Tech Stack:** Go, modernc sqlite registry, stdlib flag CLI, e2e tests that build and shell out to the carlos binary.
+
+## Global Constraints
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean (all 18 packages) before any commit lands (CLAUDE.md).
+- `gofmt -l` clean for files touched.
+- Sandbox: go commands need `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off` and may need the sandbox disabled ("read-only file system" is the tell).
+- Wire-shape changes are additive: nothing here changes storage; existing registry rows (all absolute paths in production; vitogo's bad row was already removed) must keep parsing.
+- Any e2e test that shells out to the carlos binary must build env through `carlosTestEnv(t)` and set `cmd.Dir` to a scratch dir (the binary walks cwd ancestors for `.carlos/config`).
+- Working branch: `worktree-issues-69-70-db-path` (already created). Never push main; open a PR at the end.
+- Delegation (CLAUDE.md): registry/CLI door is production-touching — Opus reviews the finished branch before the PR opens.
+
+---
+
+### Task 1: Fingerprint sees db_path rewrites (#69 core)
+
+**Files:**
+- Modify: `internal/registry/registry.go` (Fingerprint, ~line 1009)
+- Test: `internal/registry/registry_test.go` (extend the existing fingerprint test that ends with the tunnel-binding check, ~line 190)
+
+**Interfaces:**
+- Consumes: existing `Registry.Fingerprint() (string, error)`, `Registry.Get/Upsert`.
+- Produces: no signature changes — only the fingerprint SQL gains a `LENGTH(db_path)*19` term. Weight 19 is the next unused prime (3,5,7,11,13 are column weights; 17 is the rowid multiplier).
+
+- [ ] **Step 1: Write the failing test**
+
+In `internal/registry/registry_test.go`, extend the existing fingerprint test (the one containing `t.Fatal("fingerprint blind to a tunnel binding")`) by appending, after that final check:
+
+```go
+ // db_path rewrites must reload the edge too: a re-`carlos add` that only
+ // fixes -db otherwise leaves the running activator waking the host with
+ // the stale cached path forever — the exact vitogo incident (issue #69):
+ // `add` reported success, the row was updated, and wakes still died on
+ // the old path minutes later because this poll never moved.
+ rt, err = reg.Get("c.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rt.DBPath = "/data/somewhere-else.db"
+ if err := reg.Upsert(*rt); err != nil {
+ t.Fatal(err)
+ }
+ withDBPath, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if withDBPath == withTunnel {
+ t.Fatal("fingerprint blind to a db_path rewrite")
+ }
+```
+
+Note: `rt` and `err` already exist in that scope (the backing-flip check declared them), so this is `rt, err =`, not `:=`. If the route `c.example.com` in that test already carries a `DBPath`, pick a replacement value of a **different length** — the fingerprint is length-weighted by design and a same-length rewrite is deliberately invisible.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/registry/ -run 'Fingerprint' -count=1 -v` (with the sandbox go env vars)
+Expected: FAIL with `fingerprint blind to a db_path rewrite`
+
+- [ ] **Step 3: Add the term to Fingerprint**
+
+In `internal/registry/registry.go`, change the SUM expression inside `Fingerprint()`:
+
+```go
+ COALESCE(SUM(rowid*17 + LENGTH(status)*3 + LENGTH(socket) + LENGTH(addr)
+ + LENGTH(version)*5 + hibernate*7 + LENGTH(channel)
+ + LENGTH(backing)*11 + LENGTH(tunnel)*13 + LENGTH(db_path)*19),0)
+```
+
+And extend the function's doc comment: after the sentence about weights, add one sentence — `db_path is in the sum (issue #69): a re-add that only re-points a route's database must reach a running edge, or the activator keeps waking the host on the stale cached path.`
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/registry/ -count=1` (full package — the fingerprint change can break other fingerprint assertions if any pin exact values; fix only by updating pinned strings, never by weakening assertions)
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/registry/registry.go internal/registry/registry_test.go
+git commit -m "registry: fingerprint sees db_path rewrites (#69)"
+```
+
+### Task 2: `carlos add` says updated vs added (#69 operator honesty)
+
+**Files:**
+- Modify: `cli.go` (`runAdd`, the tail: the `registry.Open` → `Upsert` → `fmt.Printf("added %s\n", host)` block, ~lines 148–161)
+- Test: `e2e_test.go` (new test function, following the shape of the `-account-id` refusal test at ~line 1370)
+
+**Interfaces:**
+- Consumes: `registry.Registry.Get(host string) (*Route, error)` (existing), `registry.Registry.Upsert(Route) error` (existing).
+- Produces: `carlos add` prints `updated <host> (existing route)` when the host row already existed, `added <host>` otherwise. No flag changes.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `e2e_test.go`:
+
+```go
+// carlos add on an existing host is an update and must say so — the vitogo
+// incident (issue #69) started with `added vito.platform.tito.io` printed
+// over what was actually a rewrite of an existing row.
+func TestAddOnExistingRouteSaysUpdated(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ carlosBin := filepath.Join(tmp, "carlos")
+ build := exec.Command(goBin, "build", "-o", carlosBin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ regPath := filepath.Join(tmp, "registry.db")
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = tmp
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ out, err := run("add", "-registry", regPath, "-app", "vitogo",
+ "-db", "/data/first.db", "vito.example.com")
+ if err != nil {
+ t.Fatalf("first add: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "added vito.example.com") {
+ t.Errorf("first add output = %q, want %q", out, "added vito.example.com")
+ }
+
+ out, err = run("add", "-registry", regPath, "-app", "vitogo",
+ "-db", "/data/second.db", "vito.example.com")
+ if err != nil {
+ t.Fatalf("re-add: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "updated vito.example.com") {
+ t.Errorf("re-add output = %q, want it to say updated, not added", out)
+ }
+
+ // And the rewrite must actually land — the row carries the new path.
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ defer reg.Close()
+ rt, err := reg.Get("vito.example.com")
+ if err != nil || rt == nil {
+ t.Fatalf("get route: %v (rt=%v)", err, rt)
+ }
+ if rt.DBPath != "/data/second.db" {
+ t.Errorf("db_path = %q, want /data/second.db", rt.DBPath)
+ }
+}
+```
+
+(`registry` is already imported in `e2e_test.go`; `os`, `strings`, `filepath`, `exec` too — verify and add any missing import.)
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test . -run TestAddOnExistingRouteSaysUpdated -count=1 -v`
+Expected: FAIL on the `updated` assertion (output still says `added`)
+
+- [ ] **Step 3: Implement**
+
+In `cli.go` `runAdd`, after `r, err := registry.Open(*reg)` succeeds and before the `Upsert` call, look up the existing row; after the `Upsert`, report accordingly. Replace the tail:
+
+```go
+ // An upsert over an existing host is a rewrite, not a creation, and the
+ // verb the operator reads back must say which one happened (issue #69):
+ // "added" printed over a rewrite is how vitogo's operator was left
+ // believing a fresh row existed while a running edge kept the old one.
+ existing, err := r.Get(host)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := r.Upsert(registry.Route{
+ Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr,
+ App: *app, Account: account,
+ Channel: *channel, Hibernate: *hibernate, StatusNamed: *statusNamed, Backing: *backing,
+ DBPath: *dbPath, Status: "ready",
+ }); err != nil {
+ log.Fatal(err)
+ }
+ if existing != nil {
+ fmt.Printf("updated %s (existing route)\n", host)
+ } else {
+ fmt.Printf("added %s\n", host)
+ }
+```
+
+(The comment above the Upsert about account stamping stays where it is.)
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test . -run TestAddOnExistingRouteSaysUpdated -count=1 -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cli.go e2e_test.go
+git commit -m "cli: carlos add says updated when the route already existed (#69)"
+```
+
+### Task 3: refuse a relative -db on agent-exec routes (#70)
+
+**Files:**
+- Modify: `cli.go` (`runAdd`, immediately after the `-backing unit -hibernate requires -db` refusal, ~line 128)
+- Test: `e2e_test.go` (new test function, same scaffold as Task 2)
+
+**Interfaces:**
+- Consumes: `filepath.IsAbs` (`path/filepath` is already imported in `cli.go` — verify), `registry.BackingUnit` (existing).
+- Produces: `carlos add` with `-backing ""` (exec child) and a relative non-empty `-db` exits non-zero with a message naming the cause (no `STATE_DIRECTORY` for exec children), the requirement (absolute path), and the convention (`provision.DefaultDBPath` result). `-backing unit` keeps accepting relative paths — systemd's `StateDirectory=carlos-app/%i` is what makes the rastrillo activation-contract convention real there.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `e2e_test.go`:
+
+```go
+// A relative -db on an agent-exec route must be refused at add time. The
+// activation contract's "relative -db resolves inside $STATE_DIRECTORY"
+// convention is real for unit tenants (systemd sets it) but agent-exec
+// children are spawned without it and the activator consumes the path
+// verbatim (Restore, Seed, Start --db, Replicate) — so a relative path
+// resolves against an unwritable cwd and every wake dies as an edge 503,
+// discoverable only by correlating 503s with child stderr in the journal
+// (issue #70, vitogo's first deploy).
+func TestAddRefusesRelativeDBOnExecRoutes(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ carlosBin := filepath.Join(tmp, "carlos")
+ build := exec.Command(goBin, "build", "-o", carlosBin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ regPath := filepath.Join(tmp, "registry.db")
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = tmp
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // The vitogo add, verbatim shape: exec backing (the default), relative -db.
+ out, err := run("add", "-registry", regPath, "-app", "vitogo",
+ "-kind", "instance", "-db", "vito.db", "-hibernate",
+ "-channel", "canary/vitogo", "vito.example.com")
+ if err == nil {
+ t.Fatalf("add with relative -db on an exec route: want non-zero exit, got 0\n%s", out)
+ }
+ // The refusal must carry the diagnosis and the way out — not just "no".
+ if !strings.Contains(out, "STATE_DIRECTORY") || !strings.Contains(out, "absolute") {
+ t.Errorf("refusal = %q, want it to explain STATE_DIRECTORY and require an absolute path", out)
+ }
+ // And it must refuse BEFORE writing: no half-registered route.
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ defer reg.Close()
+ rt, err := reg.Get("vito.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rt != nil {
+ t.Fatalf("refused add still wrote a route: %+v", rt)
+ }
+
+ // A unit tenant keeps the contract: StateDirectory= is real there, a
+ // relative -db is the documented convention, and this guard must not
+ // take it away.
+ out, err = run("add", "-registry", regPath, "-app", "vitogo",
+ "-kind", "instance", "-backing", "unit", "-db", "vito.db",
+ "unit.example.com")
+ if err != nil {
+ t.Fatalf("add -backing unit with relative -db: %v\n%s", err, out)
+ }
+
+ // An absolute -db on an exec route is untouched by the guard.
+ out, err = run("add", "-registry", regPath, "-app", "vitogo",
+ "-kind", "instance", "-db", "/data/vito.example.com.db",
+ "vito.example.com")
+ if err != nil {
+ t.Fatalf("add with absolute -db: %v\n%s", err, out)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test . -run TestAddRefusesRelativeDBOnExecRoutes -count=1 -v`
+Expected: FAIL at the first assertion (the add exits 0 today)
+
+- [ ] **Step 3: Implement the guard**
+
+In `cli.go` `runAdd`, directly after the `-backing unit -hibernate requires -db` refusal block (so it runs after the `-db` defaulting, before `resolveAccount`):
+
+```go
+ // An agent-exec child gets no STATE_DIRECTORY — the activation
+ // contract's "relative -db resolves inside $STATE_DIRECTORY" convention
+ // is systemd's half, real only under -backing unit. On the exec path the
+ // activator consumes the stored path verbatim (Restore, Seed, Start's
+ // --db flag, the replicator), so a relative path resolves against a cwd
+ // nobody chose and every wake dies as an edge 503 discoverable only in
+ // the journal (issue #70). Refuse it here, at the only door that can
+ // introduce one — every other mint path builds absolute paths.
+ if *backing != registry.BackingUnit && *dbPath != "" && !filepath.IsAbs(*dbPath) {
+ log.Fatalf("add %s: -db %q is relative, and agent-exec children get no STATE_DIRECTORY to resolve it in — pass an absolute path (the default here is %s), or use -backing unit, whose StateDirectory= makes relative paths real",
+ host, *dbPath, provision.DefaultDBPath(dataDir(), host))
+ }
+```
+
+Verify `path/filepath` and the `provision` package are in `cli.go`'s imports (both should be — `runAdd` already calls `filepath.Join` and `provision.DefaultDBPath`); add if missing.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test . -run TestAddRefusesRelativeDBOnExecRoutes -count=1 -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cli.go e2e_test.go
+git commit -m "cli: refuse a relative -db on agent-exec routes (#70)"
+```
+
+### Task 4: full verification, Opus review, PR
+
+**Files:**
+- No new code; fixes from review land as amendments to the files above.
+
+- [ ] **Step 1: Full suite**
+
+Run (sandbox env vars, sandbox disabled if the toolchain complains):
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+gofmt -l internal/registry/registry.go internal/registry/registry_test.go cli.go e2e_test.go
+```
+Expected: build/vet clean, all packages pass, gofmt prints nothing.
+
+- [ ] **Step 2: Opus review (CLAUDE.md: production-touching work gets an Opus reviewer)**
+
+Dispatch a review subagent on **Opus** over `git diff main...HEAD`, briefed with both issue bodies and the two root-cause diagnoses (fingerprint blindness; exec-verbatim path consumption). Questions it must answer: does the fingerprint term break any pinned-value test; does the updated/added lookup introduce a TOCTOU worth caring about (two concurrent adds — acceptable: the message is advisory); does the relative-db guard block any legitimate existing workflow (unit tenants must keep relative). Apply fixes, re-run the full suite, amend commits or add a fix commit.
+
+- [ ] **Step 3: Push and open the PR**
+
+```bash
+git push -u origin worktree-issues-69-70-db-path
+gh pr create --repo carlosframework/platform --draft \
+ --title "registry+cli: db_path reaches running edges; relative -db refused on exec routes" \
+ --body "Fixes #69, fixes #70. ..."
+```
+
+PR body must state both root-cause diagnoses (they differ from the issue titles' guesses), what changed, and the deferred follow-up: materializing a real per-host `STATE_DIRECTORY` for exec children (issue #70's suggested fix 1) is a design decision — a new on-disk convention with migration implications — left open on the issue, not taken overnight.
+
+Expected: draft PR open, linked to both issues.
+
+---
+
+## Self-review notes
+
+- **#69 coverage:** the issue's observed failure (success message + stale behavior) is covered twice — fingerprint (behavioral root cause, Task 1) and updated/added honesty (message half, Task 2). The issue's literal guess ("stored db path silently kept its original value") is disproven by `Upsert`'s `db_path=excluded.db_path`; Task 2's test pins the row rewrite to guard it.
+- **#70 coverage:** suggested fix 2, chosen over 1/3 deliberately: resolving relative paths platform-side (fix 3) invents a per-host state-dir convention overnight and threads it through Restore/Seed/Start/Replicate **and** `ops.FleetDBs`' path matching (`filepath.Clean(r.DBPath)`) **and** `caps_pass` stamping — fleet-wide blast radius for a case the door can simply refuse. Fix 1 (real STATE_DIRECTORY) is the same convention decision; deferred explicitly on the issue.
+- **Sticky-column caveat:** `Upsert` deliberately keeps `environment/caps/tunnel/provisioned/seed` when incoming is zero; none of these is a `carlos add` flag, so "updates all supplied fields" is accurate as printed.
+- **Type consistency:** Tasks 2 and 3 both edit `runAdd`; Task 3's guard sits above Task 2's `existing` lookup and they don't interact. Both e2e tests use the local-build scaffold (each e2e test builds its own binary — the file's existing convention) plus `carlosTestEnv` + `cmd.Dir` per CLAUDE.md.