docs: instance moves for every app — design record
The platform runs the whole move; an app declares freeze/thaw/verify hooks and which stores follow. Widens #331 to every app, closes the wake-before- freeze gap behind Tito's failed move, and names adoption, the edge-local door for app routers, retention and stores as slices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+605
−0
docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md+605 −0
diff --git a/docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md b/docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md| new file mode 100644 |
| index 000000000..86cab28e3 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md |
| @@ -0,0 +1,605 @@ |
| +# Instance moves for every app: the platform runs the move, the app answers three calls |
| + |
| +**Date:** 2026-09-24 |
| +**Status:** DRAFT for review. Filed as a request on amadan `carlos/platform` |
| +for a CARLOS session to own and build; nothing here is built. |
| +**Builds on:** `2026-08-28-instance-move-design.md` (#331, the two-phase, |
| +data-following move: shipped, and every word of its never-two-writers |
| +argument stands). This record widens who can use it and what follows the |
| +instance; it does not reopen the cut ordering. |
| +**First adopter:** Tito Go. Its half is |
| +`teamtito/titogo` `docs/superpowers/specs/2026-09-24-moves-on-the-platform-design.md` |
| +(branch `docs-controld-onto-platform-design`). |
| + |
| +## 1. The problem, as found |
| + |
| +Paul, 2026-09-24: *"This is something that anyone using the platform should |
| +be able to offer with their apps."* |
| + |
| +#331 shipped the data half of a move for one class of route and left the rest |
| +to the tenant: the tenant freezes writes over its own channel, copies its own |
| +uploads, and tells its own router where the instance went (#331 spec, |
| +"Decisions taken in conversation"). Only one tenant has built that half. Its |
| +two production attempts are the whole record, and both failed |
| +(Tito's router registry, `/data/titogo/registry.db`, table `moves`, read |
| +2026-09-24): |
| + |
| +| row | host | to | when (UTC) | outcome | |
| +|---|---|---|---|---| |
| +| 1 | `move-canary.go.tito.io` | se | 2026-08-30 16:19 | aborted: `could not freeze: Post "http://titogo/__internal/move/freeze": dial unix /run/carlos/move-canary.go.tito.io.sock: connect: no such file or directory` | |
| +| 2 | `techarena.go.tito.io` | se | 2026-08-31 11:14 | failed: `instanceapi: POST 404: 404 page not found` | |
| + |
| +**Row 1 is a platform gap wearing a tenant's error.** move-canary was a |
| +platform instance, asleep. The tenant's freeze is a raw dial of the instance |
| +socket (titogo `internal/carlos/move_order.go:30-36`), and a sleeping |
| +instance has no socket: the activator removes it on hibernation. The only |
| +things that wake an instance are an edge request and a scheduled tick |
| +(`internal/activator/activator.go:2777-2779`, `ServeScheduled`). The move |
| +API gives the tenant neither, so a tenant that wants to freeze a sleeping |
| +instance has to send it a public HTTP request first and hope. Tito's router |
| +already does exactly that for ordinary traffic (titogo |
| +`internal/carlos/wake.go:10-19`, a GET to `https://<host>/` "rides the same |
| +path a buyer's browser does"). That is a workaround for a missing verb. |
| + |
| +**Row 2 is the class rail doing its job.** techarena ran under Tito's own |
| +supervisor (`titogo@.service`), fronted by the platform as an addr route to |
| +Tito's router. There is no platform record for it, so the move door's |
| +byte-identical 404 answered (`internal/console/apiinstancemove.go:43-46`). |
| +That is correct: the platform cannot move state it does not run. But no door |
| +lets an app bring such an instance under a record, so on carlos-tito-ie |
| +**71 of the app's hosts are addr rows pointing at `127.0.0.1:8444` |
| +(`provisioned=0`) and 13 are platform socket routes** (box registry, |
| +2026-09-24). Most of the app's customers cannot move at all. |
| + |
| +And where a tenant does get past both, three more things it has to build |
| +itself: |
| + |
| +1. **Its router.** An app that fronts many instances behind its own router |
| + (Tito's root domain, `go.tito.io/<slug>/…`, is served by Tito's router, |
| + which dials each instance socket directly) finds the socket gone after a |
| + cut. The platform's edge follows the move. The app's router does not. |
| + Tito carries a `routes.home` column for this and nothing writes it |
| + (titogo `internal/carlos/registry.go:539-541`, "a future narrow setter |
| + for home"; `move_sweep.go:648-664`, `finishMove` records `done` and |
| + logs `served_by`, and never re-homes the row). |
| +2. **Its files.** The platform moves "the instance DB and record only" |
| + (#331 spec). Anything in a store stays where it was. |
| +3. **Knowing it finished.** The record's `move` clears in the flip |
| + (`internal/provision/provision.go:1036-1044`), so a finished move and a |
| + never-started one look the same on the move GET (`{"state":"none"}`, |
| + `apiinstancemove.go:88-91`). Tito infers "done" by re-reading the |
| + record's region (titogo `move_sweep.go:369-445`). |
| + |
| +Every app that wants to offer moves would rebuild those, and the one that |
| +did never completed a move. This record moves them into the platform. |
| + |
| +## 2. The shape, in one paragraph |
| + |
| +A move is a platform verb on one instance: `carlos instances move <host> --to |
| +us`, or the same three console doors #331 already serves. The app declares |
| +once, on its instances manifest, which HTTP paths on the instance answer |
| +**freeze**, **thaw** and **verify**, and which of its stores follow the |
| +instance. From then on the platform drives the whole move: it copies the |
| +replica and the declared stores in delta rounds while the instance keeps |
| +serving, **wakes the instance and calls freeze** when the copy has caught |
| +up, cuts (stop, final checkpoint, final delta, purge, flip), lands the |
| +instance on the target box, wakes it there and calls verify, holds the host |
| +at `503 + Retry-After` at every edge for the whole window, and keeps a move |
| +log with a terminal state. The source region's copy is retained for a fence |
| +and then deleted. An app with its own router dials instances through a |
| +box-local edge door and stops caring which box an instance lives on. |
| + |
| +## 3. Rulings asked for |
| + |
| +These are the decisions this record needs before a plan is written. Each is |
| +stated as the proposal; §14 lists the ones still genuinely open. |
| + |
| +- **(proposed) The platform owns the freeze.** An app that declares hooks |
| + never sends its own freeze. An app that declares none gets today's #331 |
| + behaviour exactly: the cut stops the child without warning, and the edge |
| + holds the host at 503 until the target serves (§6.4). No hook is ever |
| + required to move safely: consistency comes from stop-then-checkpoint, not |
| + from the app. The freeze buys a graceful window, not correctness. |
| +- **(proposed) Hooks ride the tick's trust path.** Delivered by the agent |
| + over the instance socket through a waking entry point, authenticated with |
| + the instance admin token the scheduled-tick runner already mints and sends |
| + (`internal/host/schedule_wiring.go:147-182`). No new credential, no new |
| + trust path. |
| +- **(proposed) Stores follow only when declared,** and only stores the |
| + platform provisioned. A bucket the app brought itself is the app's |
| + (§7.3). |
| +- **(proposed) Rollback after the cut is a move back,** never an automatic |
| + undo. Which means moves to and from the home region must work (§6.6). |
| +- **(proposed) An app's own router stops routing by location.** The platform |
| + gives it one box-local door that reaches any instance wherever it lives |
| + (§8). A tenant-side `home` column is the thing this retires. |
| + |
| +## 4. Non-goals |
| + |
| +- Fleet placements (`fleet/<name>`), pooled records and non-hibernating |
| + instances stay refused (`apiinstancemove.go:61-72`, the #331 rails). |
| +- Moving addr-backed or unit-backed routes as such. An addr-backed instance |
| + first becomes a platform instance (§9); then it moves like any other. |
| +- `replica_regions` and the replica role (#330, discussion #162): a move |
| + moves the serving home. What happens to replicas is §13. |
| +- Automatic scheduling ("move at 03:00"). The door takes a request now; the |
| + app schedules if it wants to. Tito already does. |
| +- Region-aware billing and unit economics. |
| + |
| +## 5. What an app declares |
| + |
| +One additive, omitempty object on the instances manifest |
| +(`provision.Manifest`, `internal/provision/provision.go:226-241`), written by |
| +`carlos instances enable --app a --moves …`, a signed-in person's act, gated |
| +exactly as `--private-storage` is (the 2026-09-22 private-storage spec, |
| +§3). An `instance`-role credential can request a move (it already can, |
| +`internal/console/authcli.go:625-628`) but can never change what a move |
| +does. |
| + |
| +```json |
| +"moves": { |
| + "hooks": { |
| + "freeze": "/.well-known/carlos/move/freeze", |
| + "thaw": "/.well-known/carlos/move/thaw", |
| + "verify": "/.well-known/carlos/move/verify" |
| + }, |
| + "freeze_budget_seconds": 600, |
| + "stores": ["private"], |
| + "store_prefixes": { "uploads": "{host}/" } |
| +} |
| +``` |
| + |
| +- **`hooks`**: any subset. Paths are validated with the schedule |
| + declaration's path rules (`internal/schedule/declaration.go:38-60`: must |
| + start with `/`, no scheme or host, no `..`, no whitespace, ≤256 bytes). |
| + Absent = no hook of that kind. |
| +- **`freeze_budget_seconds`**: how long the app will hold writes. The |
| + platform passes it as the freeze deadline and never extends a freeze past |
| + it (§6.3). Default 600, cap 1800. |
| +- **`stores`**: which of the instance's bound stores follow it. `"private"` |
| + names the per-instance environment store (2026-09-22 spec §5). |
| +- **`store_prefixes`**: for an app-level store shared by every instance, the |
| + key prefix that belongs to one instance, as a template over `{host}` and |
| + `{slug}`. Only those keys follow. §7.2. |
| + |
| +A manifest without `moves` gets #331 exactly as it is today. |
| + |
| +## 6. The lifecycle |
| + |
| +### 6.1 States |
| + |
| +The record keeps #331's in-flight object (`provision.MoveState`, |
| +`provision.go:903-940`) and its narrow CAS (`SetRecordMove`, |
| +`provision.go:949`). States grow from four to seven; every new one is |
| +additive, and a record written by today's console still parses. |
| + |
| +``` |
| +preparing ──► ready ──► freezing ──► cutting ──► landing ──► (record at rest, log: done) |
| + │ │ │ |
| + └───────────┴───────────┴──► aborting ──► (record at rest, log: aborted) |
| +``` |
| + |
| +| state | who writes it | what is happening | legal exits | |
| +|---|---|---|---| |
| +| `preparing` | console (request) | source mirrors replica and declared stores into target staging, delta rounds | `ready`, `aborting` | |
| +| `ready` | source box | a round found zero differences; rounds keep running | `freezing` (cut requested), `aborting` | |
| +| `freezing` | console (cut) | source wakes the instance and calls freeze; tenant-held apps skip straight through | `cutting`, `aborting` | |
| +| `cutting` | source box | #331's cut, unchanged in order, plus the store's final delta | `landing` (the flip) | |
| +| `landing` | source box (in the flip write) | record's `region` is now the target; target mints, restores, wakes, verifies | record at rest | |
| +| `aborting` | console | source deletes staging, calls thaw, clears | record at rest | |
| + |
| +`freezing` is the new point before which abort is clean. `cutting` stays |
| +the point of no return (`provision.go:1013-1015`). |
| + |
| +**`landing` needs one change to #331's flip.** Today the flip sets `region` |
| +and clears `move` in one write (`provision.go:1036-1044`). Here the flip |
| +sets `region` and writes `move.state = "landing"` in that same single |
| +write, and the TARGET box clears `move` once verify has answered (or its |
| +budget has run out, §6.5). Region still flips in exactly one write, and the |
| +source still has nothing to do after it. What changes is that the record |
| +stays visibly in flight until the new home has proved it serves. |
| + |
| +### 6.2 The move log |
| + |
| +Every transition appends to |
| +`moves/<account>/<app>/<host>/<move-id>.json` in the deployment bucket: an |
| +object per move with every state, its timestamp, the box that wrote it, |
| +bytes and object counts copied, the freeze and cut durations, and the |
| +terminal outcome (`done`, `aborted`, `failed`) with its reason. The record |
| +says what is in flight; the log says what happened. `GET …/move` answers the |
| +in-flight state when there is one and otherwise the newest log's terminal |
| +state for 30 days: |
| + |
| +```json |
| +{"state":"done","target":"us","from":"ie","move_id":"…","updated_at":1790000000, |
| + "timings":{"prepare_s":95,"freeze_s":41,"cut_s":38,"land_s":22}} |
| +``` |
| + |
| +`"none"` then means what it says: no move this month. The flat shape |
| +titogo's client already parses (`apiinstancemove.go:76-86`) survives; the |
| +new fields are additive. |
| + |
| +### 6.3 Freeze: wake first, then ask |
| + |
| +When the console writes `freezing`, the source box's move worker: |
| + |
| +1. **Wakes the instance** through a new activator entry point, |
| + `Service.ServeHook`, a sibling of `ServeScheduled` (`activator.go:2777`) |
| + with the same shape: it wakes a sleeping instance, holds the in-flight |
| + count for the call, and does not stamp `lastActive`. This is the fix for |
| + row 1: the platform, which is the only thing that can wake, wakes. |
| +2. **POSTs the freeze hook** over the instance socket, with: |
| + - `Authorization: Bearer <instance admin token>`, minted and read exactly |
| + as the tick runner does (`schedule_wiring.go:161-182`, |
| + `EnsureAdminToken` then read); |
| + - `X-Carlos-Move-Id`, `X-Carlos-Move-To` (placement code), |
| + `X-Carlos-Move-Deadline` (RFC 3339, now + `freeze_budget_seconds`); |
| + - an empty body. |
| +3. **Reads the answer.** `200` = frozen: the app now refuses writes and will |
| + hold until the deadline. `409` = the app refuses to move now (a sale is |
| + closing, a job is mid-flight), with a sentence in the body; the move goes |
| + to `aborting` with that sentence as the reason. Anything else, or no |
| + answer within 30 s, is retried twice and then aborts. |
| +4. Writes `cutting`. |
| + |
| +The app's contract for a freeze is small and is the whole of what it has to |
| +build: |
| + |
| +- refuse mutating requests with `503 + Retry-After` until thawed or the |
| + deadline passes; |
| +- **clear the freeze itself at the deadline**, whatever else happens. That is |
| + the app's safety valve and the platform relies on it: a dead agent can |
| + never leave an app frozen past its own budget; |
| +- on boot, if `CARLOS_REGION` equals the frozen target, clear the freeze. |
| + The process woke in the target, so the move happened |
| + (`internal/activator/backend_exec.go:989-993` already sets `CARLOS_REGION` |
| + per child). |
| + |
| +Tito's instance already implements exactly this contract |
| +(titogo `internal/instance/move_freeze.go:119-132` boot-clear, |
| +`:154-181` freeze with a clamped 10-minute deadline). It changes its path |
| +and its authentication, nothing else. |
| + |
| +**No freeze hook declared:** `freezing` is written and immediately advanced |
| +to `cutting` by the same pass. The instance learns about the move when it is |
| +stopped. |
| + |
| +### 6.4 The window nobody serves, closed at the edge |
| + |
| +Today the cut sets the route's status to `moving` (`movespass.go:278`), the |
| +edge skips every route that is not `ready` (`internal/edge/edge.go:576-578`), |
| +the source's edges file stops naming the host, and until the target mints, |
| +**every edge answers `404 unknown host`** (`edge.go:1383-1387` then |
| +`:1999`). A buyer mid-checkout gets a 404. A webhook sender gets a 404, and |
| +some senders treat 4xx as permanent. |
| + |
| +The change: a route in status `moving` stays in the source edge's table as a |
| +**holding route** that answers every request `503 + Retry-After: 5`, marked |
| +as the edge's own answer (`edge.MarkAnswered`, as the sidecar refusal does, |
| +`activator.go:2820-2825`). The source's edges file keeps publishing the host |
| +with a new role, `moving`. Every other edge backhauls to the source and gets |
| +the same 503. The fold ranks `home` above `moving` (`homemap.go` |
| +`foldHomes`), so the moment the target's file names the host as `home`, |
| +every edge dials the target, and the source retires its holding route on |
| +its next pass. Nothing ever sees a 404 for a host that exists. |
| + |
| +### 6.5 Landing and verify |
| + |
| +The target box mints the route when the record's region names it (#331, |
| +unchanged), restores from staging through `promoteStagedReplica` |
| +(`backend_exec.go:1569-1600`), and then its move worker (new: the target has |
| +never run one) sees `move.state == "landing"` on a route it owns and: |
| + |
| +1. waits for the stores' env redelivery to have reached the environment |
| + bundle (§7.1), so the instance wakes into the right coordinates; |
| +2. wakes the instance through `ServeHook` and POSTs `verify` with the same |
| + headers as freeze. The app checks whatever it cares about: its region, |
| + its schema, that its files are reachable. It answers `200` or `500` with |
| + a sentence; |
| +3. clears `move` (CAS, `landing` → at rest) and writes `done`, or `done` |
| + with `verify_failed` and the sentence, to the log. |
| + |
| +**A failed verify does not roll anything back.** The data is already in the |
| +target and the source copy is intact (§6.7). It pages (§11), shows on the |
| +console and in `carlos instances move --status`, and a person decides: |
| +leave it, fix forward, or move back. A landing that has not verified within |
| +15 minutes is logged the same way. The target never blocks traffic on |
| +verify: the edge serves the new home as soon as it is `ready`. |
| + |
| +**No verify hook declared:** landing clears on the target's first |
| +successful wake. |
| + |
| +### 6.6 Abort, rollback, and the home region |
| + |
| +- **Abort** is legal in `preparing`, `ready` and `freezing`. It is #331's |
| + two-phase abort (`provision.go:1005-1034`, `movespass.go:257-273`) with |
| + one addition: if the move got as far as `freezing`, the source calls |
| + **thaw** before clearing. A thaw that fails is logged, not retried |
| + forever: the app's own deadline clears the freeze. |
| +- **After `cutting`, the move completes forward.** Rollback is a second move |
| + in the other direction, started by a person. |
| +- **That means a move back to the home region has to work.** It does not |
| + today: `apiinstancemove.go:196-202` refuses a move to home because the home |
| + box publishes no staging lane (its edges file carries no `data_bucket`; |
| + `moveTargets` drops it, `movespass.go:206-223`). The home box publishes |
| + its data bucket like every other box, and the region-host module grants |
| + every sibling push-only `moves/*` on it. For Tito, `region-ie.tf` already |
| + lists the other five as staging targets; the five siblings each need `ie` |
| + added. Until this ships, **a move out of the home region is one-way**, and |
| + the door should say so in its 202. |
| + |
| +### 6.7 What the source keeps, and for how long |
| + |
| +Nothing deletes the source's replica prefix after a move today: `movespass.go` |
| +deletes only staging (`:265`), and `lease.DeletePrefix` has no other caller |
| +(`internal/lease/mirror.go:113`). So a moved instance's full history stays |
| +in the region it left, indefinitely. That breaks the promise a move is |
| +bought for. |
| + |
| +The source box keeps `instances/<host>/` for a **retention fence**, default |
| +7 days from `done`. It is the rollback evidence and the only copy if the |
| +target's bucket fails. After the fence it deletes the prefix and the |
| +declared stores' source copies, and logs the deletion with a count of |
| +objects removed. Self-healing, never silent: if the deletion fails it |
| +retries on the next pass and logs once a minute |
| +(`movespass.go:367-376`'s throttle). A second move of the same host before |
| +the fence resets it. `carlos instances move --status` shows "source copy in |
| +ie until 2026-10-01". |
| + |
| +## 7. Stores follow the instance |
| + |
| +### 7.1 A private store |
| + |
| +A store today is deployment-wide in one region: `CARLOS_STORE_REGION` is |
| +read once by the console (`2026-09-02-object-storage-self-serve-design.md` |
| +§1, `cmd/console/main.go:492-524`). So the first piece is **a store backend |
| +per placement**: each armed region publishes its store region and bucket |
| +prefix (the edges file again, beside `data_bucket`), and a new store is |
| +created in its instance's region. |
| + |
| +When `stores` names `"private"`, the move: |
| + |
| +1. **preparing**: provisions a twin of the instance's environment store in |
| + the target region (same entitlement, same walk, a new bucket and |
| + credentials, `internal/objstore/provisioner.go`), and mirrors objects |
| + into it in the same delta rounds as the replica. `ready` requires both |
| + mirrors to be caught up. |
| +2. **cutting**: after the child is down, one final store delta. The child |
| + was the only writer, and a stopped child cannot write, so this delta is |
| + final. |
| +3. **the flip**: in the same pass, rewrites the environment's store |
| + coordinates in its config bundle to the twin's |
| + (`appconfig.ApplyEnvironment`, per the 2026-09-19 spec §4), sealed as |
| + today. The target wakes the instance only after that delivery has landed |
| + (§6.5 step 1). |
| +4. **after the fence**: deletes the source store (§6.7). |
| + |
| +### 7.2 A shared app store with a per-instance prefix |
| + |
| +When `store_prefixes` names an app-level store, only the keys under the |
| +instance's prefix follow, into the same app's store twin in the target |
| +region. The twin is provisioned on first use and shared by every instance of |
| +the app homed there. The env the instance receives changes bucket and |
| +region, not key layout. The app must read its store coordinates from env on |
| +every boot, not cache them. |
| + |
| +This only works for an app whose keys are prefix-shaped. An app that keys a |
| +shared bucket by random token and records ownership only in each instance's |
| +database (Tito: `assets/<token>`, titogo `internal/instance/assets.go:132`; |
| +the live bucket lists exactly that) has no prefix to declare. It moves to a |
| +private store first (§7.1), and that is a one-time copy the app does, not |
| +the platform. |
| + |
| +### 7.3 A bucket the app brought itself |
| + |
| +Not the platform's to copy: it holds no credentials for it and cannot know |
| +which keys are whose. The move door's 202 body lists |
| +`"not_moved": ["app-managed buckets are not moved"]` whenever the app's |
| +manifest has no `stores` declaration, so nobody reads a finished move as a |
| +complete one. Tito's uploads bucket (`titogo-uploads-665106592142`, |
| +eu-west-1, set through `TITO_S3_*`) is this case today; the Tito record |
| +covers how it leaves it. |
| + |
| +## 8. Apps with their own router |
| + |
| +Some apps front many instances behind a router of their own. Tito's root |
| +domain, `go.tito.io/<slug>/…`, is served by Tito's router, because that is |
| +where Tito's admin credentials live and where it signs identity headers for |
| +the instance. For those requests the router dials each instance socket |
| +itself: `/run/carlos/<host>.sock` for platform instances, which it reads off |
| +the platform's registry row. Two things break that on a move: the socket |
| +exists only on one box, and only while the instance is awake. |
| + |
| +The platform already solves both for its own traffic. The edge wakes, and |
| +the edge backhauls to whichever box homes a host (`homemap.go:611`, |
| +`Home`; `edge.go:1379-1387`). So the proposal is to **let an app's router use |
| +the edge instead of the socket**: |
| + |
| +- **The door.** A box-local unix socket, `/run/carlos/edge-local.sock`, owned |
| + by the edge, mode 0660, group = a per-app group the app's router unit |
| + joins (`SupplementaryGroups=`). A request on it carries `Host: <instance |
| + host>` and is handled exactly as if it had arrived from the internet at |
| + this edge: local socket if this box homes the host (with the wake), signed |
| + backhaul if another box does, holding 503 if it is moving. It is refused |
| + for any host not owned by the dialling app (the edge checks the host's |
| + record's account and app against the group's). |
| +- **What it is not.** It is not a bypass. The password gate, the |
| + hook-path refusal (§10) and the edge's own XFF rules apply as on the |
| + public door. Headers the app sets (Tito's signed `X-Tito-Auth-*`) pass |
| + through untouched, as on any request. The one difference: the edge treats |
| + the router's XFF as a chain to carry, the way it treats a verified hop |
| + (`homemap.go:44-55`), because the caller is a process on the box that |
| + proved group membership, not a stranger stating its own address. |
| +- **What it retires.** The app no longer needs a `home` column, a hairpin of |
| + its own, a wake nudge through the public internet, or a per-region |
| + router to reach a moved instance. Tito's hairpin (titogo PR #1906, "the hairpin: a remote home is an HTTPS hop, not a wall") was |
| + built to cross regions over public HTTPS. It becomes one dial. |
| + |
| +The alternative, for apps that would rather keep their own routing: the |
| +instance GET already carries `served_by` (`internal/console/apiinstances.go:174-181`) |
| +and, with §6.2, a move's terminal state. An app can poll that and re-point |
| +its routes itself. That is the #331 contract with better signals, and it |
| +stays available. It is not what we recommend, because every such app would |
| +rebuild the same state machine. |
| + |
| +## 9. Bringing an instance under a record (adoption) |
| + |
| +An app that started by running instances itself (Tito's controld, 71 hosts) |
| +cannot move any of them until each one has a platform record. #126 blocked |
| +that for good reason: exec children shared one uid and one flat `/data`. |
| +The fix is merged and rolled: the isolated-exec series |
| +(`2026-09-11-isolated-exec-design.md`, merge `c1d40eeb`, 2026-09-12; |
| +the final fix round `34beb1b3` is in `v0.20.0` and `v0.21.0`). carlos-tito-ie |
| +runs `carlos v0.21.0`. **But the layout has not been migrated on that box**: |
| +`/data/layout/` does not exist and `/data/instances/` holds only `_probe` |
| +(read 2026-09-24), so its 13 platform instances still run flat, as one uid. |
| +Step 4 of that design's rollout (§8.6, "carlos-tito-ie: Tito's operator |
| +rolls it … then migrates") is the gate. It is an operator act on a built |
| +feature, not new code. |
| + |
| +After that, adoption needs one new door: |
| + |
| +- `POST …/instances` with `"import": {"sha256": "…", "bytes": N}` answers |
| + a one-time presigned PUT URL for `imports/<host>/snapshot.db` in the |
| + target region's data bucket (15-minute expiry, exact length, exact |
| + checksum). The app uploads a consistent snapshot (`VACUUM INTO`, taken |
| + while its own supervisor has the instance frozen). |
| +- The record is written with `import: pending`. The activator's `Restore` |
| + gains one more fallback beside staging (`backend_exec.go:290`): no own |
| + replica, no staging, an `imports/<host>/snapshot.db` → verify its checksum, |
| + `CheckDB`, place it at the row's DB path, start replicating from |
| + generation one, delete the import. |
| +- **The host already has a route**, the app's own addr row. Reconcile |
| + today adopts an unprovisioned row only when its shape matches the record, |
| + and otherwise logs "exists but is not the route this record describes" |
| + and leaves it alone (`internal/provision/reconcile.go:1022-1034`). That is |
| + the right default and adoption must not weaken it. Instead, an |
| + `import: pending` record carries the addr it is replacing, and reconcile |
| + swaps that one row, and only if its addr matches, in one registry |
| + transaction: the addr row becomes the exec row, on this box. The edge picks up the new row on its next |
| + reload. Requests during that reload go to whichever row the edge still |
| + holds, and the app's supervisor must keep its own process frozen until |
| + the platform instance serves. `verify` (§6.5) is how the platform knows it |
| + does. |
| + |
| +Adoption and move are separate verbs on purpose. An adopted instance lands |
| +in the region its data is already in, and then moves like any other. |
| + |
| +## 10. Security |
| + |
| +- **Staging stays push-only.** The `moves/*` grant (`infra/modules/region-host`, |
| + `sibling_staging_arns`) is unchanged: Put, Delete and prefix-conditioned |
| + List on siblings' `moves/*`, never Get, never `instances/*`. Tito's |
| + deployment has it applied on all six boxes (IAM read, 2026-09-24). Store |
| + twins widen it by one statement: Put/Delete/List on |
| + `arn:aws:s3:::<store-bucket-prefix>-*/moves/*` in sibling regions, and the |
| + target's own promotion copies `moves/` into place, exactly as the replica |
| + does. No box ever reads another region's data. |
| +- **Hooks never come from outside.** The edge refuses (404, byte-identical to |
| + unknown host) any public request whose path matches a declared hook path |
| + for that host's app. The app must not have to tell a real freeze from a |
| + forged one by headers. The only way a request reaches a hook path is the |
| + agent's `ServeHook` over the socket, with the admin token. |
| +- **The admin token's reach does not grow.** It already arrives on every |
| + tick. A hook is one more path it is presented on, by the same sender, over |
| + the same socket. |
| +- **The edge door is scoped per app, by group and by record.** A process |
| + in app A's group cannot reach app B's instances through it. It is on the |
| + box, so it is no more exposed than the sockets it replaces. Unlike those |
| + sockets, it works under the isolated layout, where an app's router can no |
| + longer see `/run/carlos` at all. |
| +- **Imports are one-shot and pinned.** The presigned PUT is bound to one key, |
| + one length and one checksum, expires in 15 minutes, and the activator |
| + verifies the checksum again before trusting the file. |
| +- **Record authority.** A move is a record write, and discussion #171 |
| + ("A region box's IAM role can write any account's records") means any |
| + compromised box can forge one today. This design adds no new writer class, |
| + but a forged `move.to` is a data-exfiltration primitive. #171's fix bounds |
| + it, and this record depends on it before moves are offered self-serve to |
| + apps outside the deployment's own operator. |
| + |
| +## 11. Observability |
| + |
| +- `carlos instances move <host>`: `--to <region>` requests, `--cut`, |
| + `--abort`, `--status`, `--wait` (polls to a terminal state, printing each |
| + transition with its timing). Designed for a user with no box access (the |
| + 2026-08-08 rule): every fact it prints comes from the console. |
| +- The console's instance page shows the in-flight state and the last |
| + 30 days of move logs. |
| +- Box logs keep the `moves: <host>: …` shape (`movespass.go`) and add one |
| + line per transition with its duration. |
| +- **Stuck states page.** Each state has a time fence, and passing it is |
| + visible and alerting, never an automatic action on the far side of the cut: |
| + |
| +| state | fence | what happens past it | |
| +|---|---|---| |
| +| `preparing` | 60 min without a caught-up round | logged `stalled` with the last delta size; stays abortable | |
| +| `freezing` | 60 s | abort (the app never froze; clean) | |
| +| `cutting` | 15 min | pages; completes forward only | |
| +| `landing` | 15 min | logged `done, verify_timeout`; pages | |
| +| source retention | 7 days | source copy deleted, logged | |
| + |
| +## 12. Gap list against today's code |
| + |
| +| # | gap | where | |
| +|---|---|---| |
| +| G1 | No wake before a tenant's freeze; nothing lets a tenant wake at all | `activator.go:2777-2779` (ticks only); titogo `move_order.go:30-36` | |
| +| G2 | No hook contract; freeze is tenant-held by design | #331 spec "The two verbs"; `apiinstancemove.go:232-238` | |
| +| G3 | Edge answers 404 for a moving host | `edge.go:576-578`, `:1383-1387`, `:1999` | |
| +| G4 | Moves to the home region refused, so rollback out of home is impossible | `apiinstancemove.go:196-202`; `movespass.go:214` | |
| +| G5 | Terminal state invisible; `"none"` after done | `provision.go:1036-1044`; `apiinstancemove.go:88-91` | |
| +| G6 | Source replica never deleted after a move | `movespass.go:265` (staging only); `lease/mirror.go:113` | |
| +| G7 | Stores are one region per deployment, and nothing copies one | `CARLOS_STORE_REGION`, `cmd/console/main.go:492-524` | |
| +| G8 | No CLI verb; console API only | `instances_cli.go` (verbs: enable, create, list, delete, set-upstreams, set-channel, set-environment) | |
| +| G9 | No adoption/import door for an externally-run instance, and reconcile will not replace a differently-shaped row | `apiinstancemove.go:43-46`; `reconcile.go:1022-1034`; `Manifest.Seed` is a first-boot argv, not data (`provision.go:233`) | |
| +| G10 | Isolated layout built but not migrated on carlos-tito-ie | isolated-exec spec §8.6 step 4; box read 2026-09-24 | |
| +| G11 | An app's own router cannot reach a moved instance | titogo `registry.go:539-541`, `router.go:580-582`, `move_sweep.go:648-664` | |
| +| G12 | **Verify before building:** the tick runner fires a route without checking its status, and `ServeScheduled` does not check it either, so a tick due during a cut could wake the source after the purge and restore it from its own replica: a second writer the target never hears about | `internal/schedule/runner.go:143-152`; `activator.go:2781-2795` (checks fences, kind, hibernate; not status). The due-wake index does skip non-ready routes (`activator.go:2547`), which is why this needs a test, not an assumption | |
| +| G13 | Record writes are box-forgeable, so a forged `move.to` exfiltrates | discussion #171 | |
| + |
| +## 13. Slices |
| + |
| +Each slice is its own PR, lands dormant, and is useful alone. The order puts |
| +Tito's first real move (`elatromme` → `us`, a 5 MB database, not an import |
| +mirror) as early as it can safely be. |
| + |
| +| slice | what | depends on | unblocks | |
| +|---|---|---|---| |
| +| **P0** | Run `carlos system layout migrate` on carlos-tito-ie (operator act, built) and fix G12 with a test | nothing new | P6, and any adoption | |
| +| **P1** | `ServeHook`, the manifest `moves.hooks` declaration, `freezing` state, freeze/thaw calls with the tick's token; hook-path refusal at the edge | P0 for G12 | row 1's failure mode, for every app | |
| +| **P2** | Holding route: `moving` edges role, 503 instead of 404 | — | a cut nobody sees as an error | |
| +| **P3** | Home region publishes a staging lane; moves to and from home | infra: add `ie` to each sibling's `sibling_staging_arns` | rollback | |
| +| **P4** | Move log, terminal state on the GET, `landing` + verify, `carlos instances move` | P1 | apps stop inferring done | |
| +| **P5** | Source retention fence and deletion | P4 (needs `done` time) | the residency promise | |
| +| **P6** | Adoption: import door, `imports/` restore fallback, addr-row swap | P0 | every controld-style instance | |
| +| **P7** | Edge-local door for app routers | P0 (isolated layout is why the sockets stop being reachable) | apps with their own router, Tito's root domain | |
| +| **P8** | Per-placement store backend; private store and prefix stores follow | P4, P5 | uploads residency | |
| + |
| +**For elatromme → us, the minimum is P0, P6, P1, P3, and P7 or its polling |
| +alternative** (§8), plus Tito's own slices in the linked record. P2 is small, |
| +and a customer's first move should not show buyers a 404, so it belongs in |
| +that set too. P8 is the only way uploads move, and whether the first move |
| +waits for it is a product call (§14). |
| + |
| +## 14. Open questions |
| + |
| +1. **Freeze budget ownership.** The app declares the budget, and the |
| + platform never extends a freeze past it. If `cutting` itself runs past |
| + the budget (a slow final checkpoint), the app unfreezes while its process |
| + is already stopped, which is harmless. Is that acceptable for every app, |
| + or should the platform refuse to cut when the measured cut time (#331's |
| + rehearsal: status→quiesce→flip 331 s before the serial nudge) exceeds the |
| + declared budget? |
| +2. **Replicas on a move.** A home with `replicas` (discussion #162): does a |
| + move of the home carry the replica set's placements unchanged, refuse |
| + while replicas exist, or re-point them? |
| +3. **The edge door's identity.** Group membership per app on the box is the |
| + simplest proof. Is a per-app group acceptable under the isolated layout's |
| + uid model, or should the door take the app's scoped key instead? |
| +4. **What the control plane keeps in the home region.** The record, the |
| + config bundle, the secrets and the move log all live in the deployment |
| + bucket, in the home region. A move relocates data, not configuration. |
| + Is that the promise we make to apps, stated in `docs/site`, or does the |
| + config bundle need a regional copy too? |
| +5. **Self-serve for other accounts.** Until #171 is fixed, should the move |
| + doors be operator-only for accounts other than the deployment's own? |