| 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 |
| 4 | answers Astra's round 1 (`CODEX-FINDINGS-RUN-AND-SEEDED-R1.md`; findings |
| 5 | 8 to 20 are this spec's). Each change is marked **(codex N)**, and §11 lists |
| 6 | the 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 | |
| 13 | Two 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 | |
| 32 | Both requests come down to one thing: create an instance whose database |
| 33 | starts as a given one. |
| 34 | |
| 35 | ## 2. Rulings (Paul, 2026-10-08) |
| 36 | |
| 37 | 1. **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. |
| 40 | 2. **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. |
| 44 | 3. **`--from` stays in the source's region.** To get to another region, |
| 45 | run `carlos move` afterwards, as §9 said for adoption. |
| 46 | 4. **The new instance gets its own environment**, never the source's |
| 47 | per-host one, and **an empty private store**. |
| 48 | 5. **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. |
| 52 | 6. **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). |
| 56 | 7. **Platform-run only, nested only.** |
| 57 | |
| 58 | ## 3. The shared step: a seed snapshot |
| 59 | |
| 60 | ### 3.1 The object |
| 61 | |
| 62 | The snapshot lives at `seeds/<host>/<id>/snapshot.db` in the home |
| 63 | region'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 |
| 65 | names its seed `ID`, so cleaning up one seed can never touch another seed |
| 66 | of 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 |
| 92 | Seed *SeedSource `json:"seed,omitempty"` |
| 93 | |
| 94 | type 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 | |
| 118 | Either kind can also be abandoned. Its record then goes `aborted` → |
| 119 | `rolled-back`, and is deleted (§5.6). |
| 120 | |
| 121 | An 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 |
| 127 | journal first (§5.7). Then it dispatches on the seed state **before** any |
| 128 | other 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 | |
| 143 | An old box, or an old binary a box recovers to, would read a seeded record |
| 144 | as an ordinary new instance and mint it empty. A seeded instance that comes |
| 145 | up empty looks healthy and is wrong. So seeds get a protocol floor, built |
| 146 | on 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 |
| 162 | armed, both doors refuse with `refused: "claim"`. The claim's launch path |
| 163 | doesn'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 |
| 166 | algorithm 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 |
| 170 | applies only to a row whose record seed is `ready`, `swapped` or `placed`: |
| 171 | |
| 172 | 1. **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. |
| 187 | 2. **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). |
| 191 | 3. **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. |
| 195 | 4. **Replicate.** Start replication. The first upload is generation one of |
| 196 | the host's own lineage. Nothing of the source's lineage is copied. |
| 197 | 5. **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 |
| 201 | to the box log and the `carlos logs` sink. It **never** falls through to an |
| 202 | empty database and the manifest's seed command. The manifest's seed |
| 203 | command is never run for a seeded row. The snapshot is deleted only after |
| 204 | the host's own replica exists, so a crash at any point retries from either |
| 205 | the 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 | ``` |
| 219 | carlos 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** |
| 224 | call `dbGate`, which accepts only people (`apidb.go:110`); the db routes' |
| 225 | own refusal of every service role (`authcli.go:690`) stays as it is. The |
| 226 | source 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 | |
| 234 | Both doors call it. |
| 235 | |
| 236 | **The console's steps:** |
| 237 | |
| 238 | 1. Apply `instances create`'s existing checks to the new host, and the |
| 239 | seed floor (§3.3). |
| 240 | 2. Resolve the source with `resolveDBSource`. |
| 241 | 3. Refuse a source and target of different apps or accounts (409). |
| 242 | Customer data never leaves its account by this door. |
| 243 | 4. **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. |
| 255 | 5. **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. |
| 277 | 6. **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. |
| 281 | 7. **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, |
| 296 | error, 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 |
| 300 | always the host-derived one (see Storage below). Without private storage, |
| 301 | it is `--environment` if given, or the app's default otherwise. Never the source's per-host |
| 302 | environment, which carries host-keyed values such as the origin and the |
| 303 | cookie domain (the warning in `instances_cli.go:235-245`). |
| 304 | |
| 305 | **Storage** (codex R5-1). An app with private storage must get a new, |
| 306 | empty store, and an ordinary `create` doesn't guarantee that. It keeps an |
| 307 | explicit environment (`apiinstances.go:1096-1105`), and stores are |
| 308 | provisioned per account, app and **environment** (`objstore.GrantIn`, |
| 309 | `instanceprivatestore.go:250`). So two instances that name one environment |
| 310 | share its store (#408 §5). For an app with private storage, both seed |
| 311 | doors (`--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 | |
| 323 | For 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 | ``` |
| 329 | carlos instances import --app <app> --host <host> --file <snapshot.db> --replaces <addr> [--environment <env>] [--verify <path>] |
| 330 | ``` |
| 331 | |
| 332 | The CLI drives the calls below, and titogo's conversion tool makes the same |
| 333 | calls with its service credential. The CLI's `--verify <path>` makes a GET |
| 334 | with the verify token after the swap and commits on a 2xx. Without it, the |
| 335 | CLI 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 |
| 341 | ordinary create fields. |
| 342 | |
| 343 | The 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 | |
| 352 | The console can't see a box's registry, so the box checks the existing row |
| 353 | at the swap (§5.4), and the box is the authority on it. |
| 354 | |
| 355 | The console mints a **verify token** (32 random bytes). It answers `201` |
| 356 | with the token. This is the only time the token appears; the record keeps |
| 357 | only 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 |
| 362 | console reserves `bytes` from the scratch reservation pool (§4 step 5), |
| 363 | under the same per-app and deployment limits. If the reservation is |
| 364 | refused, it answers 507 or 429. Then: |
| 365 | |
| 366 | 1. It spools the body to `<console data>/scratch/seeds/<host>.db` under |
| 367 | `http.MaxBytesReader(bytes)`, hashing as it writes. |
| 368 | 2. A length or hash that differs from the record is refused with 422. |
| 369 | 3. It runs `CheckDBContext` on the spooled file, under a 30-minute |
| 370 | deadline. Not a database, or failing integrity, is refused with 422. |
| 371 | 4. 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. |
| 375 | 5. It deletes the spool, and the seed becomes `uploaded`. |
| 376 | |
| 377 | The spool is deleted on every exit path, and on console start. |
| 378 | |
| 379 | The snapshot must be consistent, and taken after the app has stopped the |
| 380 | instance (`VACUUM INTO` once its supervisor confirms the process is down). |
| 381 | The 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 |
| 386 | is down. The seed becomes `ready`, and the console bumps the serial. |
| 387 | |
| 388 | Until the swap, the app's own row keeps answering, so the app's router must |
| 389 | answer 503 for a stopped instance. |
| 390 | |
| 391 | ### 5.4 The swap, the gate, and commit (codex 10, 11, 12, 14, 15; R2-3) |
| 392 | |
| 393 | Reconcile on the box finds a record with seed `ready` and `Replaces` set, |
| 394 | and an existing row for the host. Today such a row is left alone ("exists |
| 395 | but is not the route this record describes", `reconcile.go:1158`, `:1287`), |
| 396 | and that stays the rule for every other case. |
| 397 | |
| 398 | **The journal comes first.** Before any of the predicates below, reconcile |
| 399 | checks the box's import journal (§5.7) for the host. A journal row means |
| 400 | this box has already swapped, so the legacy-row predicates are never |
| 401 | applied to a row the box itself replaced (codex R2-3). |
| 402 | |
| 403 | This one case swaps instead, in one registry transaction, only if every |
| 404 | condition holds. If any fails, the seed becomes `refused` with the |
| 405 | condition 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 |
| 409 | fields (`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 | |
| 416 | The row must also carry **none** of the columns `shapeMismatch` refuses |
| 417 | adoption over (`reconcile.go:598-610`): `Backing`, `Tunnel`, `Caps`, |
| 418 | `Platform` or `Canary`. These are box-scoped or owned by the domains pass, |
| 419 | and no import takes them over. |
| 420 | |
| 421 | **Ownership:** the row's app equals the record's app, and the row's account |
| 422 | is 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 |
| 431 | would 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 | |
| 441 | Then it CASes the record's seed from `ready` to `swapped`. |
| 442 | |
| 443 | **Crashes.** A crash before the transaction leaves the addr row untouched. |
| 444 | A crash after it, but before the record CAS, is resumed on the next pass: |
| 445 | the journal row names this seed `ID` and the record still says `ready`, so |
| 446 | reconcile only redoes the record CAS (codex R2-3). |
| 447 | |
| 448 | With claims off, which is the only case built here, there's no claim CAS. |
| 449 | The 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 | |
| 459 | So until commit, only the app's own verify requests reach the instance, and |
| 460 | nothing ordinary has been served. The first verify request wakes the |
| 461 | instance, and §3.4 places the snapshot. |
| 462 | |
| 463 | **Commit.** `POST …/instances/{host}/import/commit` is a record CAS that |
| 464 | moves 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. |
| 471 | Titogo sends its verify path through the edge with the verify header. If it |
| 472 | passes, 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 |
| 477 | before `committed` to `aborted`. It is refused while the record has a |
| 478 | non-terminal `ActiveRun`, although run admission already refuses |
| 479 | uncommitted imports. A commit and an abort race on the record's ETag, and |
| 480 | exactly one wins. After `committed`, abort is refused; `carlos instances |
| 481 | delete` 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 |
| 486 | are `awaiting-upload`, `uploaded` and `refused`, where no box has |
| 487 | swapped, 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 |
| 491 | stays 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 |
| 493 | journal row for this seed `ID`) deletes `seeds/<host>/<id>/` and does the |
| 494 | same CAS. |
| 495 | |
| 496 | **The console finishes** (codex R6-1). For a `rolled-back` record, the |
| 497 | console's abort handler, or failing that its deadline sweep: |
| 498 | |
| 499 | 1. **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. |
| 504 | 2. **Deletes the record** by CAS, if it still names this seed `ID`. |
| 505 | |
| 506 | The record holds the hostname until the store is gone, so a re-import of |
| 507 | the same host can't meet the leftover store that §4 would refuse. The same |
| 508 | finish 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 |
| 512 | abandoned, 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 | |
| 518 | Rollback is driven by the journal, not the record, so it doesn't depend on |
| 519 | the record still existing (codex R2-7). Each step is idempotent, and the |
| 520 | journal's state says where to resume after a restart: |
| 521 | |
| 522 | 1. **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. |
| 529 | 2. **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. |
| 552 | 3. **Remove the local data** through `instancefs`: the database, `-wal`, |
| 553 | `-shm`, `<db>.seed` and litestream's local metadata (codex R2-1). |
| 554 | 4. **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). |
| 557 | 5. **Lift the fence.** |
| 558 | 6. **Clean the bucket.** Delete the host's `instances/` replica, if any, |
| 559 | and `seeds/<host>/<id>/` for this seed `ID` only. |
| 560 | 7. **Delete the journal row.** |
| 561 | 8. **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 |
| 572 | deletes are scoped to this seed `ID`, and the record still holds the |
| 573 | hostname. It |
| 574 | doesn't swap, fence or stop anything: the route is the restored legacy |
| 575 | row, and its process is the app's own. Until the journal row is gone, the |
| 576 | host's leftover replica is this box's to delete. So a retried import never |
| 577 | meets 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 |
| 581 | reached the instance, so it holds no writes the old process never saw. |
| 582 | After step 1, not even those reach it. |
| 583 | |
| 584 | ### 5.7 The import journal |
| 585 | |
| 586 | The journal is a box-local registry table, `import_journal`, replacing rev |
| 587 | 2'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 | |
| 597 | Reconcile reads it **before** the bucket records, on every pass and at |
| 598 | agent start, and acts on it per §5.4 and §5.6. |
| 599 | |
| 600 | A journal row is deleted either in the same transaction that clears the |
| 601 | gate at commit, or as the last step of a rollback, after the bucket and the |
| 602 | record are clean. So while an |
| 603 | import is in flight, the box always knows what it replaced, whatever has |
| 604 | happened to the record. |
| 605 | |
| 606 | ### 5.8 Moves wait for placement (codex R4-2) |
| 607 | |
| 608 | A seed exists only as a snapshot in the home bucket until its first wake |
| 609 | places it. A move before then would carry no replica and no snapshot: the |
| 610 | legacy 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 | |
| 616 | The 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 | |
| 624 | The message is "wake the instance once before moving it" for a `--from` |
| 625 | instance, and "commit or abort the import first" for an import. Moving a |
| 626 | seed before it is placed would mean handing the seed, and an import's |
| 627 | rollback, 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 | |
| 637 | The 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 | |
| 653 | A flat box is ruled out because a seeded instance there would be one uid |
| 654 | among 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 | |
| 660 | A direct-mode `carlos add` files a route under the deployment's operator |
| 661 | account, 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 |
| 663 | operator account's `titogo` row could belong to any account's `titogo`. So |
| 664 | the exception needs an operator to say which. |
| 665 | |
| 666 | ``` |
| 667 | carlos 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 |
| 673 | maps an app name, as filed under the operator account, to one account and |
| 674 | app. A name can have only one entry. Re-assigning it is refused while any |
| 675 | import 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 |
| 678 | this file maps the row's app name to the record's account and app. The |
| 679 | swap files the new row under the app's account, which fixes G17 for every |
| 680 | converted host. |
| 681 | |
| 682 | The 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 | |
| 724 | Some 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 | |
| 731 | Every CLI and console string goes through copy review before it's written |
| 732 | into 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 | |
| 844 | 1. 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. |
| 846 | 2. Release, then publish the seeds floor on each deployment once its boxes |
| 847 | run the release. |
| 848 | 3. Roll the nested layout to Tito's boxes (#247 steps 2 to 5, with Tito's |
| 849 | own approvals). |
| 850 | 4. An operator assigns `titogo` to `bae/titogo` (§6.4). |
| 851 | 5. 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`. |
| 854 | 6. Convert one canary host, abort it once on purpose, then convert it |
| 855 | again. |
| 856 | 7. Convert the rest, a few at a time, with Paul's go. |
| 857 | |
| 858 | If the claim is activated (#250) before the conversion finishes, the |
| 859 | remaining 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 | |