carlos / platform Public
Instance moves: two-phase, data-following (issue #331)
Status: DRAFT for review — 2026-08-28 Companion issues: #329 (region on create — shipped separately), #330 (replica_regions — shape only, untouched here), titogo residence design §5/§8.3 (the consumer contract this serves).
Decisions taken in conversation (Paul, 2026-08-28)
- Cross-bucket IAM is push-only into a staging prefix (
moves/), never intoinstances/— a box can stage state toward a sibling region but can never touch another region's live replica lineage. - The platform moves the instance DB and record only. The uploads bulk-copy stays with the tenant (titogo's router), which owns the bucket, the credentials, and the knowledge of which keys belong to which instance. The move state machine exposes the phases so the tenant can sequence its own copy inside the frozen window. If a per-instance uploads prefix ever becomes a declared platform concept (a record field), the move widens to carry it without unwinding this design.
The invariant
Residence is a property of a route; the record's region field is the residence carrier (ingress spec §Anticipated #2). This spec never adds a second arbiter: at every instant exactly one region's box is in scope for the record, and the flip of region is the LAST act of the source. Never two writers falls out of that plus the freeze the tenant holds across the cut.
Wire shape
The record gains one additive, omitempty object — present only while a move is in flight:
"move": {
"to": "uk",
"state": "preparing" | "ready" | "cutting" | "aborting",
"requested_at": "<RFC3339>",
"ready_at": "<RFC3339, when state >= ready>"
}Records written before this field parse unchanged (wire-shape rule). move is written by the console (the two verbs below) and by the SOURCE box (the preparing → ready promotion and the terminal clear-and-flip); each write is the narrow CAS SetRecordRegion already uses. The record's region stays the SOURCE region until the cut completes — every reader that knows nothing about moves keeps a consistent answer throughout.
The two verbs
Console API, on one instance, gated exactly like every other instance door (app bearer; the tenant's router holds the token — never the instance itself):
POST .../instances/{host}/movebody{"region":"uk"}— validated against the armed-placement set (#329's gate, mandatory here: a move to an unarmed region is always a 400, dormant never applies because a move NEEDS a live target box). Refused 409 if a move is already in flight, 400 iftoequals the current region. Writesmove:{to,state:"preparing"}.POST .../instances/{host}/move/cut— refused 409 unlessmove.state == "ready". Writesmove.state:"cutting". The tenant calls this only after freezing writes; the platform does not verify the freeze (it cannot — the freeze is app-level), it documents it.DELETE .../instances/{host}/move— abort, legal inpreparingorready. TWO-PHASE (review round 1): the door marksaborting; the SOURCE box deletes the staged copy from the target bucket and then clearsmove— a bare clear would leave staging behind to win a later landing on the target (staging-wins below). Refused 409 incutting(completes forward) and inaborting(already under way).- State is read, not pushed: the existing single-record GET already returns the record verbatim, so
move.stateis pollable today with zero new read surface. - Every move write bumps
control/serial(the config fast-convergence nudge the env door already uses), so the source box notices in seconds, not on the hourly floor. Between states the source's own move worker self-paces its delta rounds.
What the source box does
The reconcile/activator pass on the box whose CARLOS_REGION matches region (the source — the record still points at it):
- preparing (AMENDED during planning, 2026-08-28: the draft said "a second litestream replica of the live DB" — two litestream processes cannot tail one WAL, both want to own checkpointing. The fallback IS the design, and it is better): the move copies the REPLICA, never the database. The activator already keeps
instances/<host>/in the source's own bucket continuously synced (running child) or final-checkpointed (parked child) — the move worker mirrors that prefix intos3://<target-data-bucket>/moves/<host>/with server-side CopyObject (its IAM: read own bucket, push sibling staging), in delta rounds every ~30s — List both sides, copy new/changed keys, delete keys the source pruned (a true mirror, so litestream retention holds in staging too). Uniform for running and parked instances; no wake, no litestream internals touched. Target bucket name and AWS region are derived, not configured per-move: each box publishes its data bucket coordinates as two new additive fields in its edges file (data_bucket,data_bucket_region— same additive rule asplacement), so the source reads the target's coordinates from the same file set everything else reads. - ready promotion: once a delta round completes with zero differences against a fresh listing, the source CASes
move.state: "preparing" → "ready", stampingready_at. Rounds keep running — ready is a level, not an edge; writes after ready keep flowing into staging on the next round. - cutting (order amended, review round 1): on seeing
state:"cutting", the source (in order): set the route's Status to "moving" (a non-ready status: the edge stops serving it, the edges file drops it, and the activator's RetireExcept sweep stops the child through the ordinary stop path — which final-checkpoints to the source's OWN bucket — but SWALLOWS a checkpoint failure, safe only while the warm cache stood behind it) → wait for the child to be down → re-checkpoint the local DB with the lease and wait (proving the own replica carries the tail — review round 1, the failed-final-checkpoint data-loss hole) → one final delta round into staging (now byte-identical to the own replica) → delete the local DB, WAL/SHM, and litestream meta dirs (the warm-cache purge; without it a later move back would resurrect a stale local file — the activator's warm-cache trust is exactly right for crashes and exactly wrong here) → one CAS that setsregion: <to>and clearsmove→ remove its own route row on its next pass (the record has left its scope, the existing retirement path does this unprompted). If the box dies mid-cut, the record still sayscuttingwith the old region: the source resumes the sequence on restart (every step is idempotent — sync again, stop a stopped child, delete absent files, CAS already-flipped is a no-op conflict it re-reads).
What the target box does
Nothing move-specific until the flip — then its ordinary pass sees a record newly in scope and mints the route. One addition in the activator's Restore: when the local DB is absent AND instances/<host>/ in its own bucket has no replica, it checks moves/<host>/ (own bucket, own IAM); if present, server-side copy moves/<host>/ → instances/<host>/, restore from there, CheckDB as always, and delete moves/<host>/ on success. A corrupt staged replica quarantines exactly like a corrupt own one — and the abort story still stands, because the source's last own-bucket checkpoint is intact and region can be pointed back by hand (SetRecordRegion — the operator recovery door that already exists).
IAM (the infra half — platform-infrastructure + tito repo applies)
region-host (and region-remote-host) gain an optional sibling_staging_arns list; when set, the role gains s3:PutObject on <each sibling data bucket>/moves/* — and nothing else: no Get, no List, no Delete, never instances/*. Residency-at-rest holds (cross-region reads remain impossible at IAM); the write blast radius is confined to staging keys no box ever trusts as live state without its own restore-time CheckDB. The mirror needs three actions on the sibling, all moves/*-scoped: s3:PutObject and s3:DeleteObject on the object ARNs, and s3:ListBucket on the bucket ARN with an s3:prefix condition of moves/* — still no reads of sibling data, still nothing outside staging.
Instance env
Already shipped with #329: every exec child gets CARLOS_REGION (the box's placement code), agent-owned in both directions. An app that lands on the target box after a move reads the new value at boot — titogo's boot-clears-freeze rule needs exactly this.
Out of scope
- Uploads (tenant-side, by decision above).
replica_regionsbehavior (#330 stays shape-only).- Moving unit-backed, addr-backed, static, or everywhere-class routes: the verbs refuse anything but an exec-backed
kind:"instance"route (400 naming the kind) — the only class whose state the platform owns. - Fleet placements (
fleet/<name>): refused; fleets keep D6 state-does-not-follow semantics on purpose. - Pooled records (
pool != ""): refused in v1 — their data lives on the pool's box, not the platform region box that would drive the move. Non-hibernating instances (hibernate:false): refused — they run outside the activator, so nothing manages (or could quiesce) their data. - The worker acts only on routes its OWN registry serves: region equality alone would let a standby or second same-region box drive a cut whose child runs on its sibling (per-box registries make its Quiesced trivially true).
- Automatic freeze verification; region-aware unit economics; the member-facing residence door (titogo builds on these verbs).
Testing
- Record CAS: move-field transitions (prepare/ready/cut/abort/conflict) against the fake store, including the 409 matrix.
- Source pass: fake-store + fake-litestream harness proving the cut ordering (final sync BEFORE stop BEFORE local purge BEFORE flip), the parked-instance prepare (no wake), resume-from-
cuttingidempotence, and that abort leaves the source serving with its own replication intact. - Target restore: staging fallback (copy, restore, CheckDB, cleanup), corrupt-staging quarantine, and precedence (own
instances/replica beats staging when both exist — a re-landed instance must not regress to an older staged copy). - Console: verb gates (bearer, kind, armed region, state machine), poll-through-GET.
- The never-two-writers argument pinned by an ordering test: a request hitting the source during
cuttingafter stop is a 503 from the edge (route gone on next pass) while the target has not yet minted. The length of that window is the target's reconcile latency, NOT assumed: measure it during build (the adopt path/timer cadence is the floor) and record it in the runbook — the tenant's freeze must be budgeted to cover it, and if it measures in minutes rather than seconds, a serial-bump nudge on the record write is the follow-up, not a redesign.