carlos / platform Public

spec: carlos db — rework transport and freshness after review

The safety claim survived verification: litestream v0.5.14's restore
path with a nil *DB reaches only LTXFiles and OpenLTXFile, so a client
with no write methods genuinely cannot corrupt a replica. Section 12
records that and the three conditions it rests on (no ReplicaClientV3,
never Follow, CheckDB afterwards), each now a test.

The transport did not survive. "The console serves objects it already
stores" was false — replicas live in per-customer data buckets the
console has no client for, and the instance-move design bars
cross-region reads at IAM. Section 6 now states that as an open
decision between console-reads-data-bucket and mirror-through-staging,
which is a residency call rather than mine to make.

Three further corrections, all in the direction of overstating safety:
a route replicates exactly one database, not up to eight; a `daily`
class instance is legitimately 24h behind, not "seconds behind live";
and a 403 for service principals was an oracle for the door's
existence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEKBoE5EyWTUJPU15ZoenD
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 644566b48b290c826b42843d060a336996d7171e parent d8980b1
1 file changed, +425 −233
  • docs/superpowers/specs/2026-09-02-carlos-db-design.md +425 −233
diff --git a/docs/superpowers/specs/2026-09-02-carlos-db-design.md b/docs/superpowers/specs/2026-09-02-carlos-db-design.md
index 8d127b4bc..0a7d2f590 100644
--- a/docs/superpowers/specs/2026-09-02-carlos-db-design.md
+++ b/docs/superpowers/specs/2026-09-02-carlos-db-design.md
@@ -1,6 +1,6 @@
# `carlos db` — reading an app's database from its replica
-Design, 2026-09-02.
+Design, 2026-09-02. Revised after review, same day (§14 records what changed).
## 1. What this is for
@@ -26,7 +26,7 @@ inference. Every design decision below is downstream of that.
### Scope
In: reading an app's platform-replicated SQLite, from the CLI, by an
-account owner, as a local file.
+account member, as a local file.
Out: writing to tenant data by any path; querying the live database;
non-SQLite stores; the console web UI (this is CLI-first — a web view
@@ -39,18 +39,24 @@ Two forks were considered and settled before this spec.
**Replica, not live.** The alternative was a query door onto the running
instance. That needs a synchronous console→instance path, which CARLOS
does not have for ordinary boxes: the control plane is the bucket and
-boxes poll (`schedule.TickEvery` is 15s; `logship` drains every 10s).
-Building one would mean a new transport *and* a new door onto the tenant
-process, to deliver a capability whose main use is a read. The replica
-is already there, already off-box, and already proven restorable by the
-`carlos ops restore-verify` drill.
+boxes poll (`schedule.TickEvery` is 15s, `logship` drains every 10s, and
+the agent polls `control/serial` every 2s). Building one would mean a new
+transport *and* a new door onto the tenant process, to deliver a
+capability whose main use is a read. The replica is already there,
+already off-box, and already proven restorable by the restore drills.
**Client-side query, not console-side.** The console serves replica
**bytes**; the member's own machine restores and queries. The console
therefore never executes tenant SQL, never holds a restored tenant
database, and needs no query timeouts, row caps, or result-set memory
-budget. It serves objects it already stores — which is a thing it
-already knows how to do safely.
+budget.
+
+> **Note.** The first draft justified this as "the console serves objects
+> it already stores". That is false — see §6. The console holds the
+> deployment bucket; replicas live in customer data buckets. The
+> client-side argument survives on its own merits, but the transport it
+> rests on is a genuinely new door, and §6 is now the hardest part of
+> this design rather than the easiest.
The client-side choice also buys the microcopy problem's best available
answer, discussed in §5: the user is handed an actual file, so "this is
@@ -62,240 +68,375 @@ rows, and freshness is replica freshness rather than live.
## 3. What already exists
-This design is mostly assembly. The parts:
-
-- `lease.Restore(ctx, dbPath, bucket, prefix, region)` restores a
- replica to a file. `lease.VerifyReplica` does it without taking the
- lease, and `internal/lease/check.go` documents at length why that is
- safe against a live replicator: litestream restores a committed
- snapshot, and the reader never writes.
-- litestream v0.5.14 is a **vendored Go library**, not a binary we
- shell out to. `litestream.NewReplicaWithClient(nil, client)` is its
- own URL-restore pattern, already used at `internal/lease/lease.go:390`.
-- `modernc.org/sqlite` v1.55.0 is already a direct dependency and is
- **pure Go**. The query engine embeds; `carlos db` needs no `sqlite3`
- on the user's machine and no cgo.
-- `activator.HibernatePrefix(prefix, host)` is the replica prefix layout
- for exec-backed instances (`<CARLOS_HIBERNATE_PREFIX>/<host>/`, prefix
- `instances` in production).
-- `ops.FleetDBs` is the box-wide replicator's set — the "glob proposes,
- registry disposes" rule for platform-replicated databases.
-- `internal/dbverify` already records, per database, whether its replica
- is `ok`, `failed`, or `unverified`, at `control/restore-verify.json`.
-- `internal/console` has the gate shape to copy: `instanceGate`
- (`apiinstances.go:70`) and `apiScheduleApp` (`apischedule.go:96`) —
- same principal proof, byte-identical 404, shared rate limiter — and
- `s.audit` (`server.go:2692`).
-
-Nothing here needs a new replication path, a new bucket, or a change to
-how any app runs.
+Accurate as of 2026-09-02. There are **two** replicators in this
+platform and they are easy to conflate:
+
+- **The activator's embedded library.** litestream v0.5.14 is a `go.mod`
+ dependency (not vendored — there is no `vendor/`), linked into the
+ `carlos` binary. It replicates **hibernating exec-backed instances**
+ under the lease. `lease.Restore` (`lease/lease.go:378`) restores from
+ it, using litestream's own nil-`*DB` URL-restore pattern
+ (`lease.go:390`). This is the set `carlos db` primarily serves.
+- **The box-wide binary.** A separately pinned litestream **0.5.15**
+ executable (`ops/litestream.go:43`), driven by a generated
+ `/etc/litestream.yml`, replicating everything else.
+ `carlos ops restore-verify` shells out to it.
+
+`ops.FleetDBs` is the box-wide unit's set, and it **excludes**
+hibernating exec routes (`ops/hibernate_verify.go:5-8`) — it is the
+complement of the activator's set, not the whole.
+
+Other parts this design uses:
+
+- `lease.VerifyReplica` restores without taking the lease;
+ `internal/lease/check.go:66-79` documents why that cannot shadow the
+ true holder. §12 records how far that reasoning actually reaches.
+- `activator.HibernatePrefix(prefix, host)` = `path.Join(prefix, host)`
+ (`activator.go:113`), prefix `instances` in production.
+- `modernc.org/sqlite` v1.55.0 is a direct dependency and **pure Go**
+ (`go.mod:23`). litestream itself imports it, so the CLI already links
+ it; the no-cgo claim holds. The query engine embeds — `carlos db`
+ needs no `sqlite3` on the user's machine.
+- `internal/dbverify` records restore-drill verdicts — but keyed by
+ **host**, not by database (`dbverify.go:45`), at one deployment-wide
+ key (`dbverify.go:28`), and only the hibernation drill publishes.
+ `Merge` drops hosts outside the writing box's fleet
+ (`dbverify.go:60-80`), so the last box to run overwrites the others.
+ Treat a missing verdict as `unverified`, which is the record's own
+ discipline.
+- `internal/console` gates: `instanceGate` (`apiinstances.go:70`),
+ `apiScheduleApp` (`apischedule.go:96`), `s.audit` (`server.go:2692`).
+ The gate is **membership** via `AccountsFor(addr)` (`apicli.go:208`) —
+ people carry no owner/member role, so this spec says "member".
+
+**The console has no registry snapshot.** It opens the *local*
+`registry.db` on its own box (`console/view.go:532-538`). The
+`control/registry-snapshot/<box>.db` objects boxes write
+(`host/snapshot_loop.go:34`) are read by nothing in `internal/console`.
+Fleet-wide instance knowledge lives in
+`apps/<sqid>/<app>/instances/<host>.json`. Any claim that the console
+"resolves from its registry snapshot" is wrong; §6 resolves from the
+instances records.
## 4. Command surface
```
carlos db ls --app <a> [--account <a>]
-carlos db pull --app <a> [--instance <host>] [--db <name>] --out <path>
-carlos db shell --app <a> [--instance <host>] [--db <name>]
-carlos db query --app <a> [--instance <host>] [--db <name>] --sql <text>
+carlos db pull --app <a> [--instance <host>] --out <path>
+carlos db shell --app <a> [--instance <host>]
+carlos db query --app <a> [--instance <host>] --sql <text>
```
`ls` is the discovery verb and the one that makes the rest legible: for
-each database the platform replicates for this app, it prints the
-instance, the database name, how far behind the replica is, and its
-last restore-drill verdict from `dbverify`. A member who has never used
-this command should be able to type `ls` first and understand what the
-other three will hand them.
+each instance, the database the platform replicates, how old the newest
+replicated write is, the instance's replication class, and its
+restore-drill verdict. It **also names databases the app declared in
+`data.dbs` that the platform does not replicate** (§7) — that fact is
+otherwise only visible in adopt logs a member cannot read.
`pull` writes the restored copy to a path you name and stops. `--out` is
-**required** — a command that leaves a production database somewhere you
-did not name is not a command we should ship.
-
-`shell` restores to a mode-0600 temp file, opens an interactive prompt
-against it with the embedded engine, and deletes the file on exit. This
-is the interactive verb, and it is the closest thing to `rails console`
-that a Go platform can honestly offer: a REPL over your *data*, which
-exists, rather than over your *app*, which cannot (Go is compiled ahead
-of time; there is no interpreter in the running binary and no way to
-evaluate an expression against its live heap).
+**required**, and must not already exist — a command that leaves a
+production database somewhere you did not name is not one we should
+ship.
+
+`shell` restores into a private directory, opens an interactive prompt
+against it with the embedded engine, and removes the directory on exit.
+This is the closest thing to `rails console` that a Go platform can
+honestly offer: a REPL over your *data*, which exists, rather than over
+your *app*, which cannot (Go is compiled ahead of time; there is no
+interpreter in the running binary and no way to evaluate an expression
+against its live heap).
`query` is the non-interactive form — one statement, results to stdout,
-exit code reflects the query. This is the shape an agent uses, and it
-should be the shape that is easy to pipe.
+exit code reflecting the query. This is the shape an agent uses and
+should be the shape that pipes cleanly.
+
+### Where the copy lands
+
+litestream's `Restore` refuses an existing output path
+(`replica.go:604`) and creates `<out>.tmp` itself with `os.Create`,
+so it lands at the process umask (typically 0644) and you **cannot**
+pre-create a mode-0600 file for it to fill.
+
+So: always restore inside an `os.MkdirTemp` directory created 0700. That
+confines the `.tmp`, and also the `-wal`/`-shm` files a read-only open
+still needs (`check.go:96-98`). `shell` and `query` never leave that
+directory. `pull` restores there and then moves the result to `--out`,
+chmod 0600. Q2 in §13 is thereby answered: always 0600, and the user can
+loosen it themselves.
### Read-only is enforced, not assumed
-Every path opens the copy with SQLite in read-only mode
-(`query_only`, and the file opened `mode=ro`). This matters most for the
-agent case and it is worth being explicit about why: a copy is already
-harmless to write to, so enforcement is not about protecting production.
-It is about **not letting a caller believe it changed production**. An
-agent that runs an `UPDATE` against a writable copy sees `1 row
-affected` and concludes it has fixed the bug. The same agent against a
-read-only copy gets an error, and the error is the truth. Silent success
-on a discarded write is the single worst outcome this command could
-produce, and read-only mode is what forecloses it.
+Open with `file:<path>?mode=ro` — the form `lease.CheckDB` already uses
+(`check.go:36`) — plus `PRAGMA query_only=1`.
+
+This matters most for the agent case, for a non-obvious reason: a copy
+is already harmless to write to, so enforcement is not about protecting
+production. It is about **not letting a caller believe it changed
+production**. An agent that runs an `UPDATE` against a writable copy
+sees `1 row affected` and concludes it fixed the bug. Against a
+read-only copy it gets SQLite's `attempt to write a readonly database`,
+and the error is the truth. Silent success on a discarded write is the
+single worst outcome this command could produce.
+
+§11 pins that error string in a test, so the claim stays true.
## 5. The microcopy contract
The safety property of this command is a *fact the user has to hold in
-their head* — that they are looking at a copy, seconds to minutes stale,
-and that nothing they do here reaches production. If they lose that
-fact, the command becomes a way to make confident wrong decisions.
-So the copy is load-bearing and is specified here as requirements.
+their head* — that they are looking at a copy, of a stated age, and that
+nothing they do here reaches production. If they lose that fact, the
+command becomes a way to make confident wrong decisions. The copy is
+load-bearing and is specified here as requirements.
Exact strings are **not drafted in this spec**. They go through the
-`copy-review` gate at implementation time, before they are written into
-any source file. What follows is what each string has to accomplish.
+`copy-review` gate at implementation time, before being written into any
+source file. What follows is what each string must accomplish.
+
+**R1 — Say it at handover, and name the source exactly.** The line
+reporting a successful restore states that this is a copy, and names
+host, database, the newest replicated transaction id and its time. Two
+pulls must be distinguishable, and an agent must be able to cite an
+"as of". Help text is read once; this line is read every time.
+
+**R2 — Age is a number, and carries its expected bound.** Report the
+**age of the newest replicated write**, never a "lag" (which implies a
+live comparison the client cannot make — §8), and never an adjective.
+Beside it, report the instance's replication class bound: an awake
+`daily` instance is legitimately up to 24h behind, and that is exactly
+the case where a member is most likely to mistake an old copy for a
+current one. Where age cannot be determined, say so rather than
+defaulting to a reassuring word — `dbverify`'s existing three-valued
+discipline (`ok`/`failed`/`unverified`, never a silent absence).
-**R1 — Say it at the moment of handover, not only in help.** The line
-that reports a successful restore must state both that this is a copy
-and how stale it is. Help text is read once; this line is read every
-time.
+**R3 — The interactive prompt carries it too.** A `shell` session can be
+open for an hour, long outliving its banner. The prompt itself carries
+both the word copy/replica and the as-of time.
+
+**R4 — Refuse loudly, explain briefly.** Three refusals need their own
+voice: `replicate: "app"` (§7), the read-only write refusal (§4), and
+the cross-region residency refusal (§6) — which must name residency, not
+read as a transient error. Per the repo's rule, store errors are logged
+rather than returned (`apischedule.go:145-147`): they carry bucket names
+and request ids.
+
+**R5 — Written for two readers.** A human skims; an agent parses. The
+stale-copy fact must survive both. No `carlos` verb has a `--json` flag
+today, so this **introduces** one, and the spec fixes its fields here so
+the human and machine forms cannot drift: `copy: true`, `as_of`,
+`replicated_txid`, `instance: awake|asleep|unknown`, `sync_class`,
+`verified: ok|failed|unverified`.
+
+**R6 — Name what is not there.** A database declared in `data.dbs` but
+not replicated (§7) is listed as such. "The platform has no copy of
+`audit.db`" must be said before someone goes looking for it.
-**R2 — Freshness is a number, never an adjective.** "Recent" and "up to
-date" are the failure mode. Report the replica's actual lag, and where
-it cannot be determined, say that it cannot be determined rather than
-defaulting to a reassuring word. This follows `dbverify`'s existing
-three-valued discipline: `ok`, `failed`, `unverified` — never a silent
-absence and never a `0`/`false` that reads as fine.
+## 6. Transport: the replica door
-**R3 — The interactive prompt carries it too.** A `shell` session can be
-open for an hour. The prompt itself, not just the banner, must be
-distinguishable from a live database prompt at a glance.
+**This section contains the design's one unresolved decision.**
-**R4 — Refuse loudly, explain briefly.** The `replicate: "app"` refusal
-(§7) and the read-only write refusal both need to say what happened and
-what the caller can do, in one line, without a lecture.
+### The problem
-**R5 — The copy is written for two readers.** A human skims; an agent
-parses. The stale-copy fact must survive both — present in the human
-line, and present as a machine-readable field in `--json` output rather
-than only in prose an agent may not be reading.
+The console cannot serve these bytes today, and the reason is a
+deliberate platform decision, not an oversight:
-## 6. Transport: the replica door
+- The console's only store is `CARLOS_DEPLOYMENT_BUCKET`
+ (`cmd/console/main.go:395-405`). `internal/console` never reads
+ `CARLOS_HIBERNATE_*`.
+- Hibernating instances' replicas live in `CARLOS_HIBERNATE_BUCKET`
+ (`activator.go:73`) — the per-customer **data** bucket
+ (`carlos fleets create --bucket`, edges-file `data_bucket`).
+- Box-wide replicas live in a third place, `s3://<bucket>/<prefix>/`
+ keyed by absolute db path (`ops/litestream.go:170`), whose coordinates
+ nothing publishes.
+- The instance-move design (2026-08-28, §149-157) makes cross-region
+ reads of data buckets **impossible at IAM by design** — residency at
+ rest.
+
+So the door needs a decision about buckets, credentials and residency
+before it can be built.
+
+### Option A — console reads the data bucket (recommended for v1)
+
+Scope v1 to activator-replicated exec instances. The console takes
+`CARLOS_HIBERNATE_BUCKET`/`_REGION`/`_PREFIX` (or reads `data_bucket`
+from edges files) and opens a second S3 client. An instance homed in
+another region gets a **specific residency refusal**, never a generic
+502.
+
+Smaller, and honest about what it can reach. Cost: the console gains
+read on a data bucket, which is a real widening of its blast radius,
+and it only works in-region.
+
+### Option B — mirror through the deployment bucket
+
+A request record; the owning box mirrors `instances/<host>/` into a
+staging prefix the console does serve, reusing the moves worker's
+CopyObject mirror (`host/movespass.go`), swept after N minutes. The
+console then genuinely serves what it stores, and residency stays a
+box-side decision.
+
+Cost: staging tenant data in the deployment bucket is *itself* a
+residency choice, and adds latency and a sweeper.
+
+**This is Paul's call, not mine.** It trades console blast radius
+against deployment-bucket residency, and both are decisions with
+precedent this week.
+
+### The door, once the bucket question is settled
+
+Not a general object proxy. It exposes LTX files by coordinate, not keys
+by name:
+
+- `list(app, instance, level)` → `[{level, min_txid, max_txid, size, created_at}]`
+- `get(app, instance, level, min_txid, max_txid)` with `Range` support —
+ litestream's `ResumableReader` already resumes by offset.
+
+Addressing by coordinate rather than key is what keeps `lock.json`
+— which sits under the same prefix and is not a member's to fetch —
+unreachable. The host is resolved from
+`apps/<sqid>/<app>/instances/<host>.json` and **must** pass
+`provision.ValidHostShape` (`provision.go:250-262`, written as
+path-traversal defence) before becoming a key segment: `path.Join` alone
+cleans `../x` to `x` rather than refusing it.
+
+Hosts are unique FQDNs (`activator.go:118-121`), so hibernate prefixes
+cannot collide across tenants. Box-wide replica keys are absolute paths
+and **do** collide across boxes sharing a fleet prefix
+(`/data/registry.db`) — a further reason to keep them out of v1.
+
+### Who may open it
-A new owner-gated console endpoint serves replica objects for one app.
+`PrincipalUser` only. Every service principal — the legacy app token
+(role `instance`) and every named service credential
+(`publish`/`instance`/`operate`/`admin`) — gets the same
+`http.NotFound` as a stranger.
-It is **not** a general object proxy. The handler resolves
-`(account, app, instance, db)` to exactly one replica prefix using the
-console's registry snapshot, and will serve keys under that prefix and
-no other. The client cannot name a bucket, a prefix, or a key: it names
-an app and a database, and the console decides what that means. This is
-the same posture as `caps` — a closed vocabulary, no passthrough field,
-no escape hatch.
+A 403 would be an oracle: it tells any token holder that a db door
+exists for this app. The gate ethos here is one byte-identical 404.
-The gate reuses `instanceGate`'s shape: same principal proof, the same
-byte-identical 404 for "no such app" and "not yours", the same rate
-limiter. Owner-only. Unlike the logs door, an **app token may not read
-its own database** — logs are the app's own output, but the database
-may hold other tenants' or end users' data, and an app token is a
-weaker credential held in more places.
+No legitimate caller loses anything — an app reads its own database
+live, and CI has no business pulling production data. `admin` is
+refused **deliberately**, despite already reaching `store grant`.
-Every successful open writes an audit row via `s.audit`, naming the
-account, app, database, and caller address. Pulling a production
-database is a meaningful act and the record should show it.
+Every successful open writes an `s.audit` row naming account, app, host,
+database and the replicated TXID served.
### The read-only replica client
-On the client, litestream needs a `ReplicaClient` to restore from. The
-S3 one (`lease.newReplicaClient`) is unusable — the member has no AWS
-credentials, by design. So `carlos db` implements the interface over the
-console door, the way `internal/fleetapi` implements `release.Store`
-over the edge store door for exactly the same reason.
+The client implements litestream's `ReplicaClient` over this door, the
+way `internal/fleetapi` implements `release.Store` over the edge store
+door for the same reason: the member has no AWS credentials, by design.
-The interface has eight methods. This implementation supplies the five a
-restore needs — `Type`, `Init`, `LTXFiles`, `OpenLTXFile`, `SetLogger` —
-and the three mutating ones (`WriteLTXFile`, `DeleteLTXFiles`,
-`DeleteAll`) return an error unconditionally.
+The interface has eight methods (`replica_client.go:19-50`). This
+implementation supplies the five a restore needs — `Type`, `Init`,
+`LTXFiles`, `OpenLTXFile`, `SetLogger` — and the three mutating ones
+(`WriteLTXFile`, `DeleteLTXFiles`, `DeleteAll`) return an error
+unconditionally.
-That is worth stating as a property rather than an implementation note:
-**the client cannot corrupt the replica, because it has no code that
-writes to one.** Not "is not supposed to" — has none. The two-replicators
-hazard that `internal/caps` and `internal/lease` both warn about at
-length cannot be reached from this path, and a future edit that tried to
-reach it would have to add the capability first.
+**The client cannot corrupt the replica, because it has no code that
+writes to one.** Not "is not supposed to" — has none. §12 records the
+verification of that claim and the three conditions it depends on.
-## 7. Which database
+## 7. Which database — one, not eight
-`caps.Data` allows up to eight databases per app (`maxDBs = 8`), so
-selection is real, not hypothetical.
+`caps.Data` allows up to eight names (`maxDBs = 8`), but **a route
+replicates exactly one database**: `Route.DBPath` is single-valued and
+only `DBs[0]` is stamped (`host/caps_pass.go:294`, whose own adopt
+warning says so verbatim). The activator's `Replicate` takes one
+`dbPath`.
-- One database, no `--db`: use it.
-- Several, no `--db`: refuse and list the names. Guessing here means
- handing someone the wrong data while telling them it is the right
- data, which is worse than a round trip.
-- `--db <name>`: must match a declared name.
+So there is no `--db` flag and no selection story. Instead:
-`--instance` selects among an app's instances. Exec-backed instances
-each have their own replica under `activator.HibernatePrefix`; with one
-instance it is inferred, with several it is required, on the same
-reasoning as `--db`.
+- The replicated database is implied by the instance.
+- `--instance` selects among an app's instances — inferred when there is
+ one, required when there are several, because guessing means handing
+ someone the wrong data while telling them it is the right data.
+- `ls` names the **declared-but-unreplicated** entries (`DBs[1:]`),
+ surfacing `dataAdvice`'s warning to a member who cannot read adopt
+ logs (R6).
### `replicate: "app"` has no replica
-An app declaring `replicate: "app"` streams its own WAL, and the
-platform replicates **nothing** for it (`caps.DataReplicateApp`;
-`caps.go:416` refuses `data.dbs` alongside it precisely to keep the
-two-replicators hazard unreachable). There is no platform replica to
-restore.
-
-This must fail with a specific message naming that cause. The dangerous
-failure is a generic "no replica found", which reads as a transient
-problem and invites a retry loop; the app's operator needs to know the
-platform was never going to have this data and that their own
-replication is where to look.
-
-## 8. Freshness, and the two clocks
-
-Litestream sets no explicit sync interval in this deployment, so WAL
-shipping runs at its 1s default; `SnapshotInterval` is 24h, but those
-are full snapshots with WAL segments covering the gap. A restore
-therefore typically lands within seconds of live.
-
-"Typically" is doing work in that sentence, and the command must not
-launder it. Two clocks can disagree:
-
-1. **Replica lag** — how far behind the replica's newest committed
- transaction is. Normally seconds.
-2. **Replicator health** — whether anything is streaming at all. A
- hibernating instance holds no lease and streams nothing while
- asleep. Its replica is *complete as of when it went to sleep*, which
- may be days ago, and is perfectly restorable the whole time. This is
- correct behaviour that looks identical to a broken replicator if you
- only report a timestamp.
-
-`ls` and the restore line report both: how stale, and why. "Asleep since
-Tuesday" and "replicator failing since Tuesday" are the same number and
-opposite situations, and R2 forbids collapsing them into one adjective.
-
-`dbverify`'s `ok`/`failed`/`unverified` verdict rides along as the third
-signal — a replica the drill has never managed to restore should say so
-before you build an argument on its contents.
+An app declaring `replicate: "app"` streams its own WAL and the platform
+replicates **nothing** for it (`caps.DataReplicateApp`; `caps.go:416`
+refuses `data.dbs` alongside it precisely to keep the two-replicators
+hazard unreachable).
+
+This must fail naming that cause. The dangerous failure is a generic "no
+replica found", which reads as transient and invites a retry loop; the
+operator needs to know the platform was never going to have this data.
+
+## 8. Age, and the three things that are not lag
+
+The client **cannot compute lag**. `ops.freshness` gets the live change
+counter from the source file on the box (`freshness.go:96-115`); neither
+the console nor the CLI can reach it. Anything claiming a distance from
+live would be invented.
+
+What the replica alone supports, and what `ls` reports:
+
+1. **Age of the newest replicated write** — the newest LTX file's
+ `MaxTXID` and timestamp.
+2. **Whether the instance is replicating now** — whether
+ `<prefix>/lock.json` is currently held (`s3/leaser.go:23`): awake and
+ streaming, versus parked.
+3. **The expected bound** — the route's replication class:
+ `""`/`stream` ≈ 1s, `batched` 5m, `daily` 24h
+ (`activator.go:205-230`, `lease.go:245-252`).
+4. **The drill verdict** — `dbverify`, per host, hibernation drill only.
+
+The first draft claimed "no explicit sync interval, so 1s default,
+therefore seconds behind live". That is wrong for the very instances
+this command targets, and wrong in the dangerous direction. An awake
+`daily` instance is **legitimately up to a day behind**, and reporting
+its age without its class makes correct platform behaviour look like
+current data.
+
+Retention differs between the two replicators too: the `SnapshotInterval`
+24h / `SnapshotRetention` 7d constants (`ops/litestream.go:64-65`) reach
+only `/etc/litestream.yml`. The activator's embedded store never sets
+`Retention`, so hibernating replicas run on litestream's
+`DefaultRetention` of **24h**.
+
+### A sleeping instance is consistent, not necessarily complete
+
+`stop()` logs and swallows a failed final checkpoint
+(`activator.go:876-881`) — the hole the instance-move spec had to add a
+re-checkpoint for. A parked replica is consistent at a *committed*
+boundary (LTX carries committed frames only) but may trail the sleep
+point.
+
+So: "consistent as of its last successful sync, which on a clean sleep
+is the sleep point" — never "complete as of when it went to sleep".
+`carlos restart` and Bounce go through the same `stop()`.
## 9. Security posture
**The exposure is real and is the point of the gate.** This command
moves production data onto a laptop. Read-only does not reduce that; a
-copy leaks exactly as well as an original. Hence: owner-only, no app
-tokens, an audit row per open, `--out` required for anything kept, and
-mode-0600 with deletion on exit for anything temporary.
+copy leaks exactly as well as an original. Hence: members only, no
+service principals of any role, an audit row per open, `--out` required
+and refused if it exists, 0600 on anything kept, a 0700 private
+directory for anything temporary.
**Nothing new is reachable on the box.** No new door on the tenant
process, no new agent verb, no path that runs anything anywhere. The
-platform's sandbox (`DynamicUser`, `ProtectSystem=strict`, empty
+sandbox (`DynamicUser`, `ProtectSystem=strict`, empty
`CapabilityBoundingSet`) is untouched, and the same-uid sibling residual
-documented in `internal/activator/admintoken.go` is not widened, because
-no part of this design runs on the box at all.
+documented in `activator/admintoken.go:8-12` is not widened, because no
+part of this design runs on the box at all.
-**No new write authority anywhere.** The console serves bytes it already
-stores; the client has no write methods; the local copy is opened
-read-only.
+**One new read authority**, whose shape §6 leaves open: either the
+console gains data-bucket read (A), or tenant data transits the
+deployment bucket (B). Both are real, and neither should be adopted by
+default.
**The failure mode to design against is not corruption, it is
confidence.** Every serious way this command hurts someone routes
-through a caller believing stale data is live, or believing a write
-landed. §5 and the read-only enforcement in §4 exist for that, and are
-not decoration on top of the security story — they are most of it.
+through a caller believing old data is current, or believing a write
+landed. §5 and §4's read-only enforcement exist for that; they are not
+decoration on the security story, they are most of it.
## 10. Failure matrix
@@ -303,64 +444,115 @@ Each row is a test.
| Condition | Result |
|---|---|
-| Not the account owner | Byte-identical 404, matching `instanceGate` |
-| App token presented | 403 — this door does not accept app tokens |
+| Not a member of the account | Byte-identical 404, matching `instanceGate` |
+| Any service principal (app token, or `publish`/`instance`/`operate`/`admin`) | Same byte-identical 404 — never a 403 (§6) |
| No such app | Byte-identical 404 |
| `replicate: "app"` | Refusal naming that cause specifically (§7) |
-| Several databases, no `--db` | Refusal listing the names |
| Several instances, no `--instance` | Refusal listing the hosts |
-| No snapshots yet (`ErrNoSnapshots`) | "Nothing replicated yet", distinct from a failure |
+| Instance homed in another region | Residency refusal naming the region — never a generic 502 |
+| Declared-but-unreplicated db named | Refusal naming it as never replicated (§7) |
+| Nothing replicated yet | "Nothing replicated yet" — match **both** `ErrNoSnapshots` and `ErrTxNotAvailable` (`lease.go:394`) |
+| Replica changed under the restore | One retry, then a clear failure (§12) |
| Restore fails integrity check | Refuse to hand over the file; surface the `dbverify` verdict |
-| Instance asleep | Succeeds, reports staleness and the reason (§8) |
-| Write attempted in `shell`/`query` | SQLite read-only error, surfaced plainly (R4) |
-| `pull` without `--out` | Usage error |
+| Instance asleep | Succeeds; reports age, class and parked state (§8) |
+| Instance awake on `daily` class | Succeeds; age reported beside its 24h bound (R2) |
+| Write attempted in `shell`/`query` | SQLite `attempt to write a readonly database`, surfaced plainly |
+| `--out` omitted, or already exists | Usage error |
+| Host failing `ValidHostShape` | Refused before it becomes a key segment |
| Client asked to write a replica | Unconditional error — no code path exists |
## 11. Testing shape
Following `apischedule_test.go` and `internal/lease/check_test.go`:
-- Console door: the full gate matrix above, and a prefix-confinement
- test asserting a client naming a key outside its resolved prefix is
- refused. That test is the one that keeps this from becoming an object
+- **Door:** the full gate matrix above, plus a coordinate-confinement
+ test asserting `lock.json` and any key outside the resolved prefix are
+ unreachable. That test is what keeps this from becoming an object
proxy as the code ages.
-- Replica client: restore a known replica end to end through an
- `httptest` console; assert the three mutating methods error without
- touching the network.
-- Selection: `caps.Data` fixtures for one/many databases and the
+- **Replica client, both halves:** the three mutating methods error
+ without touching the network **and** a call-counting fake asserts they
+ stay at zero across a full restore. The second half is the one that
+ catches a litestream upgrade changing the restore path.
+- **`ReplicaClientV3` negative assertion** — see §12, condition (i).
+- **Compile-time** `var _ litestream.ReplicaClient = (*client)(nil)`.
+- **Selection:** `caps.Data` fixtures for `DBs[1:]` unreplicated and the
`replicate: "app"` refusal.
-- Freshness: a table over (lag, hibernating, `dbverify` verdict)
- asserting the reported wording distinguishes asleep from failing.
-- Read-only: an `UPDATE` in `query` fails, and the file on disk is
- byte-identical afterwards.
-
-## 12. What this deliberately is not
-
-- **Not a live database connection.** It cannot be, and pretending
- otherwise in the copy is the main way this feature could do harm.
-- **Not a write path.** "Fix that stuck row" is a different feature with
- a different security design. If it is wanted, it should be an
- app-declared endpoint reached the way §2's rejected fork describes —
- the app choosing its surface — not a generic SQL door.
-- **Not a general object proxy.** §6's prefix confinement is the whole
- difference, and §11 tests it.
-- **Not `carlos console`.** That name already means the control plane in
- this codebase (`--console <origin>` is on nearly every verb), and the
- thing it would name — a REPL attached to a running Go app — does not
- exist for compiled Go.
+- **Freshness:** a table over (age, parked/awake, class, verdict)
+ asserting the wording distinguishes asleep from failing, and always
+ carries the class bound.
+- **Read-only:** an `UPDATE` in `query` fails with the pinned error
+ string, and the file on disk is byte-identical afterwards.
+- **Permissions:** the temp directory is 0700 and `--out` lands 0600.
+
+Definition of done includes a `docs/site/cli.md` heading — the
+`internal/docsite` gate fails without one (AGENTS.md).
+
+## 12. Why the safety claim holds
+
+Verified against litestream v0.5.14 during review, because §6's central
+claim depends on it.
+
+`Replica.Restore` (`replica.go:544-720`) → `CalcRestorePlan`
+(`replica.go:1485ff`, `LTXFiles` per level) → `NewResumableReader`,
+whose client parameter is the narrow `LTXFileOpener` interface
+(`internal/resumable_reader.go:51,72`) — `OpenLTXFile` only.
+`WriteLTXFile`, `DeleteLTXFiles` and `DeleteAll` are reached only from
+`Sync`, `EnforceRetention`, and `db.go`'s snapshot/retention paths
+(`replica.go:192,312`; `db.go:2470,2526,2649`), none of which run
+without a `*DB`/`Store`. With a nil `*DB`, they are unreachable.
+
+Three conditions this depends on. Each is a test in §11:
+
+1. **The client must not implement `ReplicaClientV3`** (`v3.go:145`).
+ `Restore` type-asserts for it (`replica.go:612`) and would take the
+ v0.3 path instead.
+2. **Never set `RestoreOptions.Follow`.** It writes into the local
+ output and a `-txid` sidecar, and keeps polling.
+3. **Run `lease.CheckDB` after the restore** rather than relying on
+ `opt.IntegrityCheck`, matching `lease.Restore`'s existing pattern.
+
+### What the claim does *not* cover
+
+`check.go`'s reasoning is about not *shadowing the writer*. It does not
+promise the reader a stable listing. `CalcRestorePlan` lists every level
+once and opens lazily, so the writer's L0 retention
+(`DefaultL0Retention` 5m, checked hourly by the activator,
+`lease.go:78`) can delete a planned file before it is opened.
+
+The outcome is a **failed** restore, never a torn one: the output is
+`<out>.tmp`, renamed only on success (`replica.go:661-663`). One retry
+handles it, as `restore-verify` already does.
## 13. Open questions
-1. **`shell`'s prompt.** An embedded REPL is a small amount of code but
- a real surface (history, output formatting, `.tables`-style dot
- commands). Ship `ls`/`pull`/`query` first and add `shell` once the
- door is proven, or ship all four together?
-2. **Does `pull` warn on a world-readable destination?** `--out` is the
- user's choice, but writing a production database to a mode-0644 path
- in a synced folder is a foreseeable mistake.
-3. **Console web view.** Out of scope here, but the door in §6 is what
- it would use. Worth confirming the response shape does not foreclose
- it.
-4. **Retention interaction.** `SnapshotRetention` is 7d. A restore to an
- arbitrary past point is not offered here — should it be, or is
- "current replica only" the right permanent scope?
+1. **§6's bucket decision — A or B.** Blocking; Paul's call.
+2. **`shell`'s prompt.** An embedded REPL is small code but a real
+ surface (history, formatting, dot commands). Ship `ls`/`pull`/`query`
+ first, or all four together?
+3. **Console web view.** Out of scope, but §6's door is what it would
+ use; confirm the coordinate-addressed shape does not foreclose it.
+4. **Past points in time.** Not offered here. Given the activator's
+ effective 24h `DefaultRetention` (§8), is "current replica only" the
+ right permanent scope?
+5. **Rate limiting.** Pulls are large; neither the token-keyed instance
+ burst nor the address-keyed write budget fits. Needs its own budget.
+
+## 14. What the review changed
+
+The first draft's safety claim survived verification (§12). Its
+*transport* did not, and several facts were wrong:
+
+- "The console serves objects it already stores" — false; the replicas
+ are in data buckets the console has no client for, and cross-region
+ reads are barred by IAM. Now §6's open decision.
+- "Selection among up to eight databases" — false; a route replicates
+ exactly one. Now §7, with the unreplicated remainder surfaced instead.
+- "Seconds behind live" — wrong for the targeted instances, in the
+ dangerous direction: `daily` class is legitimately 24h. Now §8.
+- "litestream is vendored" / "`FleetDBs` is the platform-replicated set"
+ / "the console's registry snapshot" / "`dbverify` is per database" —
+ all corrected in §3.
+- A 403 for service principals was an oracle; now a byte-identical 404
+ (§6).
+- The mode-0600 temp file was not constructible given how `Restore`
+ creates its own output; now a 0700 private directory (§4).