spec: carlos db — settle on the console reading the region data bucket
Option A over B, on the merits rather than as a coin-flip: B needs a new box-side pass and a sweeper, which would have cost section 9 its "nothing runs on the box" sentence; its first-mirror latency is a CopyObject per LTX file; it lands tenant database bytes in the deployment bucket, which the moves design deliberately routed around; and it does not remove the data-bucket read anyway, since hibernate-verify still needs it. Its one advantage over A, cross-region reach, is a residency violation rather than a feature. Two further findings, both in the direction of overstating freshness: sync_class is unknowable off-box — Replication lives on registry.Route, not provision.Record — so the console knows it only for routes on its own box. R2 now requires an explicit "unknown" that must never render as `stream`, because a wrong bound turns "I don't know" into "one second old". Age must come from the LTX header timestamp rather than S3 LastModified, or a compacted L3 file reports its compaction as a fresh write. Also: the door adapts litestream's own lss3 read methods so reader and writer cannot drift on key layout, levels 0-9 answer empty rather than 404, lease_held becomes a derived ls field so "is it replicating" is answerable without exposing lock.json, and the restore retry wraps the whole Restore because ResumableReader treats ErrNotExist as fatal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEKBoE5EyWTUJPU15ZoenD
1 file changed,
+341
−266
docs/superpowers/specs/2026-09-02-carlos-db-design.md+341 −266
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 0a7d2f590..08856f2cf 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. Revised after review, same day (§14 records what changed). |
| +Design, 2026-09-02. Revised twice after review, same day (§14). |
| ## 1. What this is for |
| @@ -26,97 +26,91 @@ inference. Every design decision below is downstream of that. |
| ### Scope |
| In: reading an app's platform-replicated SQLite, from the CLI, by an |
| -account member, as a local file. |
| +account member, as a local file, for **in-region, non-pooled, |
| +activator-replicated instances**. |
| 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 |
| -can follow once the door exists, and is not designed here). |
| +fleet-homed and cross-region instances (§13 — a v2 with a different |
| +transport); box-wide replicas; non-SQLite stores; the console web UI. |
| -## 2. The decision |
| - |
| -Two forks were considered and settled before this spec. |
| +## 2. The two decisions behind the shape |
| **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, 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. |
| +boxes poll (`schedule.TickEvery` 15s, `logship` 10s, `control/serial` |
| +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. |
| - |
| -> **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 |
| +**bytes**; the member's machine restores and queries. The console never |
| +executes tenant SQL, never holds a restored tenant database, and needs |
| +no query timeouts, row caps, or result-set memory budget. |
| + |
| +> The first draft justified this as "the console serves objects it |
| +> already stores". That is false — replicas live in customer data |
| +> buckets (§6). The client-side argument survives on its own merits, but |
| +> the transport is a genuinely new door, and §6 is 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 |
| -a copy" is a true statement about an artifact in their hand rather than |
| -a warning against an illusion. |
| +answer (§5): the user is handed an actual file, so "this is a copy" is a |
| +true statement about an artifact in their hand rather than a warning |
| +against an illusion. |
| Cost accepted: you transfer the whole database rather than one query's |
| rows, and freshness is replica freshness rather than live. |
| ## 3. What already exists |
| -Accurate as of 2026-09-02. There are **two** replicators in this |
| -platform and they are easy to conflate: |
| +Accurate as of 2026-09-02. There are **two** replicators 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. |
| + it via litestream's nil-`*DB` URL-restore pattern (`lease.go:390`). |
| + This is the set `carlos db` 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. |
| + executable (`ops/litestream.go:43`) driven by `/etc/litestream.yml`. |
| `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. |
| +`ops.FleetDBs` is the box-wide unit's set and **excludes** hibernating |
| +exec routes (`ops/hibernate_verify.go:5-8`) — the complement of the |
| +activator's set, not the whole. Box-wide replica keys are absolute paths |
| +(`ops/litestream.go:170`) and **collide across boxes sharing a fleet |
| +prefix** (`/data/registry.db`), which is one reason they stay out of v1. |
| 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. |
| + `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. |
| + (`activator.go:113`), prefix `instances` in production. Hosts are |
| + unique FQDNs (`activator.go:118-121`), so prefixes cannot collide |
| + across tenants. |
| - `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". |
| + (`go.mod:23`); litestream imports it, so the CLI already links it. The |
| + query engine embeds — no `sqlite3` on the user's machine, no cgo. |
| +- `internal/dbverify` records restore-drill verdicts keyed by **host** |
| + (`dbverify.go:45`), at one deployment-wide key (`dbverify.go:28`), |
| + published only by the hibernation drill. `Merge` drops hosts outside |
| + the writing box's fleet (`dbverify.go:60-80`), so the last box to run |
| + overwrites the others. A missing verdict renders `unverified`. |
| +- Console gates: `instanceGate` (`apiinstances.go:70`), `s.audit` |
| + (`server.go:2692`). The gate is **membership** via `AccountsFor(addr)` |
| + (`apicli.go:208`) — people carry no owner/member role. |
| **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. |
| +Fleet-wide instance knowledge is `apps/<sqid>/<app>/instances/<host>.json`. |
| +§6 and §8 both depend on this distinction. |
| ## 4. Command surface |
| @@ -127,50 +121,49 @@ 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 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. |
| +`ls` is the discovery verb and the one that makes the rest legible: per |
| +instance, the database the platform replicates, the age of the newest |
| +replicated write, whether the instance is replicating right now, its |
| +replication class *or* an honest "unknown" (§8), and its restore-drill |
| +verdict. It **also names databases declared in `data.dbs` that the |
| +platform does not replicate** (§7) — otherwise visible only in adopt |
| +logs a member cannot read. |
| `pull` writes the restored copy to a path you name and stops. `--out` is |
| -**required**, and must not already exist — a command that leaves a |
| -production database somewhere you did not name is not one we should |
| -ship. |
| +**required** and must not already exist. |
| `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). |
| +This is the closest thing to `rails console` 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, and there is no |
| +interpreter in the running binary to evaluate against its live heap. |
| `query` is the non-interactive form — one statement, results to stdout, |
| exit code reflecting the query. This is the shape an agent uses and |
| -should be the shape that pipes cleanly. |
| +should pipe 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. |
| +(`replica.go:604`) and creates `<out>.tmp` itself with `os.Create`, at |
| +the process umask (typically 0644). You **cannot** pre-create a 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. |
| +confines the `.tmp` and the `-wal`/`-shm` files a read-only open still |
| +needs (`check.go:96-98`). `shell` and `query` never leave that |
| +directory. `pull` restores there, then **copies** to `--out` with fsync |
| +and 0600 — a copy, not `os.Rename`, because the temp directory and the |
| +user's destination are routinely on different filesystems. Open |
| +question 2 is thereby answered: always 0600, and the user can loosen it. |
| ### Read-only is enforced, not assumed |
| 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 |
| +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 |
| @@ -183,13 +176,12 @@ single worst outcome this command could produce. |
| ## 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, 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. |
| +The safety property is a *fact the user has to hold in their head* — |
| +that this is 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. |
| -Exact strings are **not drafted in this spec**. They go through the |
| +Exact strings are **not drafted here**. They go through the |
| `copy-review` gate at implementation time, before being written into any |
| source file. What follows is what each string must accomplish. |
| @@ -197,35 +189,43 @@ source file. What follows is what each string must accomplish. |
| 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). |
| - |
| -**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`. |
| +"as of". |
| + |
| +**R2 — Age is a number, and carries its expected bound — or admits it |
| +cannot.** 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 replication class bound: an |
| +awake `daily` instance is legitimately up to 24h behind, exactly the |
| +case where an old copy is most easily mistaken for a current one. |
| + |
| +Where the class is **unknown** — which is the common case, because the |
| +console only knows it for routes on its own box (§8) — say so. It must |
| +never render as `stream` by default. A wrong bound is worse than no |
| +bound: it converts "I don't know" into "one second old". |
| + |
| +**R3 — The interactive prompt carries it too.** A `shell` session can |
| +outlive its banner by an hour. The prompt itself carries both the word |
| +copy/replica and the as-of time. |
| + |
| +**R4 — Refuse loudly, explain briefly.** Four refusals need their own |
| +voice: `replicate: "app"` (§7); the read-only write refusal (§4); the |
| +residency refusal (§6), which must name residency rather than read as a |
| +transient error; and the pooled-instance refusal (§6). 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. No |
| +`carlos` verb has a `--json` flag today, so this **introduces** one, and |
| +the fields are fixed here so the human and machine forms cannot drift: |
| + |
| +``` |
| +copy: true |
| +as_of # time of the newest replicated write (§8) |
| +replicated_txid |
| +lease_held: bool # is the instance replicating right now (§6, §8) |
| +sync_class # stream|batched|daily|unknown — never defaulted |
| +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 |
| @@ -233,77 +233,104 @@ not replicated (§7) is listed as such. "The platform has no copy of |
| ## 6. Transport: the replica door |
| -**This section contains the design's one unresolved decision.** |
| - |
| -### The problem |
| - |
| -The console cannot serve these bytes today, and the reason is a |
| -deliberate platform decision, not an oversight: |
| - |
| -- 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: |
| +**Decision: the console reads the region's data bucket directly.** |
| +Option B — having the owning box mirror replicas into a staging prefix |
| +in the deployment bucket — was considered and rejected. It needs a new |
| +box-side pass plus a sweeper, which contradicts §9's "no path that runs |
| +anything on the box"; its first-mirror latency is a CopyObject per LTX |
| +file; it lands tenant database bytes in the deployment bucket, which the |
| +moves design deliberately routed *around* (staging goes to a sibling |
| +data bucket, never the deployment bucket); and it does not even remove |
| +the data-bucket read, which `hibernate-verify` still needs. Its one |
| +advantage over A — cross-region reach — is a residency violation rather |
| +than a feature. |
| + |
| +### Configuration |
| + |
| +The console takes the same triple the agent reads — |
| +`CARLOS_HIBERNATE_BUCKET` / `_REGION` / `_PREFIX` — plus `CARLOS_REGION` |
| +for the placement compare, and opens one `*s3.Client` reusing |
| +`lease/endpoint.go`'s config path including `s3compat.Options()` and the |
| +endpoint override (`endpoint.go:110-131`). |
| + |
| +There is deliberately **no edges-file lookup** for another box's bucket. |
| +A bucket discovered that way is one IAM will refuse, so resolving it |
| +only converts a clear refusal into a confusing one. |
| + |
| +### Whether this needs an IAM change |
| + |
| +Probably not. The console opens S3 through the default credential chain |
| +(`cmd/console/main.go:394-399`) — the box's instance role, the same role |
| +the activator on that box uses to *write* `instances/<host>/`. If the |
| +console's box hosts any hibernating instance, the read authority is |
| +already there. If not, it is one read-only, in-region grant, smaller |
| +than the `iam:CreateUser`/`PutUserPolicy` the console already holds via |
| +`CARLOS_STORE_BACKEND=aws` (`objstore/aws.go:39-46`). |
| + |
| +### Resolution, and what is refused before S3 is touched |
| + |
| +`apps/<sqid>/<app>/instances/<host>.json` → `ValidHostShape` |
| +(`provision.go:250-262`, written as path-traversal defence — `path.Join` |
| +alone cleans `../x` to `x` rather than refusing it) → then three |
| +refusals, none of which make an S3 call: |
| + |
| +- `rec.Region != CARLOS_REGION` (both `""` in a single-region |
| + deployment) → residency refusal naming the region. |
| +- a `fleet/<name>` region (`console/fleets.go:535`) → residency refusal; |
| + the data is on the customer's own box. §13 records the v2. |
| +- `rec.Pool != ""` → refusal; a pooled record's data lives on the pool |
| + box, the same class `movespass.go:150-153` already refuses. |
| + |
| +An `AccessDenied` from S3 maps to the residency refusal as a backstop, |
| +never to a generic 502. |
| + |
| +### The door |
| + |
| +Two handlers, one gate, one rate budget: |
| - `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. |
| +- `get(app, instance, level, min_txid, max_txid)` with `Range` support |
| + |
| +Implement them as an HTTP adapter over litestream's own |
| +`lss3.ReplicaClient` **read methods**, with `Path` set per request. That |
| +way the server derives the key layout |
| +(`<prefix>/%04x/%016x-%016x.ltx`, `s3/replica_client.go:655-656`) from |
| +the same code the writer uses, and reader and writer cannot drift. |
| 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. |
| +unreachable. |
| + |
| +Following the edge store door's F8 precedent (`storedoor.go:55-59`): |
| +bytes **stream through the console**, never a presigned URL, and nothing |
| +the request supplies may influence which bucket or client is used. |
| + |
| +Six wire specifics the implementation must honour: |
| + |
| +1. `list` accepts **levels 0–9 inclusive**. `CalcRestorePlan` asks for |
| + `SnapshotLevel` (= 9, `compaction_level.go:9`) first, then 8 down to |
| + 0 (`replica.go:1452, 1476-1485`). The activator writes only 0–3 and |
| + 9; the door answers **empty** for the rest, never 404. |
| +2. Listings are ordered by `min_txid` **ascending** — |
| + `restoreLevelCursor` assumes filename order, which S3 gives because |
| + the names are zero-padded hex. |
| +3. `seek` and `useMetadata` are not on the wire. The client filters |
| + `seek` locally; nothing in a non-timestamp restore reads `CreatedAt` |
| + except debug logs. |
| +4. `size`, `min_txid` and `max_txid` are **mandatory**. `Restore` |
| + rejects `Size < ltx.HeaderSize` (`replica.go:630`) and |
| + `ResumableReader` uses size to detect a premature EOF |
| + (`resumable_reader.go:96`). Checksums are not needed. |
| +5. `get` Range semantics match the S3 client exactly: |
| + `bytes=<offset>-` when `size == 0`, else |
| + `bytes=<offset>-<offset+size-1>` (`s3/replica_client.go:647-652`). |
| + A missing object is a 404 the client maps to `os.ErrNotExist`. |
| +6. `ls` needs a third, **derived** field: `lease_held` (plus |
| + `expires_at`), computed console-side from `lock.json` |
| + (`leaser.go:31-35`; a released lease is deleted, `s3/leaser.go:180`). |
| + The lock's **body is never returned** — `Owner` is the box's |
| + identity. This is how §8's "is it replicating now" is answered |
| + without exposing an object coordinate addressing deliberately hides. |
| ### Who may open it |
| @@ -316,17 +343,17 @@ 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. |
| 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`. |
| +live, and CI has no business pulling production data. `admin` is refused |
| +**deliberately**, despite already reaching `store grant`. |
| Every successful open writes an `s.audit` row naming account, app, host, |
| database and the replicated TXID served. |
| ### The read-only replica client |
| -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 CLI implements litestream's `ReplicaClient` over this door, the way |
| +`internal/fleetapi` implements `release.Store` over the edge store door, |
| +and for the same reason: the member has no AWS credentials, by design. |
| The interface has eight methods (`replica_client.go:19-50`). This |
| implementation supplies the five a restore needs — `Type`, `Init`, |
| @@ -336,7 +363,7 @@ unconditionally. |
| **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. |
| +verification and the three conditions it depends on. |
| ## 7. Which database — one, not eight |
| @@ -346,12 +373,12 @@ only `DBs[0]` is stamped (`host/caps_pass.go:294`, whose own adopt |
| warning says so verbatim). The activator's `Replicate` takes one |
| `dbPath`. |
| -So there is no `--db` flag and no selection story. Instead: |
| +So there is no `--db` flag and no selection story: |
| - 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. |
| + one, required when there are several, because guessing hands 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). |
| @@ -370,32 +397,41 @@ 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`). |
| +counter from the source file on the box (`freshness.go:96-115`), which |
| +neither console nor CLI can reach. Any claimed distance from live would |
| +be invented. |
| + |
| +What the replica alone supports: |
| + |
| +1. **Age of the newest replicated write.** Take it from the **LTX |
| + header timestamp**, not S3 `LastModified` — a compacted file's |
| + `LastModified` is compaction time, and the `ltx` compactor carries |
| + `maxHdr.Timestamp` through (`compactor.go:118`). One ranged GET of |
| + `ltx.HeaderSize` bytes on the max-TXID file gives the true |
| + newest-write time at any level. Using `LastModified` would report an |
| + L3 compaction as a fresh write — a stale copy looking current, the |
| + exact failure this command exists to prevent. |
| +2. **Whether it is replicating now** — `lease_held`, derived from |
| + `lock.json` (§6). |
| +3. **The expected bound — when it is knowable.** `Replication` lives on |
| + `registry.Route` (`registry.go:126`) in the **box** registry; |
| + `provision.Record` has no such field. So the console knows the class |
| + only for routes on its own box. For an in-region sibling it is |
| + genuinely unknown, and R2 requires saying so rather than defaulting. |
| + §13 records the additive follow-up. |
| 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 |
| +therefore seconds behind live". That is wrong for the instances this |
| +targets, and wrong in the dangerous direction: classes are `""`/`stream` |
| +≈ 1s, `batched` 5m, `daily` 24h (`activator.go:205-230`, |
| +`lease.go:245-252`). An awake `daily` instance is **legitimately up to a |
| +day behind**, and reporting its age without its bound makes correct |
| +platform behaviour look like current data. |
| + |
| +Retention differs between the two replicators too: `SnapshotInterval` |
| +24h / `SnapshotRetention` 7d (`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**. |
| @@ -420,17 +456,18 @@ 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 |
| -sandbox (`DynamicUser`, `ProtectSystem=strict`, empty |
| +**Nothing new is reachable on the box, and nothing new runs there.** No |
| +door on the tenant process, no new agent verb, no pass in the converge |
| +loop. The sandbox (`DynamicUser`, `ProtectSystem=strict`, empty |
| `CapabilityBoundingSet`) is untouched, and the same-uid sibling residual |
| -documented in `activator/admintoken.go:8-12` is not widened, because no |
| -part of this design runs on the box at all. |
| +in `activator/admintoken.go:8-12` is not widened, because no part of |
| +this design runs on a box at all. Option B would have forfeited this |
| +sentence; A keeps it. |
| -**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. |
| +**One new read authority:** the console gains in-region, read-only |
| +access to the data bucket's `instances/*`. Bounded by residency — it |
| +cannot reach another region's data even if asked, and §6 refuses before |
| +trying rather than surfacing an `AccessDenied`. |
| **The failure mode to design against is not corruption, it is |
| confidence.** Every serious way this command hurts someone routes |
| @@ -445,17 +482,22 @@ Each row is a test. |
| | Condition | Result | |
| |---|---| |
| | 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) | |
| +| Any service principal (app token, or `publish`/`instance`/`operate`/`admin`) | Same byte-identical 404 — never a 403 | |
| | No such app | Byte-identical 404 | |
| | `replicate: "app"` | Refusal naming that cause specifically (§7) | |
| | Several instances, no `--instance` | Refusal listing the hosts | |
| -| Instance homed in another region | Residency refusal naming the region — never a generic 502 | |
| +| Instance in another region | Residency refusal naming the region — no S3 call | |
| +| Instance region `fleet/<name>` | Residency refusal — no S3 call (§13 records the v2) | |
| +| `rec.Pool != ""` | Refusal — data lives on the pool box | |
| +| S3 `AccessDenied` | Mapped to the residency refusal, 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) | |
| +| `list` for a level the activator never writes | Empty list, **not** 404 (§6.1) | |
| +| Replica changed under the restore | One retry around the whole `Restore` (§12) | |
| | Restore fails integrity check | Refuse to hand over the file; surface the `dbverify` verdict | |
| -| Instance asleep | Succeeds; reports age, class and parked state (§8) | |
| +| Instance asleep | Succeeds; reports age, `lease_held: false`, class or unknown | |
| | Instance awake on `daily` class | Succeeds; age reported beside its 24h bound (R2) | |
| +| Class not knowable (sibling box) | `sync_class: "unknown"`, never `stream` (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 | |
| @@ -465,24 +507,33 @@ Each row is a test. |
| Following `apischedule_test.go` and `internal/lease/check_test.go`: |
| -- **Door:** the full gate matrix above, plus a coordinate-confinement |
| - test asserting `lock.json` and any key outside the resolved prefix are |
| +- **Door:** the full gate matrix, 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. |
| +- **Wire conformance:** levels 0–9 answer empty rather than 404; |
| + listings ascend by `min_txid`; `size`/`min_txid`/`max_txid` always |
| + present; Range semantics match `s3/replica_client.go:647-652` for both |
| + the `size == 0` and sized forms. |
| - **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). |
| + stay at zero across a full restore. The second half is what catches a |
| + litestream upgrade changing the restore path. |
| +- **`ReplicaClientV3` negative assertion** — §12 condition (i). |
| - **Compile-time** `var _ litestream.ReplicaClient = (*client)(nil)`. |
| +- **Freshness:** a table over (age, `lease_held`, class-or-unknown, |
| + verdict) asserting the wording distinguishes asleep from failing, |
| + always carries the bound or admits it is unknown, and **never** |
| + renders unknown as `stream`. |
| +- **Age source:** a compacted L3 file whose `LastModified` is far newer |
| + than its LTX header timestamp reports the header time (§8.1). |
| - **Selection:** `caps.Data` fixtures for `DBs[1:]` unreplicated and the |
| `replicate: "app"` refusal. |
| -- **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. |
| +- **Permissions:** the temp directory is 0700, `--out` lands 0600, and |
| + `pull` succeeds when temp and destination are on different |
| + filesystems. |
| Definition of done includes a `docs/site/cli.md` heading — the |
| `internal/docsite` gate fails without one (AGENTS.md). |
| @@ -520,39 +571,63 @@ once and opens lazily, so the writer's L0 retention |
| `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. **§6's bucket decision — A or B.** Blocking; Paul's call. |
| -2. **`shell`'s prompt.** An embedded REPL is small code but a real |
| +`<out>.tmp`, renamed only on success (`replica.go:661-663`). |
| + |
| +The retry must wrap the **whole `Restore`**, not an individual read: |
| +`ResumableReader` treats `ErrNotExist` as fatal and does not retry |
| +(`resumable_reader.go:72-75`), so a vanished planned file needs a fresh |
| +plan, which only a new `Restore` produces. |
| + |
| +## 13. Open questions and follow-ups |
| + |
| +1. **Fleet-homed instances (v2).** §6 refuses them: their data is in the |
| + customer's bucket and IAM will not cross. The reverse tunnel |
| + (`edge/tunnel.go`) is a real synchronous edge→box path for exactly |
| + the hosts that dial in — so the v2 is the box serving its own replica |
| + coordinates over its tunnel channel, with the console never touching |
| + the customer's bucket. Different transport, same wire shape. |
| +2. **An additive `replication` field on the instance record.** Closes |
| + §8's `sync_class: "unknown"` for in-region siblings. Wire-additive |
| + and therefore allowed; worth doing regardless of this command. |
| +3. **`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 |
| + first, then `shell`. |
| +4. **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 |
| +5. **Past points in time.** Not offered. Given the activator's effective |
| + 24h `DefaultRetention` (§8), is "current replica only" the right |
| + permanent scope? |
| +6. **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). |
| +### Decomposition |
| + |
| +Three units, in order: (1) door + read-only client + `pull` + `query`; |
| +(2) `ls` and its freshness fields; (3) `shell`. |
| + |
| +## 14. What review changed |
| + |
| +**Round one.** The safety claim survived verification (§12). The |
| +transport did not: "the console serves objects it already stores" was |
| +false. Also corrected — a route replicates exactly one database, not up |
| +to eight; `daily` class is legitimately 24h behind, not "seconds behind |
| +live"; a 403 for service principals was an oracle; litestream is a |
| +dependency, not vendored; `FleetDBs` is the complement of the |
| +activator's set; the console has no registry snapshot; `dbverify` is |
| +keyed by host; the mode-0600 temp file was not constructible. |
| + |
| +**Round two.** Option A chosen and B rejected on its merits (§6), so §9 |
| +regains "nothing runs on the box". The edges-file lookup was dropped as |
| +buying nothing IAM will allow. Six wire specifics added, including |
| +levels 0–9 answering empty rather than 404 and mandatory `size`. Two new |
| +findings, both in the direction of overstating freshness: |
| + |
| +- **`sync_class` is unknowable off-box** — `Replication` is on |
| + `registry.Route`, not `provision.Record`. R2 now requires an explicit |
| + "unknown" that must never render as `stream`. |
| +- **Age must come from the LTX header timestamp**, not S3 |
| + `LastModified`, or a compaction reads as a fresh write. |
| + |
| +Plus `lease_held` as a derived `ls` field (answering "is it replicating" |
| +without exposing `lock.json`), the retry wrapping the whole `Restore`, |
| +and `pull` copying rather than renaming across filesystems. |