carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Instances that start from a database: `--from` (#260) and the import door (#261)
2
3**Status:** rev 7, 2026-10-08. Rev 3 answered round 2 (§12), rev 4 round 3 (§13), rev 5 round 4 (§14), rev 6 round 5 (§15), and rev 7 round 6 (§16). Paul approved rev 1 (c66792a3). Rev 2
4answers Astra's round 1 (`CODEX-FINDINGS-RUN-AND-SEEDED-R1.md`; findings
58 to 20 are this spec's). Each change is marked **(codex N)**, and §11 lists
6the dispositions.
7
8**Discussions:** amadan carlos/platform #260 and #261. Companion spec:
9`2026-10-08-carlos-run-design.md` (#259).
10
11## 1. The problem
12
13Two requests from Tito share one mechanism.
14
15- **#260, a canary with real data.** The commonest ask from Tito's team is
16 "make me a canary with this account's real data". Today that takes root
17 over SSM: copy the file, chown, register, grant. `carlos db pull` gives a
18 member a read-only copy on their laptop, but there's no way back in.
19 `instances create` can't start from data, and `Manifest.Seed` is a
20 first-boot argv (`provision.go:233`), not data.
21- **#261, converting controld instances.** About 71 `titogo@<host>` units
22 on carlos-tito-ie run outside the platform, each behind an addr-backed
23 row. Paul ruled on 2026-10-08 that every one of them converts to
24 platform-run, and that nothing new is built for controld instances.
25 - The import door the conversion needs was designed in
26 `2026-09-24-instance-moves-for-every-app-design.md` §9 (gap G9), on the
27 unmerged `docs-instance-moves-design` branch.
28 - The moves spec that replaced it (`2026-10-02-move-any-app-design.md`)
29 dropped it. That spec's "adoption" is the host claim taking over legacy
30 platform records, not importing an instance the platform never ran.
31
32Both requests come down to one thing: create an instance whose database
33starts as a given one.
34
35## 2. Rulings (Paul, 2026-10-08)
36
371. **One mechanism, two sources.** A sibling's replica (`--from`) and an
38 uploaded snapshot (import) both become one object, which the box places
39 on first wake.
402. **Uploads go through the console**, not a presigned URL. This departs
41 from §9 of the 09-24 design, following the console's own rule (F8,
42 `apidb.go:299`, `internal/edge/storedoor.go`): bytes pass through the
43 console, and nothing a request supplies picks a bucket or a key.
443. **`--from` stays in the source's region.** To get to another region,
45 run `carlos move` afterwards, as §9 said for adoption.
464. **The new instance gets its own environment**, never the source's
47 per-host one, and **an empty private store**.
485. **Who may, for both doors:** any person who is a member of the account,
49 plus service credentials with the `instance` role. "This is day-to-day
50 operator stuff ... this needs to be something developers can do."
51 Stricter permissions may come later.
526. **The swap accepts an operator-filed row.** Tito's addr rows are filed
53 under the deployment's operator account (G17), so a strict same-account
54 check would refuse all 71. Rev 2 binds this exception to an operator's
55 assignment (§6.4, codex 9).
567. **Platform-run only, nested only.**
57
58## 3. The shared step: a seed snapshot
59
60### 3.1 The object
61
62The snapshot lives at `seeds/<host>/<id>/snapshot.db` in the home
63region's data bucket, beside the `instances/` and `moves/` prefixes.
64`<id>` is the seed's `ID` (§3.2). Every write and delete of a snapshot
65names its seed `ID`, so cleaning up one seed can never touch another seed
66of the same host (codex R4-1).
67
68- The console writes it. The owning box reads it and deletes it.
69- The console's region is the home region, the only one whose data bucket
70 it may write (`resolveReplica`'s residency rule, `apidb.go:54-71`). So
71 both doors refuse a record homed anywhere else, with the existing
72 `ErrResidency` message.
73- **There is no lifecycle rule on `seeds/`** (codex R2-5). A `--from`
74 instance that nobody wakes for a week still needs its snapshot. Instead,
75 the console's deadline sweep deletes `seeds/<host>/<id>/` only in three
76 cases:
77 - no record names it, and the snapshot is more than an hour old. Every
78 door writes its record **before** the snapshot (§4 step 7, §5.1), so a
79 snapshot without a record is from a door that died between the two
80 writes (codex R3-2);
81 - the record's seed is `placed` or `committed` and has another ID;
82 - it belongs to an import stuck in `awaiting-upload` or `uploaded` for
83 more than 7 days, or to a `--from` stuck in `reserving` for more than an
84 hour. The sweep moves the record to the terminal `aborted` by CAS
85 first, and deletes nothing unless that CAS succeeds (§5.6).
86
87### 3.2 The record
88
89`provision.Record` gains:
90
91```go
92Seed *SeedSource `json:"seed,omitempty"`
93
94type SeedSource struct {
95 ID string `json:"id"` // random, minted with the seed; never reused
96 Kind string `json:"kind"` // "replica" | "import"
97 From string `json:"from,omitempty"` // replica: the source host
98 TXID string `json:"txid,omitempty"` // replica: the TXID actually restored
99 SHA256 string `json:"sha256"` // of snapshot.db
100 Bytes int64 `json:"bytes"`
101 State string `json:"state"` // §3.2 states
102 Reason string `json:"reason,omitempty"` // set with "refused"
103 By string `json:"by"` // the caller
104 At time.Time `json:"at"`
105 // import only:
106 Replaces string `json:"replaces,omitempty"` // the addr the swap replaces
107 VerifyHash string `json:"verify_hash,omitempty"` // sha256 of the verify token (§5.4)
108}
109```
110
111**States.**
112
113| Door | Path through the states |
114|---|---|
115| `--from` | `reserving` → `ready` → `placed` |
116| import | `awaiting-upload` → `uploaded` → `ready` → `swapped` → `placed` → `committed` |
117
118Either kind can also be abandoned. Its record then goes `aborted` →
119`rolled-back`, and is deleted (§5.6).
120
121An import can also leave the path:
122
123- `refused`: the box would not swap; `Reason` says why.
124- `aborted`, and then the record is deleted.
125
126**What reconcile does with each state** (codex 13). It reads the import
127journal first (§5.7). Then it dispatches on the seed state **before** any
128other per-record handling:
129
130| State | Reconcile |
131|---|---|
132| `aborted` | runs the idempotent rollback (§5.6), or, with no journal row, deletes `seeds/<host>/<id>/` and CASes the record to `rolled-back` |
133| `rolled-back` | skips the record; the console finishes it (§5.6) |
134| `reserving`, `awaiting-upload`, `uploaded`, `refused` | skips the record; nothing is minted |
135| `ready` | mints (`--from`) or swaps (import) |
136| `swapped`, `placed`, `committed` | ordinary handling, plus the import gate (§5.4) while not `committed` |
137
138**`Seed` stays on the record after placement.** It is the provenance
139`instances list` shows.
140
141### 3.3 The seed floor (codex 16)
142
143An old box, or an old binary a box recovers to, would read a seeded record
144as an ordinary new instance and mint it empty. A seeded instance that comes
145up empty looks healthy and is wrong. So seeds get a protocol floor, built
146on the moves floor's mechanism (`internal/moves/floor.go`):
147
148- **The floor object.** An operator publishes
149 `control/seeds-protocol.json`.
150- **What a box does.** A box running a seed-capable release arms the floor
151 locally. Once armed, it gives a verdict on `carlos.prev`, and the
152 recovery units refuse a `carlos.prev` below the floor, exactly as they do
153 for the moves floor (`carlos-system-recover.service`).
154- **What the console checks.** It writes no seed state at all, not even
155 `awaiting-upload`, until every box in the home region reports, in its
156 status, the seeds floor armed and `carlos.prev` either excluded or
157 seed-capable. Otherwise the door answers 409 and names the boxes.
158
159### 3.4 Placing the snapshot on first wake (codex 8)
160
161**This version: the plain restore path only.** On a box with claim control
162armed, both doors refuse with `refused: "claim"`. The claim's launch path
163doesn't run tenant code yet (`rolelaunch_linux.go:23-28`;
164`instancectl/server.go:840`), and its restore never trusts a local file
165(`hostbox/restore.go:142`, `host/claimwakeassembly.go:252`). The claim
166algorithm is set out at the end of this section for #250 to build.
167
168**The plain path.** One more fallback goes into `ExecBackend.Restore`
169(`backend_exec.go:611`), beside the staging fallback (`:633-645`). It
170applies only to a row whose record seed is `ready`, `swapped` or `placed`:
171
1721. **Is there already data?**
173 - If the host has any replica of its own, it has been placed already.
174 Restore normally, and ignore the seed.
175 - A local database counts only with **installation evidence** (codex
176 R2-1). Step 3 writes `<db>.seed`, which names the seed's `ID`, next to
177 the database. If the local file's marker names the record's current
178 seed `ID`, it is this seed, placed by an interrupted first wake: use
179 it, and go to step 4.
180 - Anything else is purged before step 2, through `instancefs`:
181 - a local database with no marker, or with a marker naming another ID;
182 - its `-wal` and `-shm` files;
183 - litestream's local metadata.
184
185 So a database left behind by an earlier, aborted import of the same
186 host can never stand in for a new one.
1872. **Fetch and check.** Read `seeds/<host>/<id>/snapshot.db` into a temp file
188 inside the instance's directory. Check its length and sha256 against
189 the record, then run `lease.CheckDBContext` (integrity, and at least one
190 table).
1913. **Place it.** Write `<db>.seed` naming the seed `ID`, then rename the
192 temp file onto the row's database path. Both go through `instancefs`,
193 owned by the instance, in the nested tree. The marker is written first,
194 so a database at that path always has its marker.
1954. **Replicate.** Start replication. The first upload is generation one of
196 the host's own lineage. Nothing of the source's lineage is copied.
1975. **Advance the state.** After the first upload succeeds, set the seed to
198 `placed` and delete `seeds/<host>/<id>/`.
199
200**Failures.** Any failure in steps 2 to 4 fails the wake. The reason goes
201to the box log and the `carlos logs` sink. It **never** falls through to an
202empty database and the manifest's seed command. The manifest's seed
203command is never run for a seeded row. The snapshot is deleted only after
204the host's own replica exists, so a crash at any point retries from either
205the placed local file or the snapshot.
206
207**The claim algorithm, for #250.** This is not built here.
208
209- Select the remote baseline first.
210- If no baseline exists and the record still carries an unplaced seed:
211 purge the local state, and install the snapshot as the admitted
212 generation's content, whatever local file exists.
213- Publish the generation before the seed is marked `placed` and its
214 snapshot deleted.
215
216## 4. `--from`: a copy of a sibling (#260)
217
218```
219carlos instances create --app <app> --host <new> --from <host> [--at <txid>] [--environment <env>] [--channel <c>]
220```
221
222**The gate (codex 17).** The door authorizes through `cliRoute` with the
223`instance` role, plus the membership check for people. It does **not**
224call `dbGate`, which accepts only people (`apidb.go:110`); the db routes'
225own refusal of every service role (`authcli.go:690`) stays as it is. The
226source checks inside `dbGate` are factored into `resolveDBSource`:
227
228- the record exists;
229- same account and app;
230- not pooled, and not on a fleet;
231- residency;
232- the versioned claim read when claims are on.
233
234Both doors call it.
235
236**The console's steps:**
237
2381. Apply `instances create`'s existing checks to the new host, and the
239 seed floor (§3.3).
2402. Resolve the source with `resolveDBSource`.
2413. Refuse a source and target of different apps or accounts (409).
242 Customer data never leaves its account by this door.
2434. **Plan the restore (codex 18).** `RestoreTo` gains a target TXID, its
244 zero value meaning "latest", and passes it through to litestream's
245 `RestoreOptions.TXID` (litestream v0.5.14 `db.go:2823-2824`). It returns
246 the TXID actually restored. An `--at` older than the replica's retained
247 history is refused with "history before <txid> is no longer kept".
248
249 **`--at <time>` is left out of this version** (codex R2-18). A
250 timestamp restore needs each LTX file's transaction time, and the db
251 door's client ignores `useMetadata` (`dbdoor/client.go:78`). The console
252 listing asks for `false` (`apidb.go:273`), so a time would be matched
253 against S3's modification time instead. Adding times means carrying
254 metadata through both, which is its own change.
2555. **Admit the restore (codex 19, R2-19).** Before downloading anything:
256 - **Work out the restored size exactly.** Read the header of the last
257 planned LTX file, with a ranged GET of its first page. Its `Commit`
258 (the database's page count at that TXID) times its `PageSize` is the
259 size of the database the restore will produce. The compressed sizes
260 of the LTX files say nothing about that.
261 - **Refuse with 413** if the restored size is over 4 GiB.
262 - **Reserve scratch.** Take the restored size plus the sum of the
263 planned LTX sizes from the **scratch reservation pool**.
264
265 **The scratch reservation pool.** One in-process counter in the
266 console, shared by `--from` restores and import spools (§5.2):
267 - the pool's capacity is the scratch filesystem's free space at console
268 start, less 1 GiB, re-read whenever the pool is empty;
269 - a reservation that doesn't fit is refused with 507;
270 - reservations are released on every exit path.
271
272 Restores and spools are also limited together to one at a time per app
273 and two across the deployment, with 429 beyond that. A restore runs
274 under a 30-minute deadline. While it runs, a watcher stats the output
275 every second and cancels the restore if the file grows past the
276 restored size it reserved.
2776. **Restore and check.** Restore with `RestoreTo` into a private 0700
278 directory under the console's scratch, `<console data>/scratch/seeds/`.
279 Run `CheckDBContext` under the same deadline. This never reads the live
280 file.
2817. **Write** (codex R3-2), in this order:
282 1. **Reserve the record.** This happens before the restore, right after
283 step 3. Create the record with seed `reserving`, kind `replica`, its
284 seed `ID` and the source. Nothing mints a `reserving` record, and the
285 sweep leaves its snapshot alone (§3.1).
286 2. **Upload.** Write `seeds/<new>/<id>/snapshot.db` with `PutFrom` from the
287 restored file (seekable, under S3's 5 GB single-PUT limit).
288 3. **Publish.** CAS the record from `reserving` to `ready`, with the
289 restored TXID, sha256 and bytes.
290
291 If the door fails after step 1, it CASes the record to `aborted`,
292 which the box (or the sweep) cleans up as for an import that never
293 swapped.
294
295**Cleanup.** The scratch is deleted on every exit path: success, refusal,
296error, deadline, and client disconnect. The console also empties
297`scratch/seeds/` when it starts.
298
299**Environment.** The new instance gets its own. With private storage, it is
300always the host-derived one (see Storage below). Without private storage,
301it is `--environment` if given, or the app's default otherwise. Never the source's per-host
302environment, which carries host-keyed values such as the origin and the
303cookie domain (the warning in `instances_cli.go:235-245`).
304
305**Storage** (codex R5-1). An app with private storage must get a new,
306empty store, and an ordinary `create` doesn't guarantee that. It keeps an
307explicit environment (`apiinstances.go:1096-1105`), and stores are
308provisioned per account, app and **environment** (`objstore.GrantIn`,
309`instanceprivatestore.go:250`). So two instances that name one environment
310share its store (#408 §5). For an app with private storage, both seed
311doors (`--from` here, and import in §5.1) therefore:
312
313- **refuse an explicit `--environment`** with 409, "a seeded instance gets
314 its own environment and an empty store". They always use the new host's
315 derived environment (`rec.Slug`).
316- **refuse if a store already exists** for that derived environment, with
317 409 naming it. A store left behind by a deleted instance of the same host
318 is never inherited. The one exception is a retry of the same seed: the
319 store's grant records the seed `ID` that created it, and a retry with
320 that `ID` reuses it.
321- **leave every existing store and binding untouched.**
322
323For an app without private storage, `--environment` works as it does for
324`create`. There's no private store to share.
325
326## 5. The import door (#261)
327
328```
329carlos instances import --app <app> --host <host> --file <snapshot.db> --replaces <addr> [--environment <env>] [--verify <path>]
330```
331
332The CLI drives the calls below, and titogo's conversion tool makes the same
333calls with its service credential. The CLI's `--verify <path>` makes a GET
334with the verify token after the swap and commits on a 2xx. Without it, the
335CLI stops after the swap and prints the token and the commit command.
336
337### 5.1 Begin
338
339`POST /api/cli/apps/{a}/{app}/instances` with
340`"import": {"sha256": "...", "bytes": N, "replaces": "<addr>"}` plus the
341ordinary create fields.
342
343The console checks:
344
345- the caller (§2 ruling 5);
346- the seed floor (§3.3);
347- that the host isn't already a platform record;
348- the storage rules in §4 (an app with private storage gets the derived
349 environment and a new, empty store);
350- `bytes` is at most 4 GiB.
351
352The console can't see a box's registry, so the box checks the existing row
353at the swap (§5.4), and the box is the authority on it.
354
355The console mints a **verify token** (32 random bytes). It answers `201`
356with the token. This is the only time the token appears; the record keeps
357only its hash. The record is written with seed `awaiting-upload`.
358
359### 5.2 Upload (codex 19, codex 20)
360
361`PUT …/instances/{host}/import`, streamed. Before reading the body, the
362console reserves `bytes` from the scratch reservation pool (§4 step 5),
363under the same per-app and deployment limits. If the reservation is
364refused, it answers 507 or 429. Then:
365
3661. It spools the body to `<console data>/scratch/seeds/<host>.db` under
367 `http.MaxBytesReader(bytes)`, hashing as it writes.
3682. A length or hash that differs from the record is refused with 422.
3693. It runs `CheckDBContext` on the spooled file, under a 30-minute
370 deadline. Not a database, or failing integrity, is refused with 422.
3714. It writes `seeds/<host>/<id>/snapshot.db` with one `PutFrom` from the
372 spooled file. A seekable file under 5 GB needs no multipart upload, so
373 there's no unfinished multipart upload to clean up and no new IAM
374 action.
3755. It deletes the spool, and the seed becomes `uploaded`.
376
377The spool is deleted on every exit path, and on console start.
378
379The snapshot must be consistent, and taken after the app has stopped the
380instance (`VACUUM INTO` once its supervisor confirms the process is down).
381The console can't see that; it can only check the file.
382
383### 5.3 Stopped
384
385`POST …/instances/{host}/import/stopped` is the app saying its own process
386is down. The seed becomes `ready`, and the console bumps the serial.
387
388Until the swap, the app's own row keeps answering, so the app's router must
389answer 503 for a stopped instance.
390
391### 5.4 The swap, the gate, and commit (codex 10, 11, 12, 14, 15; R2-3)
392
393Reconcile on the box finds a record with seed `ready` and `Replaces` set,
394and an existing row for the host. Today such a row is left alone ("exists
395but is not the route this record describes", `reconcile.go:1158`, `:1287`),
396and that stays the rule for every other case.
397
398**The journal comes first.** Before any of the predicates below, reconcile
399checks the box's import journal (§5.7) for the host. A journal row means
400this box has already swapped, so the legacy-row predicates are never
401applied to a row the box itself replaced (codex R2-3).
402
403This one case swaps instead, in one registry transaction, only if every
404condition holds. If any fails, the seed becomes `refused` with the
405condition named.
406
407**The row's shape** (codex 10, R1-10). Registry kinds are `instance`,
408`service`, `static` and `sidecar`, and the backing is a separate set of
409fields (`registry.go:31-40`). The row must be:
410
411- kind `instance` or `service`;
412- backed by `Addr` or `Addrs`, with an empty `Socket`;
413- without `Provisioned` from any record;
414- with an addr equal to `Replaces`.
415
416The row must also carry **none** of the columns `shapeMismatch` refuses
417adoption over (`reconcile.go:598-610`): `Backing`, `Tunnel`, `Caps`,
418`Platform` or `Canary`. These are box-scoped or owned by the domains pass,
419and no import takes them over.
420
421**Ownership:** the row's app equals the record's app, and the row's account
422is either:
423
424- the record's account; or
425- the deployment's operator account, with an operator assignment (§6.4)
426 that maps that app name to the record's account and app.
427
428**Layout** (codex 12). The box's layout intent and probe must say nested
429(`mintLayoutReader`). The import branch derives the new row's paths with
430`nested = true` explicitly. It doesn't use `NestedPaths(existing)`, which
431would read the addr row as flat (`reconcile.go:1103-1107`).
432
433**The swap transaction** (codex 14). In one registry transaction, the box:
434
435- writes a journal row (§5.7): the host, the seed `ID`, the **complete
436 original row**, the record key, state `swapped`, and the time;
437- replaces the row with the exec row the record describes, filed under
438 the record's account, with `ImportGate` set to the record's
439 `VerifyHash`.
440
441Then it CASes the record's seed from `ready` to `swapped`.
442
443**Crashes.** A crash before the transaction leaves the addr row untouched.
444A crash after it, but before the record CAS, is resumed on the next pass:
445the journal row names this seed `ID` and the record still says `ready`, so
446reconcile only redoes the record CAS (codex R2-3).
447
448With claims off, which is the only case built here, there's no claim CAS.
449The claim-armed refusal (§3.4) covers the claim case until #250.
450
451**The import gate** (codex 15). While a row has `ImportGate` set:
452
453- the edge, and the edge door, answer 503 with `Retry-After: 5` to every
454 request **except** one carrying `Carlos-Import-Verify: <token>`, whose
455 sha256 matches;
456- the schedule runner sends no ticks to the row;
457- `carlos run` refuses it (run spec §5.1 step 9).
458
459So until commit, only the app's own verify requests reach the instance, and
460nothing ordinary has been served. The first verify request wakes the
461instance, and §3.4 places the snapshot.
462
463**Commit.** `POST …/instances/{host}/import/commit` is a record CAS that
464moves the seed from `placed` to `committed`. Commit is refused before
465`placed`, because nothing has been verified yet. Reconcile then clears
466`ImportGate` and deletes the journal row in one transaction.
467
468### 5.5 Verify
469
470`verify` (moves spec §6.5) is how titogo knows the adopted instance serves.
471Titogo sends its verify path through the edge with the verify header. If it
472passes, it commits. If it fails, it aborts.
473
474### 5.6 Abort (R2-4, R2-7)
475
476`POST …/instances/{host}/import/abort` is a record CAS from any state
477before `committed` to `aborted`. It is refused while the record has a
478non-terminal `ActiveRun`, although run admission already refuses
479uncommitted imports. A commit and an abort race on the record's ETag, and
480exactly one wins. After `committed`, abort is refused; `carlos instances
481delete` is the verb then.
482
483**Deleting an uncommitted import is refused** (codex R2-7).
484`DELETE …/instances/{host}` on a record whose seed is an import that isn't
485`committed` answers 409, "abort the import instead". The only exceptions
486are `awaiting-upload`, `uploaded` and `refused`, where no box has
487swapped, so the console moves the record straight to `rolled-back`, deletes
488`seeds/<host>/<id>/`, and finishes as below.
489
490**The record is kept until everything is clean.** An `aborted` record
491stays in the bucket while the box rolls back. The box then CASes it to
492`rolled-back`, never deleting it. A box that never swapped (there's no
493journal row for this seed `ID`) deletes `seeds/<host>/<id>/` and does the
494same CAS.
495
496**The console finishes** (codex R6-1). For a `rolled-back` record, the
497console's abort handler, or failing that its deadline sweep:
498
4991. **Tears down the private store this seed created**, if there is one.
500 The store record for the derived environment is torn down with the
501 existing `objstore.TeardownIn` (`walk.go:559-566`), but only if its
502 grant names this seed `ID` (§4 Storage). A store without that `ID` was
503 there before the seed, and is never touched.
5042. **Deletes the record** by CAS, if it still names this seed `ID`.
505
506The record holds the hostname until the store is gone, so a re-import of
507the same host can't meet the leftover store that §4 would refuse. The same
508finish runs for a `--from` that fails after creating its store, from its
509`aborted` record (§4 step 7).
510
511**Rollback.** Reconcile runs it for any journal row whose import is
512abandoned, meaning any of these:
513
514- the record is `aborted`;
515- the record's seed `ID` no longer matches the journal's;
516- the record is gone altogether.
517
518Rollback is driven by the journal, not the record, so it doesn't depend on
519the record still existing (codex R2-7). Each step is idempotent, and the
520journal's state says where to resume after a restart:
521
5221. **Close the gate durably.** In one transaction:
523 - set the journal row to `rolling-back`, with a fence generation;
524 - set the row's `ImportGate` to a closed sentinel that matches no
525 token.
526
527 From here on the edge refuses even verify requests, and that survives
528 an agent restart.
5292. **Fence and stop** (codex R2-4, R3 open). This follows the layout
530 pass's handling of the same API (`internal/layout/pass.go:654-680`):
531 - **Ours already?** If `FenceGeneration(host)` equals the journal's
532 fence generation, the fence is this rollback's own, from an earlier
533 pass. Carry on with it.
534 - **Otherwise,** call `FenceAndStopIfUnfenced(host, generation)`. It
535 refuses any fence that already exists, including one from an earlier
536 attempt (`maintenance.go:103-106`), which is why the check above
537 comes first.
538 - **`ErrFenceHeld`** for a fence that isn't ours means another
539 operation holds the host. Defer to the next pass.
540 - **`ErrStopUnproven`.** A cold host has no live entry, and a host
541 never woken has no stop history (`maintenance.go:113-121`). Proceed
542 only if all of these hold, as the layout pass does:
543 - the box swept its instances at agent start;
544 - `Quiesced(host)` is true;
545 - `StopUncertain(host)` is false.
546
547 Otherwise lift this attempt's fence and defer.
548
549 Deferring is safe. The closed gate from step 1 still refuses every
550 request, ticks are skipped for a gated row, and runs are refused, so
551 nothing can wake the instance before the next attempt.
5523. **Remove the local data** through `instancefs`: the database, `-wal`,
553 `-shm`, `<db>.seed` and litestream's local metadata (codex R2-1).
5544. **Restore the route.** In one transaction, put the journal's saved row
555 back exactly as it was, and set the journal row to `cleanup-pending`
556 (codex R3-1).
5575. **Lift the fence.**
5586. **Clean the bucket.** Delete the host's `instances/` replica, if any,
559 and `seeds/<host>/<id>/` for this seed `ID` only.
5607. **Delete the journal row.**
5618. **CAS the record to `rolled-back`**, if it still names this seed `ID`.
562 The console finishes from there (store teardown, then record delete).
563 The record goes last of all (codex R4-1, R6-1). Until then it holds the
564 hostname, and `CreateRecord`'s create-only write refuses a new import or
565 `--from` for that host (`provision.go:845`). So no second seed can
566 arrive while this one's cleanup is still running. A crash after step 7
567 leaves an `aborted` record with no journal row, and §3.2's no-journal
568 path (delete this seed's snapshot, then the CAS) finishes the box's
569 part idempotently.
570
571**After a restart in `cleanup-pending`,** the box resumes at step 6. Its
572deletes are scoped to this seed `ID`, and the record still holds the
573hostname. It
574doesn't swap, fence or stop anything: the route is the restored legacy
575row, and its process is the app's own. Until the journal row is gone, the
576host's leftover replica is this box's to delete. So a retried import never
577meets the creation gate's "replica data remains" refusal
578(`apiinstances.go:1146`).
579
580**Why abort loses nothing.** Under the gate, nothing but verify requests
581reached the instance, so it holds no writes the old process never saw.
582After step 1, not even those reach it.
583
584### 5.7 The import journal
585
586The journal is a box-local registry table, `import_journal`, replacing rev
5872's `replaced_routes`. It has one row per host, with these columns:
588
589- host;
590- seed `ID`;
591- the original row as JSON;
592- record key;
593- state: `swapped`, `rolling-back` or `cleanup-pending`;
594- fence generation;
595- time.
596
597Reconcile reads it **before** the bucket records, on every pass and at
598agent start, and acts on it per §5.4 and §5.6.
599
600A journal row is deleted either in the same transaction that clears the
601gate at commit, or as the last step of a rollback, after the bucket and the
602record are clean. So while an
603import is in flight, the box always knows what it replaced, whatever has
604happened to the record.
605
606### 5.8 Moves wait for placement (codex R4-2)
607
608A seed exists only as a snapshot in the home bucket until its first wake
609places it. A move before then would carry no replica and no snapshot: the
610legacy move worker treats an empty replica as ready
611(`movespass.go:815`, `:919`). So a move is refused:
612
613- for a `--from` instance until its seed is `placed`;
614- for an import until it is `committed`.
615
616The refusal is applied in two places:
617
618- **In the move door,** under the record CAS. This covers both the legacy
619 door (`apiinstancemove.go:399`, through `provision.go:1146`) and the
620 moves series' request door.
621- **In the move worker, before any effect.** A record that slips past an
622 old console is still refused.
623
624The message is "wake the instance once before moving it" for a `--from`
625instance, and "commit or abort the import first" for an import. Moving a
626seed before it is placed would mean handing the seed, and an import's
627rollback, to another region. That isn't offered.
628
629## 6. Who may, and what is checked
630
631### 6.1 Callers
632
633- People who are members of the account.
634- Service credentials with the `instance` role, checked per route by
635 `cliRoute`.
636
637The same rule covers `--from`, every import call, commit and abort.
638
639### 6.2 Same app, same account
640
641- `--from` copies only between instances of one app in one account.
642- An import creates an instance of the app the route already belongs to,
643 under the rules in §5.4.
644
645### 6.3 Layout and claims
646
647- **At the console.** Both doors refuse unless the region's layout report
648 shows the target box nested (`apistatuslayout.go`), and the box doesn't
649 report claim control armed.
650- **At the box.** It refuses again: in §3.4 unless `Classify` answers
651 `LayoutNested`, and in §5.4 unless the layout intent is nested.
652
653A flat box is ruled out because a seeded instance there would be one uid
654among every tenant. For Tito's controld instances, it would also lose the
655`0700 titogo` protection that is the only thing keeping them safe today
656(#126).
657
658### 6.4 The operator assignment (G17, codex 9)
659
660A direct-mode `carlos add` files a route under the deployment's operator
661account, not the app's own. Tito's rows are `account=bab`, for app
662`bae/titogo`. An app name alone doesn't say which account it means: the
663operator account's `titogo` row could belong to any account's `titogo`. So
664the exception needs an operator to say which.
665
666```
667carlos system legacy-routes assign --name titogo --to bae/titogo
668```
669
670**Who may.** This is an operator-only door (`CARLOS_SYSTEM_OPERATORS`).
671
672**What it writes.** An entry in `control/legacy-route-owners.json`, which
673maps an app name, as filed under the operator account, to one account and
674app. A name can have only one entry. Re-assigning it is refused while any
675import under the old entry is between `ready` and `committed`.
676
677**How the swap uses it.** The swap accepts an operator-filed row only when
678this file maps the row's app name to the record's account and app. The
679swap files the new row under the app's account, which fixes G17 for every
680converted host.
681
682The swap accepts nothing wider:
683
684- no row of another app;
685- no row filed under a third account;
686- no row that isn't addr-backed;
687- no row whose addr has changed since `Replaces` was recorded.
688
689## 7. What a member sees
690
691**In `carlos instances list`**, a seeded instance shows one of:
692
693- "seeded from <host> at txid N";
694- "imported <first 12 of sha256>";
695- "import awaiting upload", "awaiting stop", "swapped, awaiting verify",
696 or "refused: <reason>".
697
698**Audit rows:**
699
700- `instance-seed`, naming the source host and TXID (or the sha256 and
701 bytes) and the caller;
702- `import-uploaded`, `import-stopped`, `import-committed` and
703 `import-aborted`;
704- `legacy-route-assign`.
705
706**Errors.** Each one has its own message:
707
708- other app or other account;
709- other region;
710- box not nested;
711- claim control armed;
712- too large;
713- no scratch space;
714- history no longer kept;
715- hash or length mismatch;
716- not a database;
717- boxes below the seed floor;
718- commit before placed;
719- abort after commit;
720- delete of an uncommitted import (abort it instead);
721- move before placement or commit;
722- abort while a run is open.
723
724Some come back as the swap's refusal reasons instead:
725
726- no addr row;
727- addr changed;
728- row of another app;
729- operator row with no assignment.
730
731Every CLI and console string goes through copy review before it's written
732into the code.
733
734## 8. Security
735
736- **Data never leaves its account.** Neither door crosses an account or an
737 app.
738- **Uploads are pinned.** An upload is pinned to one record, one length and
739 one hash. The console checks the hash as it spools the file, and the box
740 checks it again before it trusts the file.
741- **The verify token never sits on any box or bucket.** Only its hash
742 does. Without the token, nothing ordinary reaches an un-committed import.
743- **No standing identity gains a read.** The console already reads the home
744 region's replicas for `db pull`. Boxes read only their own region's
745 bucket, and `seeds/` is in it.
746- **A forged record is still the main risk.** A seed is a record field, and
747 #171 means any compromised box can forge a record. A forged seed could
748 start a new instance from a snapshot already in the bucket, or a forged
749 assignment file could widen §6.4. Both are the existing record-authority
750 boundary (G13 in the moves series), and #171's fix bounds them.
751- **The swap only narrows the "leave it alone" rule.** It narrows it for a
752 row named by its exact addr, of the same app, under the app's account or
753 an operator-assigned one. Every other mismatch is left alone, as today.
754
755## 9. Testing
756
757- **The floor:**
758 - no seed state is written until every box reports the floor armed;
759 - a recovery below the floor is refused.
760- **Box placement:**
761 - each failure (length, hash, `CheckDB`) fails the wake, and never falls
762 back to an empty database;
763 - a local database whose `<db>.seed` names an earlier seed `ID` (abort,
764 then re-import) is purged with its WAL, SHM and metadata, and the new
765 snapshot placed;
766 - a crash after the rename but before the first upload resumes from the
767 local file;
768 - a crash before the rename retries from the snapshot;
769 - the snapshot is deleted only after the first upload;
770 - refusal with claim control armed.
771- **Reconcile:**
772 - the swap's conditions, each refused on its own, with its reason;
773 - the operator assignment, present, absent and mapping elsewhere;
774 - nested paths derived for the new row;
775 - the original row stored and restored exactly;
776 - rows carrying `Backing`, `Tunnel`, `Caps`, `Platform` or `Canary`
777 refused;
778 - `aborted` dispatched before the state filter;
779 - a crash between the swap transaction and the record CAS resumes;
780 - rollback for a record that is aborted, replaced by another seed `ID`,
781 or deleted outright;
782 - the rollback fence: a verify request during rollback is refused, stop
783 evidence is required, and the fence is re-installed after a restart;
784 - an idempotent rollback across a crash between any two of its steps,
785 including between the route restore and the replica delete
786 (`cleanup-pending` resumes, and never stops the legacy process);
787 - the fence: resumed when it is ours by generation; deferred when it is
788 another operation's; for a never-woken host, accepted only when the
789 box was swept at start and the host is quiesced;
790 - `--from`'s `reserving` record: the sweep never deletes its snapshot,
791 and abandons it by CAS only after an hour;
792 - abort A, crash between the journal delete and the record delete, then
793 restart: A finishes, and a second import of the host is refused until
794 A's record is gone;
795 - a cleanup never deletes another seed `ID`'s snapshot;
796 - a move refused before placement or commit, in the door (both the
797 legacy and the moves series') and in the worker;
798- **The gate:**
799 - the edge and the door answer 503 without the token and pass with it;
800 - no ticks while gated;
801 - commit clears the gate;
802 - the commit and abort race.
803- **Console:**
804 - both doors' gates, including `instance` service credentials and
805 `db pull` still refusing them;
806 - `--from` with `--at <txid>`, and history no longer kept;
807 - on an app with private storage, `--environment` refused and an existing
808 store for the derived environment refused, except a retry of the same
809 seed `ID`; the source's store and bindings unchanged;
810 - abort and then re-import with private storage on, both before upload
811 and after the swap. The seed's store is torn down and the re-import
812 succeeds. A store whose grant names another seed `ID`, or none, is
813 never torn down. A crash between the teardown and the record delete
814 resumes;
815 - a failed `--from` after store provisioning: the store is torn down and
816 the host can be created again;
817 - restored size from the last LTX header, and the 413;
818 - the shared reservation pool across restores and spools, its 507, and
819 release on every path;
820 - the watcher cancelling a restore that outgrows its reservation;
821 - the per-app and deployment limits shared by both;
822 - delete refused for an uncommitted import, allowed before upload
823 completes;
824 - the seeds sweep: deletes only unreferenced or superseded snapshots,
825 never one a live `--from` record still needs;
826 - scratch cleanup on every exit path and at start;
827 - every import state transition;
828 - hash, length and `CheckDB` refusal;
829 - the audit rows.
830- **CLI:**
831 - `instances create --from` and `instances import`, with and without
832 `--verify`, end to end against a fake console;
833 - `system legacy-routes assign`.
834- **On the dev box:**
835 - a `--from` canary of a real instance;
836 - an import of a stopped snapshot over an addr row, with a verify, then
837 a commit;
838 - a second import aborted after the swap, with the addr row restored and
839 serving.
840- **Cross-compile:** darwin and windows builds stay green.
841
842## 10. Rollout and #261's schedule
843
8441. Build both doors now. They refuse on flat and claim-armed boxes, so
845 they're safe to ship before Tito's boxes are nested.
8462. Release, then publish the seeds floor on each deployment once its boxes
847 run the release.
8483. Roll the nested layout to Tito's boxes (#247 steps 2 to 5, with Tito's
849 own approvals).
8504. An operator assigns `titogo` to `bae/titogo` (§6.4).
8515. Titogo builds its half (its design §4 and §7): snapshot, import, carry
852 env, verify with the token, commit or abort, repoint, retire; import
853 mirrors that write through the instance; and controld's `retire`.
8546. Convert one canary host, abort it once on purpose, then convert it
855 again.
8567. Convert the rest, a few at a time, with Paul's go.
857
858If the claim is activated (#250) before the conversion finishes, the
859remaining imports wait for §3.4's claim algorithm.
860
861## 11. Round 1 dispositions
862
863| Finding | Disposition |
864|---|---|
865| codex 8 (P1) warm-file shortcut vs claim restore | **Accepted.** §3.4: this version is the plain path only, and claim-armed boxes refuse; the claim algorithm is written down for #250 as Astra gave it |
866| codex 9 (P1) app name doesn't fix the account | **Accepted.** §6.4: an operator assignment maps the name to one account and app |
867| codex 10 (P2) `Kind == "addr"` doesn't exist | **Accepted.** §5.4: the shape defined by kind and backing fields |
868| codex 11 (P2) the claim acquisition at the swap | **Accepted** by scoping. With claims off the swap is one registry transaction; claim-armed boxes refuse (§3.4) |
869| codex 12 (P2) existing-row branch derives flat | **Accepted.** §5.4: the import branch derives nested explicitly |
870| codex 13 (P2) the state filter makes rollback unreachable | **Accepted.** §3.2: dispatch on seed state first |
871| codex 14 (P2) `Replaces` can't restore the row | **Accepted.** §5.4: the complete row goes in `replaced_routes` in the swap's transaction |
872| codex 15 (P1) abort safety not observable | **Accepted.** §5.4: the import gate with a verify token, and commit as the durable latch; abort only before commit |
873| codex 16 (P1) downgrade consumes seed records | **Accepted.** §3.3: the seeds floor, armed with a `carlos.prev` verdict, before any seed state is written |
874| codex 17 (P1) `dbGate` refuses service credentials | **Accepted.** §4: `resolveDBSource` is factored out; the db door's own gate is unchanged |
875| codex 18 (P2) `RestoreTo` can't honour `--at` | **Accepted.** §4 step 4: a target passed to litestream's TXID/Timestamp options |
876| codex 19 (P2) scratch isn't bounded | **Accepted.** §4 step 5 and §5.2: admission from the plan's sizes, free-space checks, deadlines, cleanup on every path and at start |
877| codex 20 (P2) multipart upload | **Accepted, differently.** §5.2: spool to scratch (which `CheckDB` needs anyway), then one `PutFrom` under the 5 GB single-PUT limit; no multipart, no new IAM action |
878
879## 12. Round 2 dispositions
880
881| Finding | Disposition |
882|---|---|
883| R1-10, still open: protected columns | **Accepted.** §5.4: a row with `Backing`, `Tunnel`, `Caps`, `Platform` or `Canary` is refused, as adoption refuses it |
884| R1-18, still open: timestamps | **Accepted by scope.** §4 step 4: `--at <txid>` only in this version; carrying LTX times through the db door is its own change |
885| R1-19, still open: scratch isn't bounded | **Accepted.** §4 step 5: restored size from the last LTX header's page count and page size; one reservation pool for restores and spools; shared limits; a watcher that cancels on overgrowth |
886| R2-1 (P1) re-import resurrects aborted data | **Accepted.** §3.4: a seed `ID` and a `<db>.seed` marker; a local file without a matching marker is purged; rollback removes the database, WAL, SHM, marker and metadata |
887| R2-2 (P1) run gets past the import gate | **Accepted.** Run spec §5.1 step 9 and §5.4 here: runs are refused on uncommitted imports, under the record CAS |
888| R2-3 (P2) a crash between the swap and the record CAS | **Accepted.** §5.4 and §5.7: the journal is read first, keyed by seed `ID`; a swapped-but-`ready` import resumes with the CAS |
889| R2-4 (P2) a verify wake during rollback | **Accepted.** §5.6: the gate is closed durably first, then `FenceAndStopIfUnfenced` with complete stop evidence; the fence is re-installed after a restart |
890| R2-5 (P1) the lifecycle rule deletes needed seeds | **Accepted.** §3.1: no lifecycle rule; a console sweep deletes only unreferenced or superseded snapshots, after a terminal abort for stale imports |
891| R2-7 (P2) delete bypasses rollback | **Accepted.** §5.6: delete is refused for an uncommitted import; rollback is driven by the journal, including when the record is gone |
892
893## 13. Round 3 dispositions
894
895| Finding | Disposition |
896|---|---|
897| R2-4, still open: the fence retry can't work | **Accepted.** §5.6 step 2: the layout pass's pattern; our own fence is recognised by the journal's generation, and a cold host's stop is accepted only when the box was swept at start and the host is quiesced |
898| R3-1 (P2) rollback forgets replica cleanup | **Accepted.** §5.6 steps 4 to 8: the journal stays as `cleanup-pending` until the bucket and record are clean; resuming never swaps or stops |
899| R3-2 (P2) sweep deletes a snapshot mid-create | **Accepted.** §4 step 7: the record is reserved before the upload; the sweep never deletes a snapshot named by a live record, and abandons a stale reservation by CAS first |
900
901## 14. Round 4 dispositions
902
903| Finding | Disposition |
904|---|---|
905| R4-1 (P1) a resumed rollback deletes the next import's snapshot | **Accepted, both fixes.** §3.1: snapshot keys carry the seed `ID`, and every delete is scoped to it. §5.6: the aborted record is deleted last, so it holds the hostname until cleanup is done |
906| R4-2 (P2) a move strands an unplaced seed | **Accepted.** §5.8: moves are refused until a `--from` seed is placed or an import is committed, in the door and in the worker |
907
908## 15. Round 5 dispositions
909
910| Finding | Disposition |
911|---|---|
912| R5-1 (P1) create doesn't guarantee an empty private store | **Accepted.** §4 Storage: with private storage, both seed doors refuse an explicit environment, use the derived one, and refuse an existing store unless it was created by the same seed `ID` |
913
914## 16. Round 6 dispositions
915
916| Finding | Disposition |
917|---|---|
918| R6-1 (P2) abort leaves a store that blocks re-import | **Accepted.** §5.6: the box ends at `rolled-back`; the console tears down the store only if its grant names this seed `ID`, then deletes the record, which holds the hostname until then. The same applies to a failed `--from` |
919