carlos / platform Public

docs: instance moves — fold in the Fable design review

Cut fence against tick wakes (G12, confirmed), abort until the purge,
landing never abortable and consoles roll first, holding route covers
aliases and consults the fold, target fenced until store env lands,
home-region fix is a placement not a bucket, retention re-checks at fire
time, store copies use a move-scoped credential minted by the console,
adoption stops rather than freezes, the layout pass is P9 and unbuilt,
GET history is opt-in so Tito's live sweep does not wedge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev ea3f487ffe60613875edfa6e75c9b101b374f350 parent 0290737
1 file changed, +259 −94
  • docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md +259 −94

CI passed — run details

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
index 10c5b419d..408085190 100644
--- 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
@@ -2,7 +2,10 @@
**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.
+for a CARLOS session to own and build; nothing here is built. Reviewed as
+a design by Fable on 2026-09-24; its eighteen findings are folded in (the
+biggest: the tick-wake dual writer, the home-region diagnosis, and the
+layout pass being unbuilt).
**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
@@ -32,7 +35,9 @@ two production attempts are the whole record, and both failed
**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
+instance has nothing listening on it (the error is ENOENT; titogo's own
+`internal/carlos/wake.go:10-13` records the same observation for proxied
+traffic). 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
@@ -132,7 +137,7 @@ 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,
+§5). 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.
@@ -150,7 +155,7 @@ does.
```
- **`hooks`**: any subset. Paths are validated with the schedule
- declaration's path rules (`internal/schedule/declaration.go:38-60`: must
+ declaration's path rules (`internal/schedule/declaration.go:40-63`: 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
@@ -184,12 +189,43 @@ preparing ──► ready ──► freezing ──► cutting ──► landing
| `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) |
+| `cutting` | source box | #331's cut, unchanged in order, plus the store's final delta | `landing` (the flip); `aborting` until the purge (below) |
| `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`).
+**The point of no return moves from "cutting" to "the purge".** Today
+abort is refused from the moment `cutting` is written
+(`provision.go:1013-1015`), but the only step that cannot be undone is the
+local purge (`movespass.go:333`). Before it, the local DB, the own replica
+and the route row are all intact. A cut stuck before the purge (a denied
+store grant, a target bucket that will not take the final delta) would
+otherwise hold the host at 503 on every edge with nothing but a page to
+answer it. So the cut stamps `move.purged_at` in a CAS immediately before
+it purges, and abort is legal in `cutting` until that stamp exists. An
+abort in `cutting` lifts the fence (below), sets the route `ready`, calls
+thaw, then deletes staging as today.
+
+**The cut fences the source first (G12).** Setting status `moving` stops
+the edge, but nothing stops the tick runner: it fires any row the registry
+returns (`internal/schedule/runner.go:143-152`), and `ServeScheduled`
+checks kind and hibernate, not status (`activator.go:2781-2795`). A tick
+due after the purge would wake the source, restore it from its own replica
+and let it write: a second writer the target never hears about. `Quiesced`
+(`activator.go:2675`) only asks whether an entry is live, so it cannot
+catch this. The cut therefore starts with `FenceAndStop`
+(`internal/activator/maintenance.go:44-50`), the maintenance fence every
+wake path already honours (`activator.go:2783`), and `ServeHook` honours
+it too. The fence stays until the route row is retired, or is lifted by an
+abort.
+
+**`landing` must never be abortable, and consoles roll first.** A console
+built before this design would accept DELETE on a `landing` record
+(`provision.go:1008-1022` refuses only `cutting` and `aborting`). The
+target's worker would then run the aborting arm, delete `moves/<host>/`
+from its own bucket before promotion, and the first wake would seed an
+empty database. `MoveAbort` refuses `landing` explicitly, and the rollout is
+consoles before boxes. That is the opposite of the #331 order, and it is
+stated in the plan's first task.
**`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
@@ -215,9 +251,14 @@ state for 30 days:
"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.
+`"none"` then means what it says: no move this month. **But only for a
+client that asks.** Tito's live sweep treats anything but `aborting` or
+`none` after an abort as "try again next tick" (titogo
+`internal/carlos/move_sweep.go:583-600`) and would wedge on `aborted`, and
+its `stepScheduled` would adopt a month-old `done` to the same target as a
+live move (`:130-135`). So the terminal state is served only with
+`?history=1`, and the default answer stays today's. Old clients see no
+change.
### 6.3 Freeze: wake first, then ask
@@ -239,7 +280,9 @@ When the console writes `freezing`, the source box's move worker:
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.
+ answer within 30 s, is retried twice and then aborts. The response body is
+app-controlled text, so at most 512 bytes of it are kept, as plain text,
+and it is escaped wherever the console renders it.
4. Writes `cutting`.
The app's contract for a freeze is small and is the whole of what it has to
@@ -252,8 +295,12 @@ build:
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).
+ (`internal/activator/backend_exec.go:989-993` sets `CARLOS_REGION` per
+ child). **Except at home:** that same code strips the variable when the
+ box's region is empty, and the home box's is. A child landing at home
+ never sees a region, so this bullet cannot fire on a move home until
+ P3 gives home children a code (§6.6). Until then the freeze lapses at its
+ deadline, which is safe but slow.
Tito's instance already implements exactly this contract
(titogo `internal/instance/move_freeze.go:119-132` boot-clear,
@@ -281,7 +328,21 @@ 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.
+its next pass.
+
+Two details make that true rather than nearly true:
+
+- **The source's own holding route consults the fold before answering.** A
+ local table entry wins over backhaul (`edge.go:1381-1387`), so a client
+ whose DNS lands on the source edge would keep getting 503 after the flip,
+ until reconcile retires the row. The holding handler backhauls instead
+ when another box already publishes the host as `home`.
+- **Custom domains are held too.** An alias is derived per box from its
+ primary (`internal/domains/aliases.go:104-105`) and published only while
+ the primary is a served row (`edgesfile.go:243`), so dropping the primary
+ at `moving` drops every attached domain with it, until the target derives
+ it again. The holding route carries the host's aliases, published with
+ the same `moving` role.
### 6.5 Landing and verify
@@ -290,9 +351,14 @@ 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
+1. **holds the new row fenced** until the stores' env redelivery has
+ reached this box (§7.1). The edge answers the holding 503 for it, so no
+ buyer's request can wake the instance into the old store's
+ coordinates, write after the final delta, and lose that write when the
+ old store is deleted. `activator.go:1100` already refuses wakes on a
+ layout fence, and this is the same shape. An instance with no declared
+ stores has nothing to wait for;
+2. lifts the fence, 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;
@@ -304,7 +370,8 @@ 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`.
+verify. It blocks only on the env delivery in step 1, which is a fact the
+platform controls, not a judgment the app makes.
**No verify hook declared:** landing clears on the target's first
successful wake.
@@ -319,14 +386,27 @@ successful wake.
- **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.
+ today, and the reason is the missing **placement**, not the bucket. The
+ home box already publishes `data_bucket` (`internal/host/edgesfile.go:515`
+ reads `CARLOS_HIBERNATE_BUCKET` unconditionally). What it leaves out is
+ `placement`, because a home box runs with `CARLOS_REGION` unset
+ (`edgesfile.go:514`), and it has to: records store `""` for home and
+ reconcile scopes by equality (`internal/host/agent.go:2023-2026`,
+ `internal/provision/reconcile.go:339`). So `placements()`
+ (`internal/console/instancesplacement.go:176`) and `moveTargets`
+ (`movespass.go:214`) both drop it, and `apiinstancemove.go:196-202`
+ refuses. Worse, if the door allowed it, `landing` would never clear:
+ `runMoveRound` would look up `targets[""]` and wait forever.
+- **The fix is a second variable, not a changed one.** The home box gets
+ `CARLOS_HOME_PLACEMENT` (the console's `CONSOLE_HOME_REGION` code, `ie`
+ for Tito). It publishes it in the edges file with a `home: true` mark,
+ `moveTargets` maps `""` to that entry, and exec children at home receive
+ it as `CARLOS_REGION`, so an app sees the same variable in every region.
+ Record scope stays `""`, so nothing that reconciles today changes. The
+ region-host module grants each sibling push-only `moves/*` on the home
+ bucket. For Tito, `region-ie.tf` already lists the other five; each of
+ the five needs `ie` added. Until this ships, **a move out of the home
+ region is one-way**, and the door says so in its 202.
### 6.7 What the source keeps, and for how long
@@ -342,9 +422,20 @@ 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".
+(`movespass.go:367-376`'s throttle). `carlos instances move --status` shows
+"source copy in ie until 2026-10-01".
+
+**The fence re-checks at fire time, because the prefix may be live again.**
+A move ie→us and then us→ie within the week promotes the second move's
+staging over `instances/<host>/` in ie (`backend_exec.go:1593`). A deletion
+scheduled by the first move would then remove the live replica. So the
+deletion is keyed to its move id and runs only if, at the moment it fires,
+the record's region is not this box's, this box owns no route for the host,
+there is no local DB, and no later move log names this region as a target.
+Any one of those failing cancels it, and the cancellation is logged. The
+task lives in the move log, not in the box's memory, so any box in the
+source region can pick it up. If none is left, `--status` says the source
+copy has no box to delete it.
## 7. Stores follow the instance
@@ -352,7 +443,7 @@ ie until 2026-10-01".
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
+§1, `cmd/console/main.go:554-556`). 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.
@@ -367,13 +458,35 @@ When `stores` names `"private"`, the move:
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).
+3. **the flip**: the environment's store coordinates in its config bundle
+ are rewritten to the twin's (`appconfig.ApplyEnvironment`, per the
+ 2026-09-19 spec §4), sealed as today. The target holds the instance
+ fenced until that delivery has landed (§6.5 step 1).
4. **after the fence**: deletes the source store (§6.7).
+**Who does which part, and the IAM it costs.** Provisioning the twin
+(bucket, IAM user, keys) and rewriting the bundle are console acts today
+(`ApplyEnvironment`'s callers are all console-side:
+`internal/console/apiconfig.go:437`, `internal/objstore/walk.go:725`,
+`:836`). They stay there. A box's move worker never writes a config bundle
+or an IAM user. The console writes both when it sees the record reach
+`ready` and the flip respectively, and the target's fence waits on the
+console's delivery.
+
+The object copy is the widening, and it is real. A cross-region
+`CopyObject` runs at the destination and reads the source
+(`internal/lease/mirror.go:126-129`), so whoever copies must be able to
+**read the tenant's store**. `infra/modules/region-host/main.tf:609-612`
+records "No GetObject: the console provisions stores, it does not read
+tenant data" as a deliberate property. The proposal keeps that true for
+every standing identity: for each move the console mints a **short-lived
+IAM user scoped to one move**, with GetObject and ListBucket on the one
+source store bucket and PutObject and DeleteObject on the one twin, and
+deletes it when the move reaches a terminal state. The box's move worker
+receives those keys through the move record's sealed field and uses them
+for the store rounds only. That is new: a credential that can read tenant
+data exists for the length of a move. §14 asks whether that is acceptable.
+
### 7.2 A shared app store with a per-instance prefix
When `store_prefixes` names an app-level store, only the keys under the
@@ -383,6 +496,14 @@ 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.
+**It needs a per-instance environment to do that.** A shared app store's
+coordinates live in the app's default bundle, which every instance reads.
+Rewriting it would re-point every instance, and not rewriting it leaves the
+moved one on the source bucket. So `store_prefixes` is refused at
+`enable` unless the app also has the private-storage policy (every
+instance has its own environment, 2026-09-22 spec §5), and the move
+writes the twin's coordinates into that instance's environment only.
+
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`;
@@ -406,9 +527,15 @@ 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.
+itself: for platform instances Tito's router computes
+`/run/carlos/<host>.sock` (titogo `internal/carlos/fleet_http.go:934`) and
+dials it as an enrolled `carlos-sockets` consumer. Three things break that:
+the socket exists only on one box, only while the instance is awake, and
+under the isolated layout at a different path
+(`/run/carlos/instances/<iid>/app.sock`, isolated-exec design §3.1). The
+consumer contract survives the layout change (same design, §1.1 and §3.1),
+so an app that read the path from the platform would keep working. An app
+that computes it breaks.
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`,
@@ -429,7 +556,10 @@ the edge instead of the socket**:
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.
+ proved group membership, not a stranger stating its own address. The
+ door builds on the same `carlos-sockets` enrolment the socket contract
+ already uses, so it needs no layout change and ships independently of
+ the layout pass.
- **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
@@ -447,15 +577,17 @@ rebuild the same state machine.
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.
+The fix is **half built**. The isolated-exec series merged
+(`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`), and
+carlos-tito-ie runs `carlos v0.21.0`. What shipped is classification and
+fences: `internal/activator/layout.go:30-32` reads migration journals that
+"plan 4 writes". Plan 4 itself (`130ad340`) is a plan document, and nothing
+writes a journal. There is no `carlos system layout migrate` verb either:
+`carlos system` is update, status and rollback (`main.go:271-272`). On the
+box, `/data/layout/` does not exist and `/data/instances/` holds only
+`_probe` (2026-09-24), so its 13 platform instances still run flat, as one
+uid. **The layout pass is new code**, and it is P9.
After that, adoption needs one new door:
@@ -463,7 +595,13 @@ After that, adoption needs one new door:
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).
+ after its own supervisor has **stopped** the instance, not frozen it).
+- The presign is signed by the console, which holds Put on the home
+ region's data bucket only (`infra/modules/region-host/main.tf:162-176`
+ grants a box its own bucket and siblings' `moves/*`, nothing more). So an
+ import lands in the **home** region, which is where an adopted instance's
+ data already is. Importing straight into another region is not offered:
+ adopt at home, then move.
- 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,
@@ -476,11 +614,21 @@ After that, adoption needs one new door:
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.
+ transaction: the addr row becomes the exec row, on this box. The edge
+ picks up the new row on its next reload.
+- **Stopped, not frozen, and the platform waits to be told.** A freeze is
+ the wrong tool here: Tito's clamps each order to 10 minutes
+ (`move_freeze.go:17`, `:169-173`), and upload, record, reconcile, edge
+ reload and first wake can outlast that (#331's rehearsal measured mint
+ plus wake at 700 s before the serial nudge). When the clamp lapsed, the
+ old process would take writes while the snapshot served. So the app stops
+ its own process before the snapshot, and the swap waits for a second call,
+ `POST …/instances/{host}/import/stopped`, which the app makes only after
+ its supervisor has confirmed the process is down. Until the swap, the app's
+ own row answers, so the app's router must answer 503 for a stopped
+ instance. For a small database that window is a minute or two, and it is
+ the whole cost of adoption.
+- `verify` (§6.5) is how the platform knows the adopted instance serves.
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.
@@ -490,11 +638,13 @@ in the region its data is already in, and then moves like any other.
- **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.
+ deployment has it applied on all six boxes (IAM read, 2026-09-24). No
+ standing identity gains a read of anyone's replica.
+- **Stores are the one widening,** and §7.1 states it: a move-scoped IAM
+ user that can read one source store and write one twin, minted by the
+ console and deleted at the terminal state. No standing identity gains
+ GetObject on tenant data, and the move worker never writes a config
+ bundle or an IAM user.
- **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
@@ -505,16 +655,17 @@ in the region its data is already in, and then moves like any other.
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.
+ box, so it is no more exposed than the sockets it replaces. Unlike a
+ computed socket path, it does not change when the layout does.
- **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
+ compromised box can forge one today. The console stays the only writer of
+ bundles and IAM (§7.1), and boxes write only the move states they already
+ write, but a forged `move.to` is a data-exfiltration primitive, and with
+ P8 it would also mint a store-reading credential. #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.
@@ -534,8 +685,9 @@ in the region its data is already in, and then moves like any other.
| 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 |
+| `freezing` | 120 s (three 30 s attempts plus margin) | abort (the app never froze; clean) |
+| `cutting`, before the purge | 15 min | pages; abortable (§6.1) |
+| `cutting`, after the purge | 15 min | pages; completes forward only |
| `landing` | 15 min | logged `done, verify_timeout`; pages |
| source retention | 7 days | source copy deleted, logged |
@@ -546,16 +698,19 @@ in the region its data is already in, and then moves like any other.
| 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` |
+| G4 | Moves to the home region refused: home publishes no placement (it does publish its bucket), so rollback out of home is impossible; home children get no `CARLOS_REGION` | `apiinstancemove.go:196-202`; `edgesfile.go:514-515`; `movespass.go:214`; `backend_exec.go:989-993` |
| 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` |
+| G7 | Stores are one region per deployment, and nothing copies one | `CARLOS_STORE_REGION`, `cmd/console/main.go:554-556` |
| 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 |
+| G10 | The isolated layout pass is not built: fences and classification shipped, the pass and its verb did not; nothing is migrated on carlos-tito-ie | `internal/activator/layout.go:30-32`; `main.go:271-272`; plan `130ad340`; 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 |
+| G12 | **Confirmed dual-writer race today:** a tick due during or after a cut wakes the source, which restores from its own replica and writes; the target never hears of it | `internal/schedule/runner.go:143-152`; `activator.go:2781-2795` (fences, kind, hibernate; not status); `activator.go:692`; `Quiesced` at `:2675` is "no live entry" only |
| G13 | Record writes are box-forgeable, so a forged `move.to` exfiltrates | discussion #171 |
+| G14 | A replica record passes the move gate and then wedges in `preparing`: reconcile never mints a replica on a platform box, so `OwnsRoute` is false | `apiinstancemove.go:53-72` (no `Role` or `Replicas` check); `reconcile.go:210` |
+| G15 | Instance-registered once-timers and `NextDue` live in the source box's registry and are lost on a move, silently | `internal/schedule/control.go:28` |
+| G16 | An old console accepts DELETE on `landing`, which empties the instance | `provision.go:1008-1022` |
## 13. Slices
@@ -565,31 +720,33 @@ 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 | **P7 first on any deployment whose app router dials instance sockets itself** (below) | 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 |
-
-**P0 has an ordering trap on Tito's box.** Tito's router does not read an
-instance's socket from the platform. It guesses `/run/carlos/<host>.sock`
+| **P0** | The cut fence (G12), `MoveAbort` refusing `landing` (G16), the move gate refusing replicas (G14). Small, and needed before any production move, including today's | — | every move, safely |
+| **P1** | `ServeHook`, the manifest `moves.hooks` declaration, `freezing` state, freeze/thaw calls with the tick's token; hook-path refusal at the edge; abort until the purge | P0 | row 1's failure mode, for every app |
+| **P2** | Holding route: `moving` edges role for the host and its aliases, 503 instead of 404, fold-aware | — | a cut nobody sees as an error |
+| **P3** | `CARLOS_HOME_PLACEMENT`; moves to and from home; home children get `CARLOS_REGION` | infra: add `ie` to each sibling's `sibling_staging_arns` | rollback |
+| **P4** | Move log, terminal state on `GET …/move?history=1`, `landing` + verify, `carlos instances move`; once-timers carried (G15) | P1 | apps stop inferring done |
+| **P5** | Source retention fence and deletion, re-checked at fire time | P4 | the residency promise |
+| **P6** | Adoption: import door (home region), `imports/` restore fallback, `import/stopped`, addr-row swap | P0, and P9 on any box where isolation matters (all of them) | every controld-style instance |
+| **P7** | Edge-local door for app routers | — (uses the existing `carlos-sockets` enrolment) | apps with their own router; makes P9 safe for them |
+| **P8** | Per-placement store backend; private store and prefix stores follow, with the move-scoped credential | P4, P5 | uploads residency |
+| **P9** | The isolated layout pass and `carlos system layout migrate` (isolated-exec plan 4, written, not built), then run on carlos-tito-ie | P7 on any deployment whose app router computes socket paths | isolation for adopted instances (#126) |
+
+**P9 has an ordering trap on Tito's box.** Tito's router does not read an
+instance's socket from the platform. It computes `/run/carlos/<host>.sock`
(titogo `internal/carlos/fleet_http.go:934`, `signup_http.go:660`), and
-the isolated layout moves the socket to `/run/carlos/instances/<iid>/app.sock`
-(isolated-exec design §3.1). Migrating the layout first would cut Tito's
-root domain off from every platform instance on the box. So on carlos-tito-ie
-the order is P7, then Tito's router switches to the door, then P0. Any other
-app that dials instance sockets by a path it computed has the same trap,
-which is one more argument for P7.
-
-**For elatromme → us, the minimum is P7, P0, P6, P1, P3 and P2** (P7 or its
-polling alternative, §8), plus Tito's own slices in the linked record. P2
-is on that list because a customer's first move should not show buyers a
-404. P8 is the only way uploads move, and whether the first move waits for
-it is a product call for Tito, not a platform one.
+the layout pass moves the socket to `/run/carlos/instances/<iid>/app.sock`.
+Running it first would cut Tito's root domain off from every platform
+instance on the box. So on carlos-tito-ie the order is P7, then Tito's router
+switches to the door, then P9. P7 does not depend on the layout, so there is
+no cycle. Any other app that dials sockets by a path it computed has the
+same trap, which is one more argument for P7.
+
+**For elatromme → us, the minimum is P0, P7, P9, P6, P1, P2 and P3,** plus
+Tito's own slices in the linked record. P2 is on that list because a
+customer's first move should not show buyers a 404. P4 is strongly advised,
+because without it Tito keeps inferring `done`. P8 is the only way uploads
+move, and whether the first move waits for it is a product call for Tito,
+not a platform one.
## 14. Open questions
@@ -613,3 +770,11 @@ it is a product call for Tito, not a platform one.
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?
+6. **The move-scoped store credential (§7.1).** A short-lived IAM user that
+ can read one tenant store for the length of a move. Acceptable, or
+ should store copies run as the tenant's own store credential, which the
+ instance already holds, in a hook the app runs?
+7. **Adoption downtime.** Adoption stops the instance for the snapshot,
+ upload, swap and first wake (§9). For Tito's accounts that is a minute
+ or two each. Is a scheduled, per-account window acceptable, or does
+ adoption need a replicate-then-cut shape of its own?