carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# `carlos db` — reading an app's database from its replica
2
3Design, 2026-09-02. Revised twice after review, same day (§14).
4
5## 1. What this is for
6
7There is no way to look at an app's data through the platform. The
8question "why does this row look wrong" currently has no `carlos` answer,
9which means it has an AWS answer — an SSM session onto the box and a
10`sqlite3` prompt against the live file. That violates the standing rule
11that every `carlos` command is designed for a user with no access to the
12underlying infra (AGENTS.md, Paul 2026-08-08), and it puts a hand-typed
13shell next to a production database for what is almost always a read.
14
15`carlos db` closes that gap by answering a deliberately narrower
16question. It does **not** connect you to the running database. It hands
17you a **restored copy of the replica** and lets you query that.
18
19The narrowing is the feature, not a compromise. It makes the command
20safe for a class of caller we specifically want to serve: an **agent**
21debugging a production problem. An agent that can read the data and
22cannot possibly change it is a useful colleague; an agent with a live
23`sqlite3` prompt on production is an incident waiting for a bad
24inference. Every design decision below is downstream of that.
25
26### Scope
27
28In: reading an app's platform-replicated SQLite, from the CLI, by an
29account member, as a local file, for **in-region, non-pooled,
30activator-replicated instances**.
31
32Out: writing to tenant data by any path; querying the live database;
33fleet-homed and cross-region instances (§13 — a v2 with a different
34transport); box-wide replicas; non-SQLite stores; the console web UI.
35
36## 2. The two decisions behind the shape
37
38**Replica, not live.** The alternative was a query door onto the running
39instance. That needs a synchronous console→instance path, which CARLOS
40does not have for ordinary boxes: the control plane is the bucket and
41boxes poll (`schedule.TickEvery` 15s, `logship` 10s, `control/serial`
422s). Building one would mean a new transport *and* a new door onto the
43tenant process, to deliver a capability whose main use is a read. The
44replica is already there, already off-box, and already proven restorable
45by the restore drills.
46
47**Client-side query, not console-side.** The console serves replica
48**bytes**; the member's machine restores and queries. The console never
49executes tenant SQL, never holds a restored tenant database, and needs
50no query timeouts, row caps, or result-set memory budget.
51
52> The first draft justified this as "the console serves objects it
53> already stores". That is false — replicas live in customer data
54> buckets (§6). The client-side argument survives on its own merits, but
55> the transport is a genuinely new door, and §6 is the hardest part of
56> this design rather than the easiest.
57
58The client-side choice also buys the microcopy problem's best available
59answer (§5): the user is handed an actual file, so "this is a copy" is a
60true statement about an artifact in their hand rather than a warning
61against an illusion.
62
63Cost accepted: you transfer the whole database rather than one query's
64rows, and freshness is replica freshness rather than live.
65
66## 3. What already exists
67
68Accurate as of 2026-09-02. There are **two** replicators and they are
69easy to conflate:
70
71- **The activator's embedded library.** litestream v0.5.14 is a `go.mod`
72 dependency (not vendored — there is no `vendor/`), linked into the
73 `carlos` binary. It replicates **hibernating exec-backed instances**
74 under the lease. `lease.Restore` (`lease/lease.go:378`) restores from
75 it via litestream's nil-`*DB` URL-restore pattern (`lease.go:390`).
76 This is the set `carlos db` serves.
77- **The box-wide binary.** A separately pinned litestream **0.5.15**
78 executable (`ops/litestream.go:43`) driven by `/etc/litestream.yml`.
79 `carlos ops restore-verify` shells out to it.
80
81`ops.FleetDBs` is the box-wide unit's set and **excludes** hibernating
82exec routes (`ops/hibernate_verify.go:5-8`) — the complement of the
83activator's set, not the whole. Box-wide replica keys are absolute paths
84(`ops/litestream.go:170`) and **collide across boxes sharing a fleet
85prefix** (`/data/registry.db`), which is one reason they stay out of v1.
86
87Other parts this design uses:
88
89- `lease.VerifyReplica` restores without taking the lease;
90 `check.go:66-79` documents why that cannot shadow the true holder.
91 §12 records how far that reasoning actually reaches.
92- `activator.HibernatePrefix(prefix, host)` = `path.Join(prefix, host)`
93 (`activator.go:113`), prefix `instances` in production. Hosts are
94 unique FQDNs (`activator.go:118-121`), so prefixes cannot collide
95 across tenants.
96- `modernc.org/sqlite` v1.55.0 is a direct dependency and **pure Go**
97 (`go.mod:23`); litestream imports it, so the CLI already links it. The
98 query engine embeds — no `sqlite3` on the user's machine, no cgo.
99- `internal/dbverify` records restore-drill verdicts keyed by **host**
100 (`dbverify.go:45`), at one deployment-wide key (`dbverify.go:28`),
101 published only by the hibernation drill. `Merge` drops hosts outside
102 the writing box's fleet (`dbverify.go:60-80`), so the last box to run
103 overwrites the others. A missing verdict renders `unverified`.
104- Console gates: `instanceGate` (`apiinstances.go:70`), `s.audit`
105 (`server.go:2692`). The gate is **membership** via `AccountsFor(addr)`
106 (`apicli.go:208`) — people carry no owner/member role.
107
108**The console has no registry snapshot.** It opens the *local*
109`registry.db` on its own box (`console/view.go:532-538`). The
110`control/registry-snapshot/<box>.db` objects boxes write
111(`host/snapshot_loop.go:34`) are read by nothing in `internal/console`.
112Fleet-wide instance knowledge is `apps/<sqid>/<app>/instances/<host>.json`.
113§6 and §8 both depend on this distinction.
114
115## 4. Command surface
116
117```
118carlos db ls --app <a> [--account <a>]
119carlos db pull --app <a> [--instance <host>] --out <path>
120carlos db shell --app <a> [--instance <host>]
121carlos db query --app <a> [--instance <host>] --sql <text>
122```
123
124`ls` is the discovery verb and the one that makes the rest legible: per
125instance, the database the platform replicates, the age of the newest
126replicated write, whether the instance is replicating right now, its
127replication class *or* an honest "unknown" (§8), and its restore-drill
128verdict. It **also names databases declared in `data.dbs` that the
129platform does not replicate** (§7) — otherwise visible only in adopt
130logs a member cannot read.
131
132`pull` writes the restored copy to a path you name and stops. `--out` is
133**required** and must not already exist.
134
135`shell` restores into a private directory, opens an interactive prompt
136against it with the embedded engine, and removes the directory on exit.
137This is the closest thing to `rails console` a Go platform can honestly
138offer: a REPL over your *data*, which exists, rather than over your
139*app*, which cannot — Go is compiled ahead of time, and there is no
140interpreter in the running binary to evaluate against its live heap.
141
142`query` is the non-interactive form — one statement, results to stdout,
143exit code reflecting the query. This is the shape an agent uses and
144should pipe cleanly.
145
146### Where the copy lands
147
148litestream's `Restore` refuses an existing output path
149(`replica.go:604`) and creates `<out>.tmp` itself with `os.Create`, at
150the process umask (typically 0644). You **cannot** pre-create a 0600
151file for it to fill.
152
153So: always restore inside an `os.MkdirTemp` directory created 0700. That
154confines the `.tmp` and the `-wal`/`-shm` files a read-only open still
155needs (`check.go:96-98`). `shell` and `query` never leave that
156directory. `pull` restores there, then **copies** to `--out` with fsync
157and 0600 — a copy, not `os.Rename`, because the temp directory and the
158user's destination are routinely on different filesystems. Open
159question 2 is thereby answered: always 0600, and the user can loosen it.
160
161### Read-only is enforced, not assumed
162
163Open with `file:<path>?mode=ro` — the form `lease.CheckDB` already uses
164(`check.go:36`) — plus `PRAGMA query_only=1`.
165
166This matters most for the agent case, for a non-obvious reason. A copy
167is already harmless to write to, so enforcement is not about protecting
168production. It is about **not letting a caller believe it changed
169production**. An agent that runs an `UPDATE` against a writable copy
170sees `1 row affected` and concludes it fixed the bug. Against a
171read-only copy it gets SQLite's `attempt to write a readonly database`,
172and the error is the truth. Silent success on a discarded write is the
173single worst outcome this command could produce.
174
175§11 pins that error string in a test, so the claim stays true.
176
177## 5. The microcopy contract
178
179The safety property is a *fact the user has to hold in their head* —
180that this is a copy, of a stated age, and that nothing they do here
181reaches production. If they lose that fact, the command becomes a way to
182make confident wrong decisions. The copy is load-bearing.
183
184Exact strings are **not drafted here**. They go through the
185`copy-review` gate at implementation time, before being written into any
186source file. What follows is what each string must accomplish.
187
188**R1 — Say it at handover, and name the source exactly.** The line
189reporting a successful restore states that this is a copy, and names
190host, database, the newest replicated transaction id and its time. Two
191pulls must be distinguishable, and an agent must be able to cite an
192"as of".
193
194**R2 — Age is a number, and carries its expected bound — or admits it
195cannot.** Report the **age of the newest replicated write**, never a
196"lag" (which implies a live comparison the client cannot make, §8) and
197never an adjective. Beside it, report the replication class bound: an
198awake `daily` instance is legitimately up to 24h behind, exactly the
199case where an old copy is most easily mistaken for a current one.
200
201Where the class is **unknown** — which is the common case, because the
202console only knows it for routes on its own box (§8) — say so. It must
203never render as `stream` by default. A wrong bound is worse than no
204bound: it converts "I don't know" into "one second old".
205
206**R3 — The interactive prompt carries it too.** A `shell` session can
207outlive its banner by an hour. The prompt itself carries both the word
208copy/replica and the as-of time.
209
210**R4 — Refuse loudly, explain briefly.** Four refusals need their own
211voice: `replicate: "app"` (§7); the read-only write refusal (§4); the
212residency refusal (§6), which must name residency rather than read as a
213transient error; and the pooled-instance refusal (§6). Per the repo's
214rule, store errors are logged rather than returned
215(`apischedule.go:145-147`) — they carry bucket names and request ids.
216
217**R5 — Written for two readers.** A human skims; an agent parses. No
218`carlos` verb has a `--json` flag today, so this **introduces** one, and
219the fields are fixed here so the human and machine forms cannot drift:
220
221```
222copy: true
223as_of # time of the newest replicated write (§8)
224replicated_txid
225lease_held: bool # is the instance replicating right now (§6, §8)
226sync_class # stream|batched|daily|unknown — never defaulted
227verified # ok|failed|unverified
228```
229
230**R6 — Name what is not there.** A database declared in `data.dbs` but
231not replicated (§7) is listed as such. "The platform has no copy of
232`audit.db`" must be said before someone goes looking for it.
233
234## 6. Transport: the replica door
235
236**Decision: the console reads the region's data bucket directly.**
237Option B — having the owning box mirror replicas into a staging prefix
238in the deployment bucket — was considered and rejected. It needs a new
239box-side pass plus a sweeper, which contradicts §9's "no path that runs
240anything on the box"; its first-mirror latency is a CopyObject per LTX
241file; it lands tenant database bytes in the deployment bucket, which the
242moves design deliberately routed *around* (staging goes to a sibling
243data bucket, never the deployment bucket); and it does not even remove
244the data-bucket read, which `hibernate-verify` still needs. Its one
245advantage over A — cross-region reach — is a residency violation rather
246than a feature.
247
248### Configuration
249
250The console takes the same triple the agent reads —
251`CARLOS_HIBERNATE_BUCKET` / `_REGION` / `_PREFIX` — plus `CARLOS_REGION`
252for the placement compare, and opens one `*s3.Client` reusing
253`lease/endpoint.go`'s config path including `s3compat.Options()` and the
254endpoint override (`endpoint.go:110-131`).
255
256There is deliberately **no edges-file lookup** for another box's bucket.
257A bucket discovered that way is one IAM will refuse, so resolving it
258only converts a clear refusal into a confusing one.
259
260### Whether this needs an IAM change
261
262Probably not. The console opens S3 through the default credential chain
263(`cmd/console/main.go:394-399`) — the box's instance role, the same role
264the activator on that box uses to *write* `instances/<host>/`. If the
265console's box hosts any hibernating instance, the read authority is
266already there. If not, it is one read-only, in-region grant, smaller
267than the `iam:CreateUser`/`PutUserPolicy` the console already holds via
268`CARLOS_STORE_BACKEND=aws` (`objstore/aws.go:39-46`).
269
270### Resolution, and what is refused before S3 is touched
271
272`apps/<sqid>/<app>/instances/<host>.json` → `ValidHostShape`
273(`provision.go:250-262`, written as path-traversal defence — `path.Join`
274alone cleans `../x` to `x` rather than refusing it) → then three
275refusals, none of which make an S3 call:
276
277- `rec.Region != CARLOS_REGION` (both `""` in a single-region
278 deployment) → residency refusal naming the region.
279- a `fleet/<name>` region (`console/fleets.go:535`) → residency refusal;
280 the data is on the customer's own box. §13 records the v2.
281- `rec.Pool != ""` → refusal; a pooled record's data lives on the pool
282 box, the same class `movespass.go:150-153` already refuses.
283
284An `AccessDenied` from S3 maps to the residency refusal as a backstop,
285never to a generic 502.
286
287### The door
288
289Two handlers, one gate, one rate budget:
290
291- `list(app, instance, level)` → `[{level, min_txid, max_txid, size, created_at}]`
292- `get(app, instance, level, min_txid, max_txid)` with `Range` support
293
294Implement them as an HTTP adapter over litestream's own
295`lss3.ReplicaClient` **read methods**, with `Path` set per request. That
296way the server derives the key layout
297(`<prefix>/%04x/%016x-%016x.ltx`, `s3/replica_client.go:655-656`) from
298the same code the writer uses, and reader and writer cannot drift.
299
300Addressing by coordinate rather than key is what keeps `lock.json`
301— which sits under the same prefix and is not a member's to fetch —
302unreachable.
303
304Following the edge store door's F8 precedent (`storedoor.go:55-59`):
305bytes **stream through the console**, never a presigned URL, and nothing
306the request supplies may influence which bucket or client is used.
307
308Six wire specifics the implementation must honour:
309
3101. `list` accepts **levels 0–9 inclusive**. `CalcRestorePlan` asks for
311 `SnapshotLevel` (= 9, `compaction_level.go:9`) first, then 8 down to
312 0 (`replica.go:1452, 1476-1485`). The activator writes only 0–3 and
313 9; the door answers **empty** for the rest, never 404.
3142. Listings are ordered by `min_txid` **ascending** —
315 `restoreLevelCursor` assumes filename order, which S3 gives because
316 the names are zero-padded hex.
3173. `seek` and `useMetadata` are not on the wire. The client filters
318 `seek` locally; nothing in a non-timestamp restore reads `CreatedAt`
319 except debug logs.
3204. `size`, `min_txid` and `max_txid` are **mandatory**. `Restore`
321 rejects `Size < ltx.HeaderSize` (`replica.go:630`) and
322 `ResumableReader` uses size to detect a premature EOF
323 (`resumable_reader.go:96`). Checksums are not needed.
3245. `get` Range semantics match the S3 client exactly:
325 `bytes=<offset>-` when `size == 0`, else
326 `bytes=<offset>-<offset+size-1>` (`s3/replica_client.go:647-652`).
327 A missing object is a 404 the client maps to `os.ErrNotExist`.
3286. `ls` needs a third, **derived** field: `lease_held` (plus
329 `expires_at`), computed console-side from `lock.json`
330 (`leaser.go:31-35`; a released lease is deleted, `s3/leaser.go:180`).
331 The lock's **body is never returned** — `Owner` is the box's
332 identity. This is how §8's "is it replicating now" is answered
333 without exposing an object coordinate addressing deliberately hides.
334
335### Who may open it
336
337`PrincipalUser` only. Every service principal — the legacy app token
338(role `instance`) and every named service credential
339(`publish`/`instance`/`operate`/`admin`) — gets the same
340`http.NotFound` as a stranger.
341
342A 403 would be an oracle: it tells any token holder that a db door
343exists for this app. The gate ethos here is one byte-identical 404.
344
345No legitimate caller loses anything — an app reads its own database
346live, and CI has no business pulling production data. `admin` is refused
347**deliberately**, despite already reaching `store grant`.
348
349> **Revision, 2026-10-01 (implementation; Paul may overrule).** Service
350> principals get a 403, not the 404 above. The two door routes are
351> registered with the zero role Set, the repo's closed-door mechanism
352> since 2026-08-18, and the shared `cliRoute` gate answers every service
353> credential with one constant 403 ("closed to service credentials")
354> BEFORE any account or app lookup. The body is the same for every role,
355> account and app, so it reveals only that the door exists, which is
356> public in the code, and nothing about this app or account. Members who
357> are not in the account, and unknown apps and instances, still get the
358> byte-identical 404. A literal 404 would need either a role Set the
359> handler then overrides, or a change to the shared gate for every closed
360> door.
361
362Every successful open writes an `s.audit` row naming account, app, host,
363database and the replicated TXID served.
364
365### The read-only replica client
366
367The CLI implements litestream's `ReplicaClient` over this door, the way
368`internal/fleetapi` implements `release.Store` over the edge store door,
369and for the same reason: the member has no AWS credentials, by design.
370
371The interface has eight methods (`replica_client.go:19-50`). This
372implementation supplies the five a restore needs — `Type`, `Init`,
373`LTXFiles`, `OpenLTXFile`, `SetLogger` — and the three mutating ones
374(`WriteLTXFile`, `DeleteLTXFiles`, `DeleteAll`) return an error
375unconditionally.
376
377**The client cannot corrupt the replica, because it has no code that
378writes to one.** Not "is not supposed to" — has none. §12 records the
379verification and the three conditions it depends on.
380
381## 7. Which database — one, not eight
382
383`caps.Data` allows up to eight names (`maxDBs = 8`), but **a route
384replicates exactly one database**: `Route.DBPath` is single-valued and
385only `DBs[0]` is stamped (`host/caps_pass.go:294`, whose own adopt
386warning says so verbatim). The activator's `Replicate` takes one
387`dbPath`.
388
389So there is no `--db` flag and no selection story:
390
391- The replicated database is implied by the instance.
392- `--instance` selects among an app's instances — inferred when there is
393 one, required when there are several, because guessing hands someone
394 the wrong data while telling them it is the right data.
395- `ls` names the **declared-but-unreplicated** entries (`DBs[1:]`),
396 surfacing `dataAdvice`'s warning to a member who cannot read adopt
397 logs (R6).
398
399### `replicate: "app"` has no replica
400
401An app declaring `replicate: "app"` streams its own WAL and the platform
402replicates **nothing** for it (`caps.DataReplicateApp`; `caps.go:416`
403refuses `data.dbs` alongside it precisely to keep the two-replicators
404hazard unreachable).
405
406This must fail naming that cause. The dangerous failure is a generic "no
407replica found", which reads as transient and invites a retry loop; the
408operator needs to know the platform was never going to have this data.
409
410## 8. Age, and the three things that are not lag
411
412The client **cannot compute lag**. `ops.freshness` gets the live change
413counter from the source file on the box (`freshness.go:96-115`), which
414neither console nor CLI can reach. Any claimed distance from live would
415be invented.
416
417What the replica alone supports:
418
4191. **Age of the newest replicated write.** Take it from the **LTX
420 header timestamp**, not S3 `LastModified` — a compacted file's
421 `LastModified` is compaction time, and the `ltx` compactor carries
422 `maxHdr.Timestamp` through (`compactor.go:118`). One ranged GET of
423 `ltx.HeaderSize` bytes on the max-TXID file gives the true
424 newest-write time at any level. Using `LastModified` would report an
425 L3 compaction as a fresh write — a stale copy looking current, the
426 exact failure this command exists to prevent.
4272. **Whether it is replicating now** — `lease_held`, derived from
428 `lock.json` (§6).
4293. **The expected bound — when it is knowable.** `Replication` lives on
430 `registry.Route` (`registry.go:126`) in the **box** registry;
431 `provision.Record` has no such field. So the console knows the class
432 only for routes on its own box. For an in-region sibling it is
433 genuinely unknown, and R2 requires saying so rather than defaulting.
434 §13 records the additive follow-up.
4354. **The drill verdict** — `dbverify`, per host, hibernation drill only.
436
437The first draft claimed "no explicit sync interval, so 1s default,
438therefore seconds behind live". That is wrong for the instances this
439targets, and wrong in the dangerous direction: classes are `""`/`stream`
440≈ 1s, `batched` 5m, `daily` 24h (`activator.go:205-230`,
441`lease.go:245-252`). An awake `daily` instance is **legitimately up to a
442day behind**, and reporting its age without its bound makes correct
443platform behaviour look like current data.
444
445Retention differs between the two replicators too: `SnapshotInterval`
44624h / `SnapshotRetention` 7d (`ops/litestream.go:64-65`) reach only
447`/etc/litestream.yml`. The activator's embedded store never sets
448`Retention`, so hibernating replicas run on litestream's
449`DefaultRetention` of **24h**.
450
451### A sleeping instance is consistent, not necessarily complete
452
453`stop()` logs and swallows a failed final checkpoint
454(`activator.go:876-881`) — the hole the instance-move spec had to add a
455re-checkpoint for. A parked replica is consistent at a *committed*
456boundary (LTX carries committed frames only) but may trail the sleep
457point.
458
459So: "consistent as of its last successful sync, which on a clean sleep
460is the sleep point" — never "complete as of when it went to sleep".
461`carlos restart` and Bounce go through the same `stop()`.
462
463## 9. Security posture
464
465**The exposure is real and is the point of the gate.** This command
466moves production data onto a laptop. Read-only does not reduce that; a
467copy leaks exactly as well as an original. Hence: members only, no
468service principals of any role, an audit row per open, `--out` required
469and refused if it exists, 0600 on anything kept, a 0700 private
470directory for anything temporary.
471
472**Nothing new is reachable on the box, and nothing new runs there.** No
473door on the tenant process, no new agent verb, no pass in the converge
474loop. The sandbox (`DynamicUser`, `ProtectSystem=strict`, empty
475`CapabilityBoundingSet`) is untouched, and the same-uid sibling residual
476in `activator/admintoken.go:8-12` is not widened, because no part of
477this design runs on a box at all. Option B would have forfeited this
478sentence; A keeps it.
479
480**One new read authority:** the console gains in-region, read-only
481access to the data bucket's `instances/*`. Bounded by residency — it
482cannot reach another region's data even if asked, and §6 refuses before
483trying rather than surfacing an `AccessDenied`.
484
485**The failure mode to design against is not corruption, it is
486confidence.** Every serious way this command hurts someone routes
487through a caller believing old data is current, or believing a write
488landed. §5 and §4's read-only enforcement exist for that; they are not
489decoration on the security story, they are most of it.
490
491## 10. Failure matrix
492
493Each row is a test.
494
495| Condition | Result |
496|---|---|
497| Not a member of the account | Byte-identical 404, matching `instanceGate` |
498| Any service principal (app token, or `publish`/`instance`/`operate`/`admin`) | Same byte-identical 404 — never a 403 |
499| No such app | Byte-identical 404 |
500| `replicate: "app"` | Refusal naming that cause specifically (§7) |
501| Several instances, no `--instance` | Refusal listing the hosts |
502| Instance in another region | Residency refusal naming the region — no S3 call |
503| Instance region `fleet/<name>` | Residency refusal — no S3 call (§13 records the v2) |
504| `rec.Pool != ""` | Refusal — data lives on the pool box |
505| S3 `AccessDenied` | Mapped to the residency refusal, never a generic 502 |
506| Declared-but-unreplicated db named | Refusal naming it as never replicated (§7) |
507| Nothing replicated yet | "Nothing replicated yet" — match **both** `ErrNoSnapshots` and `ErrTxNotAvailable` (`lease.go:394`) |
508| `list` for a level the activator never writes | Empty list, **not** 404 (§6.1) |
509| Replica changed under the restore | One retry around the whole `Restore` (§12) |
510| Restore fails integrity check | Refuse to hand over the file; surface the `dbverify` verdict |
511| Instance asleep | Succeeds; reports age, `lease_held: false`, class or unknown |
512| Instance awake on `daily` class | Succeeds; age reported beside its 24h bound (R2) |
513| Class not knowable (sibling box) | `sync_class: "unknown"`, never `stream` (R2) |
514| Write attempted in `shell`/`query` | SQLite `attempt to write a readonly database`, surfaced plainly |
515| `--out` omitted, or already exists | Usage error |
516| Host failing `ValidHostShape` | Refused before it becomes a key segment |
517| Client asked to write a replica | Unconditional error — no code path exists |
518
519## 11. Testing shape
520
521Following `apischedule_test.go` and `internal/lease/check_test.go`:
522
523- **Door:** the full gate matrix, plus a coordinate-confinement test
524 asserting `lock.json` and any key outside the resolved prefix are
525 unreachable. That test is what keeps this from becoming an object
526 proxy as the code ages.
527- **Wire conformance:** levels 0–9 answer empty rather than 404;
528 listings ascend by `min_txid`; `size`/`min_txid`/`max_txid` always
529 present; Range semantics match `s3/replica_client.go:647-652` for both
530 the `size == 0` and sized forms.
531- **Replica client, both halves:** the three mutating methods error
532 without touching the network **and** a call-counting fake asserts they
533 stay at zero across a full restore. The second half is what catches a
534 litestream upgrade changing the restore path.
535- **`ReplicaClientV3` negative assertion** — §12 condition (i).
536- **Compile-time** `var _ litestream.ReplicaClient = (*client)(nil)`.
537- **Freshness:** a table over (age, `lease_held`, class-or-unknown,
538 verdict) asserting the wording distinguishes asleep from failing,
539 always carries the bound or admits it is unknown, and **never**
540 renders unknown as `stream`.
541- **Age source:** a compacted L3 file whose `LastModified` is far newer
542 than its LTX header timestamp reports the header time (§8.1).
543- **Selection:** `caps.Data` fixtures for `DBs[1:]` unreplicated and the
544 `replicate: "app"` refusal.
545- **Read-only:** an `UPDATE` in `query` fails with the pinned error
546 string, and the file on disk is byte-identical afterwards.
547- **Permissions:** the temp directory is 0700, `--out` lands 0600, and
548 `pull` succeeds when temp and destination are on different
549 filesystems.
550
551Definition of done includes a `docs/site/cli.md` heading — the
552`internal/docsite` gate fails without one (AGENTS.md).
553
554## 12. Why the safety claim holds
555
556Verified against litestream v0.5.14 during review, because §6's central
557claim depends on it.
558
559`Replica.Restore` (`replica.go:544-720`) → `CalcRestorePlan`
560(`replica.go:1485ff`, `LTXFiles` per level) → `NewResumableReader`,
561whose client parameter is the narrow `LTXFileOpener` interface
562(`internal/resumable_reader.go:51,72`) — `OpenLTXFile` only.
563`WriteLTXFile`, `DeleteLTXFiles` and `DeleteAll` are reached only from
564`Sync`, `EnforceRetention`, and `db.go`'s snapshot/retention paths
565(`replica.go:192,312`; `db.go:2470,2526,2649`), none of which run
566without a `*DB`/`Store`. With a nil `*DB`, they are unreachable.
567
568Three conditions this depends on. Each is a test in §11:
569
5701. **The client must not implement `ReplicaClientV3`** (`v3.go:145`).
571 `Restore` type-asserts for it (`replica.go:612`) and would take the
572 v0.3 path instead.
5732. **Never set `RestoreOptions.Follow`.** It writes into the local
574 output and a `-txid` sidecar, and keeps polling.
5753. **Run `lease.CheckDB` after the restore** rather than relying on
576 `opt.IntegrityCheck`, matching `lease.Restore`'s existing pattern.
577
578### What the claim does *not* cover
579
580`check.go`'s reasoning is about not *shadowing the writer*. It does not
581promise the reader a stable listing. `CalcRestorePlan` lists every level
582once and opens lazily, so the writer's L0 retention
583(`DefaultL0Retention` 5m, checked hourly by the activator,
584`lease.go:78`) can delete a planned file before it is opened.
585
586The outcome is a **failed** restore, never a torn one: the output is
587`<out>.tmp`, renamed only on success (`replica.go:661-663`).
588
589The retry must wrap the **whole `Restore`**, not an individual read:
590`ResumableReader` treats `ErrNotExist` as fatal and does not retry
591(`resumable_reader.go:72-75`), so a vanished planned file needs a fresh
592plan, which only a new `Restore` produces.
593
594## 13. Open questions and follow-ups
595
5961. **Fleet-homed instances (v2).** §6 refuses them: their data is in the
597 customer's bucket and IAM will not cross. The reverse tunnel
598 (`edge/tunnel.go`) is a real synchronous edge→box path for exactly
599 the hosts that dial in — so the v2 is the box serving its own replica
600 coordinates over its tunnel channel, with the console never touching
601 the customer's bucket. Different transport, same wire shape.
6022. **An additive `replication` field on the instance record.** Closes
603 §8's `sync_class: "unknown"` for in-region siblings. Wire-additive
604 and therefore allowed; worth doing regardless of this command.
6053. **`shell`'s prompt.** An embedded REPL is small code but a real
606 surface (history, formatting, dot commands). Ship `ls`/`pull`/`query`
607 first, then `shell`.
6084. **Console web view.** Out of scope, but §6's door is what it would
609 use; confirm the coordinate-addressed shape does not foreclose it.
6105. **Past points in time.** Not offered. Given the activator's effective
611 24h `DefaultRetention` (§8), is "current replica only" the right
612 permanent scope?
6136. **Rate limiting.** Pulls are large; neither the token-keyed instance
614 burst nor the address-keyed write budget fits. Needs its own budget.
615
616### Decomposition
617
618Three units, in order: (1) door + read-only client + `pull` + `query`;
619(2) `ls` and its freshness fields; (3) `shell`.
620
621## 14. What review changed
622
623**Round one.** The safety claim survived verification (§12). The
624transport did not: "the console serves objects it already stores" was
625false. Also corrected — a route replicates exactly one database, not up
626to eight; `daily` class is legitimately 24h behind, not "seconds behind
627live"; a 403 for service principals was an oracle; litestream is a
628dependency, not vendored; `FleetDBs` is the complement of the
629activator's set; the console has no registry snapshot; `dbverify` is
630keyed by host; the mode-0600 temp file was not constructible.
631
632**Round two.** Option A chosen and B rejected on its merits (§6), so §9
633regains "nothing runs on the box". The edges-file lookup was dropped as
634buying nothing IAM will allow. Six wire specifics added, including
635levels 0–9 answering empty rather than 404 and mandatory `size`. Two new
636findings, both in the direction of overstating freshness:
637
638- **`sync_class` is unknowable off-box** — `Replication` is on
639 `registry.Route`, not `provision.Record`. R2 now requires an explicit
640 "unknown" that must never render as `stream`.
641- **Age must come from the LTX header timestamp**, not S3
642 `LastModified`, or a compaction reads as a fresh write.
643
644Plus `lease_held` as a derived `ls` field (answering "is it replicating"
645without exposing `lock.json`), the retry wrapping the whole `Restore`,
646and `pull` copying rather than renaming across filesystems.
647