carlos / platform Public

spec: move-any-app rev 2, Phase 1 only, after Astra round 1 (#175)

Answers all twenty round-1 findings (dispositions in §20) and takes three
outside changes: Paul's rulings M4-M15 (rev 1's recommendations), #199
rev 3 (no record storage fields; tombstones and parking), and the
confirmed root cause (only Ireland holds CARLOS_SECRETS_KEY).

Phase 1 is now fully specified as an operator-driven single-instance move
built on a handoff protocol this spec owns: generation markers outside the
record, holds released only by the exact operation, and an execution right
that moves only on stop evidence. Journaled cut and landing phases,
pre-flip recovery, commit-then-open finalization, clean and dirty
rollback (target writes carried back), timers frozen and replaced exactly,
every registry column classified, a sealed archive with separate content
and ciphertext identity, a move-protocol floor, the shared secrets_key_fp
field for nearest-region R9, and a crash simulation with mutation checks.
Phases 2-5 become follow-on specs with invariants only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 45b7769194357dcfbfac83a830674ace7d8d701b parent 9fbf4af
1 file changed, +1059 −1418
  • docs/superpowers/specs/2026-10-02-move-any-app-design.md +1059 −1418
diff --git a/docs/superpowers/specs/2026-10-02-move-any-app-design.md b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
index be8336431..5619052a9 100644
--- a/docs/superpowers/specs/2026-10-02-move-any-app-design.md
+++ b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
@@ -1,1470 +1,1111 @@
-# Move any app or instance to another region, safely
+# Move any app or instance to another region: Phase 1, a safe single-instance move
**Date:** 2026-10-02
+**Revision:** 2.
+
+- Rev 1 (e41c4fef) went to Astra (GPT-6, via codex). The findings are in
+ `CODEX-FINDINGS-MOVES-R1.md` beside this file, unedited (9fbf4afe):
+ 12 P1, 7 P2, 1 P3, "not ready for Phase 1 planning".
+- Rev 2 answers every finding. Changes are marked "(codex N)", and §20
+ records each disposition.
+- Rev 2 also takes three changes from outside the review:
+ - Paul accepted every recommendation in rev 1's §23. They are rulings M4
+ to M15 (§0.1).
+ - #199 rev 2 was dropped. #199 rev 3 parks a deleted instance's data at
+ delete time and changes no record field. This spec now **owns** the
+ generation handoff, the cross-region execution fence, the cleanup of
+ what a completed move leaves behind, and the carriage of the instance's
+ files (§0.3, §17).
+ - The cause of the missing config on the UK box is confirmed (§1.2).
+- **Scope cut.** Rev 2 specifies **Phase 1 only**: a safe, operator-driven
+ move of one instance. Everything else (owner self-serve, moves to home,
+ residency deletion, stores, app moves, hooks) is reduced to a list of
+ follow-on specs, each with the invariants it must meet and no design
+ (§16).
+
**Status:** DRAFT for review. Nothing here is built.
-**Discussion:** amadan `carlos/platform` #175 (instance moves). Its last
-four posts are the production findings this spec answers.
+**Discussion:** amadan `carlos/platform` #175.
**Goal (Paul, 2026-10-02):** "I want region-default AND the ability to
-easily move any app or instance to a different region."
-**Companion:** `docs/superpowers/specs/2026-10-01-nearest-region-default-design.md`
-on branch `design-nearest-region` (approved). That spec gives every app a
-region at creation. This one moves apps and instances afterwards.
+easily move any app or instance to a different region." The region default
+is `docs/superpowers/specs/2026-10-01-nearest-region-default-design.md` on
+branch `design-nearest-region`.
**Builds on:**
-- #331, `docs/superpowers/specs/2026-08-28-instance-move-design.md`: the
- two-phase, data-following move. Its never-two-writers cut ordering
- stands and is extended, not reopened.
-- The moves design record, `docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md`
- on branch `docs-instance-moves-design` (called "the 09-24 record" below),
- with its §0 rulings and slices P0 to P9, and
- `CODEX-RULINGS-INSTANCE-MOVES.md` beside it.
-- On `main` (7edd4b33): P0 (cut fence) and P7 (edge door), merged and
- live; the move-ready fix (a round promotes at a delta of at most 16 MiB,
- and copy receipts handle multipart ETags).
-- **Replica ownership (#199)**, `docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md`
- on branch `design-replica-ownership` (rev 2 at ef0b7e45, being
- rewritten; called "the #199 spec" below). Approved as a constraint on
- this spec by Paul on 2026-10-02. §0.3 says what it changes here, and §26
- lists every interface point between the two specs, so they can be
- reviewed together.
-
-Citations are to this worktree at 7edd4b33 unless they name another
-branch or repo.
-
-## 0. Rulings in force
-
-### 0.1 Paul's rulings for this spec (2026-10-02). Not reopened here.
-
-| id | ruling |
-|---|---|
-| M1 | **Who:** owners move their own apps and instances, self-serve, from the console and the CLI. Operators can do the same for any account. The platform refuses unsafe moves. |
-| M2 | **Downtime:** a short cut window (tens of seconds of 503), an automatic post-cut health check, and **automatic rollback to the source if the target will not start**. Not live migration. |
-| M3 | **Stores move with the app:** an app's object store is copied to the target region and rebound, so residency follows the app (the 09-24 record's P8). |
+- #331, `docs/superpowers/specs/2026-08-28-instance-move-design.md`.
+- The moves design record (the "09-24 record"),
+ `docs/superpowers/specs/2026-09-24-instance-moves-for-every-app-design.md`
+ on branch `docs-instance-moves-design`, with `CODEX-RULINGS-INSTANCE-MOVES.md`.
+- On `main` (7edd4b33): P0 (the cut fence), P7 (the edge door), and the
+ move-ready fix (16 MiB readiness bar, copy receipts for multipart ETags).
+- #199 rev 3, `docs/superpowers/specs/2026-10-02-replica-ownership-design.md`
+ at 66f1dad3 on `design-replica-ownership` (the "#199 spec"): host
+ tombstones and parking at delete time.
+
+Citations are to this worktree at 7edd4b33 unless they name another branch
+or repo.
-### 0.2 The 09-24 record's §0 rulings, as they apply here
+## 0. Rulings
-They bind unless M1 to M3 override them. Two are overridden, and in a
-narrow way:
+### 0.1 Paul's rulings for this work (2026-10-02). Not reopened here.
+
+| id | ruling | where Phase 1 spends it |
+|---|---|---|
+| M1 | **Who:** owners move their own apps and instances, self-serve, from the console and the CLI. Operators can do the same for any account. The platform refuses unsafe moves. | Phase 1 is operator-only (§12); owners come in F1 (§16) |
+| M2 | **Downtime:** a short cut window (tens of seconds of 503), an automatic health check after the cut, and **automatic rollback to the source if the target will not start**. Not live migration. | §7 |
+| M3 | **Stores move with the app:** an app's object store is copied to the target region and rebound. | F4; Phase 1 leaves the app store where it is and refuses private stores (§7.1) |
+| M4 | The files the move carries are a fixed set (no app-declared paths). | §6.1 |
+| M5 | The files archive is capped at 256 MiB, enforced while streaming and again at the cut. | §8.1 |
+| M6 | Person-started moves cut automatically once ready and validated; the API default stays manual; `--manual-cut` exists. | §12 |
+| M7 | The source copy is held for 7 days after a move; owners may end the hold early. | F3 |
+| M8 | An app move pauses when one of its instances rolls back, and keeps the target app region. | F5 |
+| M9 | An app store's final copy holds every consumer of the store at 503, behind a durable barrier that covers concurrent creates. | F4, F5 |
+| M10 | An app with sidecars is refused an app move until sidecar moves exist. | F5 |
+| M11 | Target pre-warming is deferred; move eligibility is gated on measured restore and cut duration instead. | §7.3 (T11) |
+| M12 | Rehearsal order: a probe app, then a disposable ips instance, then kpmg, each with scoped approval. | §14.4 |
+| M13 | Owners only, not members; the member-minted instance-token bypass is closed. | Phase 1 is operator-only (§12); F1 |
+| M14 | One landing path in production. (Ruled as "require #199's epoch". #199 rev 3 has no epoch; the intent stands: Phase 1 has exactly one landing path.) | §7.6 |
+| M15 | A landed copy is installed into the target's `instances/`, never served from `moves/` (Astra's I-1 verdict). | §5.1, §7.6 |
+
+### 0.2 The 09-24 record's rulings
+
+They bind unless M1 to M15 override them:
- **R4 ("rollback is a move back, never an automatic undo") is narrowed by
- M2.** Before the target has admitted any public traffic, a failed landing
- is undone automatically by flipping the record back (§7.6). Nothing of
- value is lost, because no user write reached the target. After the
- target opens, R4 stands exactly: recovery is a move back, and nothing
- ever restores an older source copy over newer target data (the 09-24
- record's M2).
-- **Q5 ("other accounts are operator-only until #171") is superseded by M1
- for an owner's own apps.** §14 says how the residual #171 risk is
- bounded: the console's own move ledger, not the record, authorizes every
- privileged act a move performs.
-
-Everything else binds as written: R1 to R3 and R5, Q1 to Q4 and Q6 to Q8,
-M1 and M2 of that record (write fence for stores, recovery path for a stuck
-landing), P4 joining the minimum path, and P7-trust.
-
-### 0.3 The #199 constraint (Paul, 2026-10-02)
-
-This spec assumes the #199 spec's storage model and is written against it:
-
-- **Replica data is keyed by incarnation and generation.** A record's
- replica lives at `<Root>/<host>/<incarnation>/<Gen>/`, and the record's
- `DB` pointer names the current generation (#199 §2.2, §2.3). The local
- database carries a `.lineage` file naming the storage key it belongs to
- (#199 §2.5).
-- **Records that exist when #199 rolls out keep the legacy host-only
- layout** (`instances/<host>`) until their first v2 event. A record is
- legacy while it has no `DB` pointer and was created before the
- deployment's v2 epoch (`control/storage-v2.json`, #199 §2.3).
-- **A move lands its copy as a new generation in the target, and the flip
- sets `Region` and `DB` in one CAS** (#199 §4). A stale copy from an
- earlier stay in the target has an older generation and is never read.
- "Staging wins" (`backend_exec.go:1581-1588`) goes away for v2 records.
-- **The source still writes only `moves/*` in other regions.** No new IAM.
-- **Still undecided in #199:** whether landing copies the staged
- generation into `instances/`, or the pointer names the staging root
- (`Root: "moves"`). This spec works with either and recommends one (§26,
- I-1).
-
-Where this spec says "generation" it means #199's `Gen`. Where it gives a
-legacy path, that path applies only to records #199 classifies as legacy.
-
-## 1. What happened in production, and why
-
-### 1.1 Finding 1: moves never reached `ready` (fixed on main)
-
-`bdn/ips` `kpmg.bdn.oncarlos.com` sat in `preparing` for 80 minutes. The
-cause was multipart ETags: litestream uploads large LTX files in parts,
-`CopyObject` gives the copy a single-part ETag, and the mirror's ETag
-comparison never matched. Fixed in 2cbf6196 and 167d721b (merged as
-7edd4b33): copy receipts in the source's own bucket at
-`move-receipts/<host>.json` (`internal/activator/backend_exec.go:1557`)
-and a 16 MiB readiness bar (`internal/host/movespass.go:215-235`, applied
-at `:387`). Nothing more is needed for it here.
-
-### 1.2 Finding 2: a completed move left the instance unable to start for 1h54m
-
-kpmg moved IE (home) to uk cleanly and then failed every wake on the UK
-box with `instance not healthy within 30s`
-(`internal/activator/backend_exec.go:1443`). Two causes.
-
-**(a) The per-host data directory does not move.** The move carries the
-litestream replica and nothing else. The cut mirrors
-`instances/<host>/` into the target's `moves/<host>/`
-(`movespass.go:476-487`), purges the local database artifacts
-(`:492`, `PurgeLocalDBArtifacts` at `backend_exec.go:1566-1575`, which
-removes the DB, WAL, SHM and the two litestream meta directories and
-nothing else), and flips the record (`:494`). The app keeps its
-`instance.key` and `report.token` in `/data/<host>/`, beside its database.
-No platform code knows that directory exists: no path in this repo is
-built from it, nothing backs it up, and nothing deletes it. On the UK box
-the app found no key, minted a new one, and could not read data sealed
-under the old one.
-
-**(b) `<host>.config.env` was never rendered on the target.** Exec
-config is rendered by the agent's in-process `SyncExec`
-(`internal/appconfig/syncexec.go`, `ConfigEnvPath` at `:71-76`), which runs
-only when the box holds `CARLOS_SECRETS_KEY`: both config loops log
-"disabled" and return without it (`internal/host/agent.go:881-886`,
-`:1279`), and the fast converge skips the config leg when the key is absent
-(`agent.go:1812`). The spawn treats a missing config file as an empty one
-(`backend_exec.go:1146-1149`), so the app started with no store binding
-and logged "build app: File storage is unavailable." The UK box had
-`<host>.env` (the activator mints it at first wake,
-`internal/activator/prepare.go:49`) but no `<host>.config.env`.
-
-**Which of three causes stopped the render is not yet confirmed.** In
-order of likelihood: the UK box has no `CARLOS_SECRETS_KEY` in
-`/etc/carlos/host.env` (the loops are then inert, see §6); it holds a
-different key, so every sealed value fails to open; or `SyncExec` ran and
-skipped the route. The UK agent's journal answers it in one line (the
-"config sync loop disabled" message at `agent.go:884`). Phase 1's first
-task reads it. The design below does not depend on the answer: the
-target-readiness report (§7.3) catches all three before anything is
-stopped.
-
-Recovery was a hand edit of the record's region back to home plus a serial
-bump. It worked because the source still had the key in `/data/<host>/`
-and its own bucket still had the replica. Had anything deleted
-`/data/<host>/` on the source, the data would have been unrecoverable.
+ M2.** A landing that fails before the target admits public traffic is
+ undone automatically (§7.8). After the target opens, R4 stands: recovery
+ is a move back, and nothing restores an older source copy over newer
+ target data.
+- **Q5 (other accounts are operator-only until #171)** is consistent with
+ Phase 1, which is operator-only.
+- Everything else binds as written.
+
+### 0.3 What #199 rev 3 changes here
+
+- **No record field carries storage identity.** Replica data stays at
+ `instances/<host>` (`internal/activator/activator.go:138`). This spec
+ therefore keys a move's generation by the move id, in object names and in
+ marker files, never in a persistent record field (§5.1).
+- **Deletes write a host tombstone first**, at
+ `control/host-tombstones/<host>/<incarnation>.json`, and the box in the
+ tombstone's region parks `instances/<host>/` and `moves/<host>/` into
+ `parked/<incarnation>/<host>/`. Reconcile defers a recreated host's row
+ until its park is done (#199 §2, §3). A move must compose with all three
+ (§7.12).
+- **The incarnation** is `provision.Incarnation(rec)`: `RecordIdentity` over
+ account, app, host and CreatedAt, or a domain-separated legacy identity
+ when CreatedAt is zero (#199 §2). Every move artifact is bound to it.
+- **The source-region lineage a completed move leaves behind, and the
+ instance's working directory, are this spec's** (#199 §2.5b of rev 2 and
+ §5 of rev 3).
+
+## 1. What happened in production
+
+### 1.1 Finding 1: busy instances never reached `ready` (fixed on main)
+
+Multipart ETags never matched after `CopyObject`, so every round re-copied
+everything. Fixed in 2cbf6196 and 167d721b (merged as 7edd4b33): copy
+receipts (`internal/activator/backend_exec.go:1557`) and a 16 MiB
+readiness bar (`internal/host/movespass.go:215-235`, applied at `:387`).
+
+### 1.2 Finding 2: a completed move could not start for 1h54m
+
+`kpmg.bdn.oncarlos.com` (account bdn, app ips) moved IE (home) to uk. Every
+wake on the UK box then failed with `instance not healthy within 30s`
+(`backend_exec.go:1443`). Two causes, both now confirmed.
+
+**(a) The per-host data directory does not move.** The cut mirrors the
+replica (`movespass.go:476-490`), purges database files only
+(`backend_exec.go:1566-1575`), and flips the record (`movespass.go:494`).
+The app keeps `instance.key` and `report.token` in `/data/<host>/`. No
+platform code names that directory. On the UK box the app minted a new
+key and could not read data sealed under the old one.
+
+**(b) Config never rendered, because only the Ireland box holds
+`CARLOS_SECRETS_KEY` (codex 20).** The UK, US and AU journals all log
+"config sync loop disabled: CARLOS_SECRETS_KEY not set"
+(`internal/host/agent.go:884`). Without the key both config loops return
+at once (`agent.go:881-886`, `:1279`), the fast converge skips the config
+leg (`agent.go:1812`), and the spawn treats a missing config file as empty
+(`backend_exec.go:1146-1149`). So the app started with no store binding.
+
+**Remediation (Paul approved, ops in progress):** distribute the key to
+every region box. **Verification** is the fingerprint every box publishes
+(§10): a region is ready when its boxes publish the console's fingerprint.
+Readiness check T2 refuses any target that does not (§7.3).
### 1.3 Finding 3: the rehearsal proved nothing
-`bdf/hello` moved IE to uk in about 6 minutes with about 50 s of 503. It
-has no key file, no store, no config and no scheduled jobs, so it
-exercised none of the state that failed. §20.4 specifies a rehearsal app
-shaped like ips.
+`bdf/hello` has no key file, store, config or timers. §14.4 rehearses with
+an app shaped like ips.
### 1.4 Finding 4: SSM Parameter Store is regional
-The carloku-infrastructure root says so plainly: "SSM Parameter Store is
-regional: this host reads /carlos/* in ITS OWN region, so every parameter
-it needs (release key, app secrets) must be created in eu-west-2. Nothing
-replicates them from eu-west-1." (`region-uk.tf:61-64`; the same note is in
-`region-us.tf` and `region-au.tf`). §6 traces what that means for an
-instance.
-
-## 2. Goals and non-goals
-
-Goals:
-
-- **Any eligible instance moves safely.** Every piece of per-host state is
- carried or provably reproduced on the target, checked before anything
- is stopped, and checked again before the target serves.
-- **A target that cannot run the instance gives it back.** The source
- serves again automatically, with the data it had at the cut.
-- **An app moves as a unit.** Its region changes, then its instances move
- one at a time, then its object store follows (M3).
-- **Owners do it themselves** from the console and `carlos move` (M1).
-- **The source region is emptied** after a hold period (P5), so a move
- delivers the residency it was bought for.
-
-Non-goals:
-
-- Live migration, zero-downtime cuts, or pre-warming the target (§23 Q8).
-- Moving pooled, fleet-placed, addr-backed, unit-backed or non-hibernating
- instances, replicas, or homes with replicas (§13).
-- The freeze/thaw/verify hooks (the 09-24 record's P1). They make a cut
- graceful; this spec makes it safe without them. They slot in at Phase 5.
-- Adoption of externally run instances (P6) and the isolated layout pass
- (P9). They stay on the 09-24 record. §8.5 records one requirement P9
- must now meet.
-- Latency routing for moved instances. Phase L of the nearest-region spec
- derives it from the record's region, which a move already updates.
+"SSM Parameter Store is regional: this host reads /carlos/* in ITS OWN
+region ... Nothing replicates them from eu-west-1" (carloku-infrastructure
+`region-uk.tf:61-64`). §10 traces what an instance needs from it: only the
+box's secrets key.
+
+## 2. Phase 1: what it does and what it refuses
+
+Phase 1 moves **one** exec-backed, hibernating `kind:"instance"` record from
+one non-fleet placement to another, driven by an operator through the
+console API or `carlos move`. It carries every piece of per-host state the
+instance needs, proves the target can run it before the source stops,
+starts it on the target with no public traffic, and opens only after a
+commit. If the target cannot start it, the instance goes back to the
+source automatically, carrying anything the target wrote.
+
+Phase 1 refuses everything §7.1 lists, including moves into home (F2),
+private stores (F4), and any caller that is not an operator (F1).
+
+Phase 1 does **not** delete anything. What a completed move leaves in the
+source region is parked under the move id, so it is enumerable, never read
+again, and ready for the residency cleanup in F3.
## 3. Words
-- **Instance move:** one record's `region` changes. The app's region does
- not.
-- **App move:** the app's region changes (the console `apps.region`
- column, nearest-region spec §5.1), then each of its instances that lives
- in the old region moves, then the app's store follows.
- **Source / target:** the box serving the instance before the move, and
- the box that will serve it after. "Box" means the one box whose registry
- owns the route (`OwnsRoute`, `movespass.go:180-185`).
-- **The flip:** the single record write that sets `region` to the target.
-- **Landing:** the interval after the flip during which the target
- restores, checks and probes the instance with no public traffic.
-- **Open:** the target admits public traffic (route status `ready`).
-- **Authoritative copy:** the one copy of the instance's data that a
- later restore must reproduce. §4 defines where it is in every state.
-- **Hold:** the period after a successful move during which the source
- keeps its copy (§10).
-- **Incarnation, generation, storage key, epoch:** #199's terms (§2.1 to
- §2.3 there). The **source generation** is the one the record points at
- when the move is requested (`move.from_db`). The **move generation** is
- the new one the move allocates for the target (`move.to_gen`).
+ the box serving it after. "Box" means the one box whose registry owns the
+ route (`OwnsRoute`, `movespass.go:180-185`).
+- **Move id:** console-minted, unique, `mv_` plus 26 lowercase base32
+ characters. It names the move's **generation**.
+- **Generation:** the identity of one installed copy of an instance's state
+ (database, files, timers). An instance that has never moved is in the
+ **origin** generation. A landing installs generation `<move id>`. A dirty
+ rollback installs generation `<rollback id>` (§7.8).
+- **Handoff:** the protocol that transfers the right to run an instance
+ from one box to another, with a new generation (§5). A move is a handoff.
+ A dirty rollback is a handoff back. A future `carlos db push` is a
+ handoff on one box.
+- **Hold:** a registry row status that admits no traffic, no ticks and no
+ ordinary wakes (§5.2).
+- **The flip:** the record CAS that sets `region` to the target.
+- **Commit:** the record CAS after which the target is authoritative.
+- **Open:** the target's row becomes `ready`.
## 4. Invariants
-Review rounds on earlier move work kept finding new interleavings of one
-fault. So the properties are stated once, here, and §20.2 tests them by
-randomized simulation across crashes, not by example.
-
-- **INV-1 One writer.** At every instant at most one box runs a process
- for the host. While the target runs one during landing, the source's
- route is fenced and its child is proven stopped.
-- **INV-2 No open before commit.** The target sets its route to `ready`
- only after its compare-and-set from `landing` to at-rest has succeeded.
- A box that loses that CAS never opens.
-- **INV-3 No lost acknowledged write.** Any write the instance acknowledged
- to a client (a request admitted by an edge, or a tick) is present in the
- authoritative copy at every terminal state: done, aborted, rolled back,
- or stuck.
-- **INV-4 Authority.** Before the flip the authoritative copy is the
- source (its local DB while running, the source generation once
- stopped). From the flip until the target's commit it is the source
- generation, which is byte-identical to the final staged copy. After the
- commit it is the move generation. A rollback restores from the source
- generation and nothing else.
-- **INV-4b One pointer.** `region` and the `DB` pointer change only
- together, in one CAS: at the flip (to the target and the move
- generation) and at a rollback (back to the source and the source
- generation). No restore on any box reads a location the record does not
- point at, so an older generation from an earlier stay is never read.
-- **INV-5 Files continuity.** Every start of the instance, on any box,
- sees the files and `<host>.env` of the authoritative state. No start
- ever runs where a carried file should exist and does not.
-- **INV-6 Config presence.** No start during landing runs without the
- instance's config rendered on that box, whenever the app has a config
- bundle.
-- **INV-7 Termination.** Every move reaches `done`, `aborted`,
- `rolled_back` or `stuck` in bounded time, provided crashed actors
- eventually restart. `stuck` happens only when the source also fails to
- start after a rollback.
-- **INV-8 Residency.** Nothing deletes source state before `done`. After
- the hold, nothing of the instance remains in the source region except
- what §10 lists. A deletion never removes a lineage that is live at the
- moment it fires.
-
-## 5. Every piece of per-host state, and what happens to it
-
-"Per-host" means keyed by the instance's hostname or its instance id
-(`provision.InstanceID`, `internal/provision/provision.go:1259`). The
-flat layout keeps files under `<DataDir>` (`/data`); the nested
-(isolated-exec) layout keeps them under `InstanceDir` =
-`<DataDir>/instances/<iid>/` (`provision.go:1270`;
-`internal/activator/paths.go:33-54`).
-
-| # | state | where | written by | today's move | this design |
-|---|---|---|---|---|---|
-| S1 | SQLite DB, WAL, SHM | `<DataDir>/<host>.db` (`provision.go:1219`) or `InstanceDir/<host>.db` | app | carried as the replica; source copy purged at cut (`movespass.go:492`) | unchanged |
-| S2 | Litestream meta dirs | `<db>-litestream`, `.activator-<db>-litestream` (`backend_exec.go:1570-1571`) | activator | purged; rebuilt on restore | unchanged |
-| S3 | Replica lineage | legacy: `instances/<host>` (`activator.go:138`); v2: `<Root>/<host>/<incarnation>/<Gen>/` in the box's own data bucket (#199 §2.3) | activator | mirrored to target staging `moves/<host>/` (`backend_exec.go:1549`) and promoted over the target's `instances/<host>` (`:1580-1606`); source copy kept forever | mirrored into the **move generation** `moves/<host>/<incarnation>/<to_gen>/` in the target bucket; the flip points the record at it (#199 §4); the source generation is deleted after the hold (§10) |
-| S4 | Copy receipts | `move-receipts/<host>.json`, source bucket (`backend_exec.go:1557`) | move worker | cleared at the flip | keyed by the **source** storage key: `move-receipts/<host>/<incarnation>/<from_gen>.json` (#199 §4); legacy records keep the host-only key |
-| S4b | Local lineage stamp | `<dbPath>.lineage` (#199 §2.5) | activator | n/a | removed first by the cut's purge (#199 §5.6); written on the target after its first sync |
-| S5 | **Instance files** | flat: `<DataDir>/<host>/` (app-created: `instance.key`, `report.token`); nested: files the app writes in `InstanceDir` that are not platform-owned | app | **not carried, not backed up, never deleted** | **carried** in the sealed files archive (§8) |
-| S6 | **`<host>.env`** | `<DataDir>/<host>.env` (`paths.go:50`) | activator mints `CARLOS_ADMIN_TOKEN` (`admintoken.go:115`, `prepare.go:49`); operators add box-local overrides, which win over the bucket (README "App config & secrets") | **not carried**: target mints a new token, overrides are lost | **carried** in the files archive |
-| S7 | **`<host>.config.env`** | `ConfigEnvPath` (`syncexec.go:71-76`) | `SyncExec`, only with `CARLOS_SECRETS_KEY` (`agent.go:881-886`, `:1812`) | rendered only if the target holds the key; absence is silently accepted at spawn (`backend_exec.go:1146-1149`) | reproduced from the bucket; **proven** on the target before the cut (§7.3) and again before the probe (§7.5) |
-| S8 | Instance config overlay | `apps/<acct>/<app>/instances-config/<host>.json` (`internal/appconfig/instances.go:9`) | console | home bucket, read by any box | reproduced (part of S7) |
-| S9 | Config ack | `control/config-ack/<box>/<host>.json` (`configack.go:111-118`) | agent | source's ack goes stale | target's fresh ack is a landing check (§7.5); source's is deleted after the hold |
-| S10 | Legacy sidecar env | `<host>-sidecar.env` (`paths.go:51`; "Eleven runs on it today", `internal/provision/sidecar_reconcile.go:54-59`) | operator, by hand | not carried; target spawns no sidecar | **refused** (§13) |
-| S11 | Timers: declared schedules | `timers` table, `kind=schedule` (`internal/registry/registry.go:565-579`) | converger from `schedules.json` (`internal/schedule/converge.go:17-20`) | rows re-created on the target with `next_at`/`last_at`/`attempts` reset | **carried** with their state; the converger keeps an imported row whose spec and path match (`converge.go:223-241`) |
-| S12 | Timers: once-timers | `timers` table, `kind=once` (`internal/schedule/spec.go:19`), registered by the app over its control socket (`control.go:41`) | app | **lost** (09-24 G15) | **carried** |
-| S13 | Registry row | `routes` table | reconcile mints `Status: "ready"` from the record (`internal/provision/reconcile.go:374`) | re-minted | re-minted with status `landing` (§7.5) |
-| S14 | Password gate | `routes.gate`, converged from the record (`reconcile.go:983`) | console | reproduced | unchanged |
-| S15 | Custom domains and certs | aliases derived per box from claims (`internal/domains/aliases.go`); certificate cache in the deployment bucket under `control/certs/` (`internal/edge/certsweep.go:4`) | domains pass, edge | reproduced; 404 between the source's retire and the target's open | reproduced; the 404 becomes a 503 with P2 (Phase 2) |
-| S16 | Sockets | `/run/carlos/<host>.sock` and `.control`; nested `/run/carlos/instances/<iid>/` (`provision.go:1282-1289`) | agent | runtime, recreated | unchanged |
-| S17 | Launch files, log sockets, exit records | `InstanceDir/launch.*.json`, `/run/carlos/logs/<iid>.*.sock` (`provision.go:1294`) | agent | rewritten per start | unchanged |
-| S18 | Layout journal | `<DataDir>/layout/<host>.json` (`provision.go:1303`) | layout pass (unbuilt) | the cut waits while present (`movespass.go:447`) | unchanged |
-| S19 | Quarantined restore | `<db>.corrupt` (`backend_exec.go:313-324`) | activator | stays on source | stays; deleted after the hold |
-| S20 | Activator fences, leases, wake state | memory; fences are lost on agent restart (`movespass.go:294-300`) | activator | re-installed each pass | landing uses a **durable** fence: the row's status (§7.5) |
-| S21 | Restart marks, generation caches, sidecar-wake memory | per box, keyed by app | agent | re-baselined | unchanged |
-| S22 | Private store (#412) | environment bundle in the home bucket; bucket in `CARLOS_STORE_REGION` (`cmd/console/main.go:558`) | console | not moved | **follows the instance** (§11, Phase 4) |
-| S23 | App-level store | default bundle; one bucket per app | console | not moved | **follows the app**, not the instance (§11) |
-| S24 | Out-of-band host-role grants | e.g. `keymail-blobs-rw` attached to `module.host_ie.role_name` only (carloku `keymail.tf:17-35`) | operator, in tofu | invisible to the platform; the instance would lose access | **refused** through the manifest's `regions` allow-list (§13) |
-| S25 | App logs, metrics, usage | deployment bucket (`internal/host/logship.go:56`) | agent | control plane, stays at home (Q4) | unchanged |
-
-**S5 needs a definition the platform can act on.** No code in this repo
-names `/data/<host>/`. It is the app's own convention: a directory named
-for the host beside its database. The design makes it a platform contract:
-
-- **Flat layout:** the instance files are the directory
- `strings.TrimSuffix(dbPath, ".db")`, which is `<DataDir>/<host>/` for
- every row whose `DBPath` is the default.
-- **Nested layout:** every regular file and directory under `InstanceDir`
- except the platform's own names (S1, S2, S4b, S6 is carried separately,
- S7, S17, S19, and #199's `.incarnation` marker).
-- Anything an app writes elsewhere under `/data` is not carried.
- `docs/site` says so, next to the existing rule that the app must read
- its store coordinates from env.
-
-**The files belong to the incarnation, and a move hands them to a new
-generation.** The #199 spec gives the nested working directory an
-`.incarnation` marker and moves a mismatched directory aside to
-`<DataDir>/.retired/<old-incarnation>/` (#199 §2.5b). A push replaces only
-the database, so `instance.key` survives a push. A move is different: it
-creates a new generation **in another box**, and the files have to arrive
-with it. So the files are keyed the same way the replica is, in both
-places they live:
-
-- **In the bucket**, the archive sits beside the move generation, at
- `moves/_files/<host>/<incarnation>/<to_gen>.{archive,json}` (§8). A
- stale archive from an earlier stay has another generation in its key and
- is never read.
-- **On a box**, the directory carries `.incarnation` (#199) and a second
- marker this spec adds, `.files-gen`, naming the generation whose landing
- wrote it. A landing never trusts a directory it did not just write: it
- moves whatever is there aside and extracts fresh (§7.5, L2). A push
- leaves `.files-gen` alone, because the files did not change.
-- **Flat rows**, which #199 cannot attribute (§2.5b there), become
- attributable for this one directory, because this spec makes
- `<DataDir>/<host>/` a platform-defined path. #199's `.incarnation` check
- should extend to it (§26, I-6).
-
-## 6. SSM, secrets, and what a target box needs
-
-The platform's Go code reads no SSM parameter at runtime. There is no SSM
-client in this repo. The region-host module only grants a box
-`ssm:GetParameter` on one ARN pattern (`infra/modules/region-host/main.tf:179-180`)
-and denies every other parameter read (`:435-450`). So SSM reaches an
-instance in exactly two ways, and only one of them matters to a move:
-
-1. **Box provisioning.** `/etc/carlos/host.env` is staged per box, and its
- values come from the deployment's secret store, which on carloku is SSM
- in the box's own region. The value that matters is
- **`CARLOS_SECRETS_KEY`**: it decrypts every sealed config value, and the
- README says it "belongs in exactly two places, your secret manager and
- every host's `/etc/carlos/host.env`". A box without it renders no exec
- config at all (§1.2b).
-2. **Apps that read SSM themselves.** Today these are unit-backed adopted
- apps whose deployment scripts read `/carlos/kass/*` and
- `/carlos/keymail/*` (carloku `README.md:16-18`, `scripts/copy-kass-secrets.sh`).
- Unit-backed routes never move (`internal/console/apiinstancemove.go:53-60`).
-
-An exec instance's own secrets come from its config bundle in the
-deployment bucket, which lives at home (Q4) and which every box can read.
-**So nothing per-app or per-instance needs to be replicated between SSM
-regions. What has to be true is that every target box holds the
-deployment's secrets key.** That is a box property, so the design checks
-it as one:
-
-- Each box publishes, in its edges file, the fingerprint of the public half
- of its `CARLOS_SECRETS_KEY` (`secrets_key_fp`, additive and omitempty,
- beside `placement` and `data_bucket` at `internal/host/edgesfile.go:603-605`),
- or `""` when it has none.
-- The console publishes its own `CARLOS_SECRETS_PUBKEY` fingerprint.
-- The move door refuses a target whose fingerprint is absent or different
- (§7.3, check T2). That one refusal would have stopped the kpmg move
- before the cut.
-
-Operationally, the roll recipe gains one line: staging a new region means
-creating its SSM copy of the secrets key, and the edges file proves it
-landed. A follow-up for carloku-infrastructure (not this repo) is a small
-tofu resource that copies `/carlos/secrets-key`, or whatever the
-deployment names it, into each region from one source of truth.
-
-## 7. The instance move
-
-### 7.1 States
-
-The record keeps #331's in-flight object (`provision.MoveState`,
-`provision.go:907-917`) and its CAS (`SetRecordMove`, `:949`). New states
-and fields are additive. A record written by today's console still parses.
-
-```
-preparing ─► ready ─► cutting ─► landing ─► (at rest in target; log: done)
- │ │ │ │
- │ │ │ └─► rolling_back ─► (at rest in source; log: rolled_back)
- │ │ │ └─► stuck (source failed too; operator)
- └──────────┴─────────┴─► aborting ─► (at rest in source; log: aborted)
- (cutting only before the source stops anything: cut_refused ─► ready)
+Stated first, designed to, and proved by the simulation in §14.2.
+
+- **INV-1 One writer.** At any instant at most one process, on any box in
+ any region, runs the instance with write access to its database, files
+ or store. A box starts the instance only while holding the execution
+ right (§5.3), and the right moves between boxes only on **stop
+ evidence** from the box giving it up. A timeout never moves it (codex 1).
+- **INV-2 No lost acknowledged write.** No write the instance acknowledged,
+ to a client, to a tick, or to its own background work or an external
+ system, is lost by any outcome: done, aborted, rolled back, or stuck. In
+ particular a rollback carries back whatever the target wrote (codex 2).
+- **INV-3 Convergence without a human.** A crash of any actor (a console
+ or agent process restart, or a box reboot) at any step leaves a state
+ from which the move reaches a terminal outcome with no human action. The
+ one exception is stated, not hidden: a box that **never returns**
+ (destroyed) during a handoff it holds; that leaves a paged, recoverable
+ state and an operator verb (§7.10).
+- **INV-4 Files match the database.** Every start of the instance, on any
+ box, uses a files directory whose generation equals the generation of the
+ database it starts with. A mismatch refuses the start (§5.1).
+
+Supporting invariants:
+
+- **INV-5 No admission before commit.** A row leaves a hold only by a
+ conditional write naming the exact operation that holds it, and the
+ target's hold is released only after the commit (codex 3, 6).
+- **INV-6 The environment that runs is the environment that was checked.**
+ The readiness checks evaluate the final merged launch environment, and
+ the probe binds health to the spawned process (codex 14).
+- **INV-7 Install is never replayed over writes.** Once a probe has started
+ a process on an installed generation, nothing re-copies, re-extracts or
+ re-imports into it (codex 5).
+- **INV-8 Phase 1 deletes nothing a recovery might need.** Data a move
+ supersedes is parked under its move id, never deleted (codex 8).
+- **INV-9 Bounded.** Every non-terminal state has a deadline after which it
+ either advances, recovers, or pages in a state that still satisfies
+ INV-1 and INV-2.
+
+## 5. The handoff protocol
+
+The two primitives this spec owns, and that a later `carlos db push` builds
+on: the generation handoff and the execution fence.
+
+### 5.1 Generation markers
+
+A generation is named in three places. None is a record field, so no old
+writer can erase one (#199 rev 3's lesson).
+
+| marker | where | written by | when |
+|---|---|---|---|
+| `generation.json` `{"gen","incarnation","host","installed_at","box"}` | `instances/<host>/generation.json` in the box's own data bucket, beside the existing `lock.json` | the installing box | last step of an install (§7.6, I3), after every object is verified |
+| `<dbPath>.gen` | beside the local database | the activator | after a restore completes, copied from `generation.json`; on a warm start it is already there |
+| `.files-gen` | inside the files directory (§6.1) | the installing box | last step of a files install, by atomic rename |
+
+Absent everywhere means **origin**, so every instance that has never moved
+is unchanged.
+
+**The start check (INV-4).** Before every spawn (`Start`, `Seed`, and the
+probe), the activator compares `<dbPath>.gen` with `.files-gen`, when the
+files directory exists. Unequal refuses the wake with a member-safe reason
+("this instance's files and database come from different copies; an
+operator has been paged") and pages. A cold restore writes `<dbPath>.gen`
+from the restored prefix's `generation.json` before the comparison.
+
+Where the database is warm on a box but its generation differs from the
+bucket's `generation.json`, the warm file is from an older copy (an earlier
+stay on this box). The activator parks it (§6.3) and restores cold. This
+replaces "staging wins" (`backend_exec.go:1581-1588`): staging is never
+restored from directly (M15), and `promoteStagedReplica` is deleted.
+
+### 5.2 Holds
+
+A hold is a registry row status that the edge does not serve
+(`internal/edge/edge.go:595` serves only `ready`), that ticks refuse
+(`activator.go:2785-2789` refuses any non-`ready` row), and that no
+ordinary wake reaches. Phase 1 has three hold statuses: `moving` (source,
+from the cut until the move ends), `landing` (target, until it opens), and
+`restoring` (source, during a rollback).
+
+**Every hold names its operation.** The `routes` table gains an additive
+column, `hold_op`, set to the move id (or rollback id) whenever a hold
+status is written. Leaving a hold is one conditional statement:
+
+```sql
+UPDATE routes SET status='ready', hold_op='' WHERE host=? AND hold_op=?
```
-| state | written by | what is happening | legal exits |
+A delayed or duplicate open from a finished operation matches nothing, so
+it can never reopen a newer operation's hold (codex 6). Reconcile, which
+today mints every row `ready` (`internal/provision/reconcile.go:364-376`),
+mints a row whose record is mid-move in the hold its role requires, with
+`hold_op` set (§9.6). A row for a record in `rolling_back` or `stuck` is
+never minted `ready` (codex 3).
+
+### 5.3 The execution right and stop evidence
+
+The right to run the instance belongs to exactly one box, named by the move
+state:
+
+| move state | holder |
+|---|---|
+| no move, `preparing`, `ready`, `cutting` (every phase), `reverting` | source. After `stopping` it holds the right without using it, so a pre-flip recovery needs nobody else's evidence |
+| `landing`, `committed` | target. The flip requires the source's stop evidence (§7.4), and the target reads and checks it before it starts anything (§7.6) |
+| `rolling_back` | nobody until the target's stop evidence; then source |
+
+**Stop evidence** is what the activator's `FenceAndStop` already produces
+(`internal/activator/maintenance.go:50`, `StopEvidence.Matches`): the main
+process stopped and exited, its sidecar stopped, its replica closed, its
+lease released. The box that gives up the right writes it to the
+deployment bucket at
+`moves/<acct>/<app>/<host>/<move id>/stopped-<role>.json` with its box
+label, the incarnation, the database file's sha256 after a final
+checkpoint, the files digest and the timers digest (§7.4, §7.8). The box
+that takes the right reads it and checks the move id, incarnation and role
+before it starts anything.
+
+**Why this survives a crash.** The agent runs as `carlos-edge.service`
+with systemd's default `KillMode=control-group` (no `KillMode` line in
+`internal/host/units/carlos-edge.service`), so a flat exec child dies with
+the agent. A nested child runs in its own `carlos-instance@` unit
+(`internal/host/units/carlos-instance@.service`) and survives, so a
+restarted agent stops it through the instance-control channel before it
+writes evidence. Either way a restarted box can produce evidence. A box
+that never comes back cannot, and that is INV-3's stated exception
+(§7.10).
+
+**What does not count as evidence:** a deadline, a lost CAS, a silent box,
+or a lease in another bucket. Leases are per bucket and prefix
+(`backend_exec.go:228-231`), so the source's and target's leases never
+exclude each other (codex 1).
+
+## 6. State inventory
+
+### 6.1 Files on a box
+
+| # | state | where | disposition |
|---|---|---|---|
-| `preparing` | console (request) | replica rounds, files-archive rounds, target readiness reports | `ready`, `aborting` |
-| `ready` | source | delta under the bar; rounds continue | `cutting`, `aborting` |
-| `cutting` | console (cut door) or source (auto-cut) | §7.4 | `landing` (the flip), `ready` (cut refused before any stop), `aborting` (before any stop) |
-| `landing` | source, in the flip write | target restores, checks, probes; no public traffic | at rest in target (target's commit), `rolling_back` |
-| `rolling_back` | target (a failed check) or source (landing deadline passed) | record region is the source again; source restores and probes | at rest in source, `stuck` |
-| `stuck` | source | source failed its probe after a rollback | at rest (operator, §7.6) |
-| `aborting` | console | source deletes staging and the files archive | at rest in source |
+| F1 | SQLite DB, WAL, SHM | `<DataDir>/<host>.db` (`provision.go:1219`) or nested `InstanceDir/<host>.db` | carried as the replica; installed as a generation (§7.6) |
+| F2 | litestream meta dirs | `<db>-litestream`, `.activator-<db>-litestream` (`backend_exec.go:1570-1571`) | rebuilt on restore |
+| F3 | `<dbPath>.gen` | beside the DB (§5.1) | written on restore |
+| F4 | **files directory** | flat: `strings.TrimSuffix(dbPath, ".db")` = `<DataDir>/<host>/` (the app's `instance.key`, `report.token`); nested: every entry in `InstanceDir` except F1 to F3, F5, F6, F8, F9, F10 and #199's markers | **carried** in the archive (§8) |
+| F5 | `<host>.env` | `paths.go:50`; `CARLOS_ADMIN_TOKEN` (`admintoken.go:115`) and operator overrides | **carried** in the archive; its keys are checked against the rendered config (T3) |
+| F6 | `<host>.config.env` | `syncexec.go:71-76` | **rendered** on the target for the held row (§9.1), checked by digest (T3) |
+| F7 | `<host>-sidecar.env` | `paths.go:51` | **refused** (§7.1) |
+| F8 | launch files, sockets, log sockets, exit records | `provision.go:1282-1294` | rewritten per start |
+| F9 | quarantined restore `<db>.corrupt` | `backend_exec.go:313-324` | stays; parked with the rest at finalization |
+| F10 | layout journal | `<DataDir>/layout/<host>.json` (`provision.go:1303`) | the cut waits while present (`movespass.go:447`) |
+
+Anything else an app writes under `/data` is not carried (M4). `docs/site`
+says so.
+
+### 6.2 Registry state (codex 16)
-New fields on `MoveState`, all omitempty:
+Every `routes` column (`internal/registry/registry.go:34`), classified:
+
+| column(s) | class | Phase 1 |
+|---|---|---|
+| Host, Slug, Kind, App, Account, Region, Channel, Environment, Hibernate, StatusNamed, Steering, Gate, Seed | reproduced | from the record by `routeFor` (`reconcile.go:364-376`) |
+| Socket, DBPath | reproduced | derived on the target for its own layout |
+| Provisioned | reproduced | true on mint |
+| Status | protocol | the hold statuses of §5.2 |
+| hold_op (new) | protocol | §5.2 |
+| **Replication** | **carried** | box-local, absent from records, preserved by every path today (`registry.go:138-145`, `reconcile.go:1100-1113`); read from the source row at the cut, written to the target row at install |
+| **NextDue** | **carried** | the app's own next scheduled wake, probed at stop (`activator.go:2019-2030`); read from the source row after its stop, written to the target row at install, so the due-wake sweep still wakes it |
+| Version, PrevVersion | reset | the target materializes the channel head itself (§9.2) |
+| LastError, DNSStatus, DNSCheckedAt, CertExpiresAt, CreatedAt | reset | recomputed by their own passes |
+| Caps | **refused** if non-empty | box-local authority; not carried implicitly |
+| Unit, Backing (unit), Platform, Tunnel, FleetBox, Pool, Canary | **refused** if set | not the platform's to move |
+| Addr, Addrs, Health | **refused** if set | addr-backed |
+| Replica, Replicas | **refused** | a replica, or a home with replicas (Q2) |
+| IdleSeconds, MaxRunSeconds, Args | n/a | sidecar columns |
+
+The `timers` table (`registry.go:565-579`) is carried as a snapshot (§7.4,
+§9.4).
+
+### 6.3 Parking on a box and in a bucket
+
+Phase 1 reuses #199's two park mechanisms with a move-scoped destination:
+
+- **Bucket park** of a prefix: per-key copy into the destination, then a
+ manifest of every key and its size, then delete the source keys named in
+ the manifest (#199 §3.3). Never `MirrorPrefix`, which deletes
+ destination-only objects (`internal/lease/mirror.go:222`).
+- **Local park:** `rename` into `<DataDir>/.parked/<name>/<host>/` (#199
+ §3.4).
+
+Destinations: `superseded/<move id>/<host>/` in the bucket and
+`.parked/superseded-<move id>/<host>/` on the box. A move id is unique, so
+two parks can never collide, and no park destination is the ancestor of a
+live prefix (codex 8).
+
+## 7. The move
+
+### 7.1 Request and refusals
+
+`POST /api/cli/apps/{account}/{app}/instances/{host}/move`
+`{"region":"uk","cut":"auto"|"manual"}`, operator-only in Phase 1 (§12).
+
+Refused (400 or 409, with the sentence), at the door and again by every box
+step, since records have other writers:
+
+- not exec-backed `kind:"instance"`, addr-backed, pooled, fleet-placed,
+ non-hibernating (`internal/console/apiinstancemove.go:53-72`);
+- a replica (`:187-190`, `provision.go:978-980`), or **a home with
+ replicas** (new; neither the door nor `MoveRequest` checks `Replicas`
+ today);
+- a target of home (`apiinstancemove.go:203-209`; F2), or the current
+ region, or one outside the manifest's `regions` allow-list
+ (`apiinstances.go:1797`; also how an app that needs out-of-band
+ host-role grants is pinned to its region);
+- a legacy `<host>-sidecar.env` on the source, or any refused registry
+ column (§6.2);
+- **a private store** bound to the instance's environment (#412): its
+ cutover needs a joint transaction (codex 12; F4). An **app-level store**
+ stays where it is and the moved instance reads it across regions; T4
+ proves it can;
+- a move already in flight, or a host tombstone for this host in either
+ region that is not yet `parked` (§7.12);
+- any box in the source or target region below the protocol floor (§11).
+
+`MoveRequest` (`provision.go:966-983`) writes `move` with the fields of
+§7.2 and state `preparing`.
+
+### 7.2 Record fields
+
+All additive under the existing `move` object (`provision.go:907-917`).
+Old binaries are kept away by the protocol floor (§11).
```json
"move": {
- "id": "mv_01J9…", // console-minted; the generation nonce, the log, the ledger
- "from": "", // record region before the move ("" = home)
- "from_db": {"gen": "g3-…", "root": "instances"}, // the source generation; null = legacy
- "to_gen": "g4-mv_01J9…", // the move generation (#199 §4)
- "to": "uk",
- "state": "landing",
- "cut": "auto", // "" = manual cut door (today's behaviour)
- "requested_at": "…", "ready_at": "…", "updated_at": "…",
- "cut_at": "…", // source stamps when it sets the route "moving"
- "flipped_at": "…",
- "landing_deadline": "…", // flipped_at + 10 min
- "files": {"digest": "sha256:…", "bytes": 18234, "entries": 4},
- "reason": "UK could not start the instance: …" // rollback, refusal or stuck
+ "id": "mv_…", "incarnation": "…", "from": "", "to": "uk",
+ "state": "landing", "cut": "auto",
+ "requested_by": "operator:paul@…", "requested_at": "…", "updated_at": "…",
+ "ready_at": "…",
+ "cut_phase": "flipped", "cut_at": "…",
+ "source": {"replication": "stream", "next_due": 0,
+ "db_sha256": "…", "files": {"plain": "sha256:…", "sealed": "sha256:…", "bytes": 1824, "entries": 4},
+ "timers_sha256": "…"},
+ "flipped_at": "…", "landing_deadline": "…",
+ "landing_phase": "probing",
+ "landing": {"installed_db_sha256": "…", "probe_attempts": 1},
+ "committed_at": "…", "opened_at": "…", "source_parked_at": "…",
+ "rollback": {"id": "rb_…", "requested_at": "…", "reason": "…", "dirty": true,
+ "phase": "returned", "release_deadline": "…"},
+ "reason": "…"
}
```
-`MoveRequest` (`provision.go:966-983`) records `from`, `from_db` (the
-record's `DB` at that moment, or null for a legacy record) and allocates
-`to_gen = g<seq+1>-<move id>`, where `seq` is the source generation's
-sequence (0 for a legacy or pointerless record, so a legacy record's first
-move lands at `g1-<move id>`, #199 §4). The move id is the nonce, so no two
-moves, and no move and push, can share a location.
-
-`MoveComplete` (`provision.go:1056-1065`) changes in two ways. In one CAS
-it writes `region = to`, `DB = {Gen: to_gen, Root: <I-1>}` and
-`state = landing`, instead of clearing `move`. The target clears `move` at
-its commit. Region and generation flip together in exactly one write
-(#199 §4), and the source has nothing to do after it. This is the 09-24
-record's §6.1 `landing`, with #199's pointer added.
-
-Consoles roll first (§21). P0's allowlist in `MoveAbort`
-(`provision.go:1027-1035`) already refuses any state it does not know, so a
-console from v0.22 cannot abort a `landing`, `rolling_back` or `stuck`
-record.
-
-### 7.2 Preparing
-
-The replica rounds are main's delta rounds of `lease.MirrorPrefix`
-(`movespass.go:372-394`) with #199's keys: from the source storage key
-(`StorageKey(rec)`, #199 §2.3) into the target's
-`moves/<host>/<incarnation>/<to_gen>/`, with receipts at the source-keyed
-receipt path (S4). Two additions run in the same pass:
-
-1. **Files-archive rounds (source).** Each round, the source builds the
- instance files archive (§8) from the live directory, and uploads it only
- when its digest has changed since the last upload. It goes to the
- target's data bucket at `moves/_files/<host>/<incarnation>/<to_gen>.archive`
- with a manifest at `….json`. The `_` makes the key impossible to
- collide with a host (no valid host contains one, `provision.go:1252-1258`),
- and it sits inside the existing push-only `moves/*` grant
- (`infra/modules/region-host/main.tf:217-227`). No new IAM.
- This archive is a rehearsal copy taken from a running instance, so it
- may be inconsistent. It proves the path and the key, not the content.
- The final archive is taken at the cut.
-2. **Readiness reports (target).** §7.3.
-
-`ready` keeps today's rule (delta at most the bar) and gains one: the
-newest target report for this move id passes and is under 10 minutes old.
-
-### 7.3 Target readiness (the pre-flip check)
-
-The target box gains a move-worker role. Its pass already lists every
-record (`movespass.go:242`). For each record whose `move.to` is this box's
-placement and whose state is `preparing` or `ready`, it runs the checks
-below and writes the result to the deployment bucket at
-`moves/<acct>/<app>/<host>/<move-id>/target.json`. Every check runs while
-the source keeps serving.
-
-| check | passes when | catches |
+Every box-side transition is a CAS guarded by `move.id` and
+`move.incarnation`, so a recreated host's record (a new incarnation) never
+matches an old move's step (§7.12).
+
+### 7.3 Preparing and readiness
+
+**Replica rounds (source)**, as on main (`movespass.go:372-394`), into the
+target's `moves/<host>/<move id>/`, with receipts at
+`move-receipts/<host>/<move id>.json` in the source bucket. Keying staging
+and receipts by move id is the generation handoff's bucket half: a stale
+staging prefix or receipt from another move is never listed.
+
+**Snapshot rounds (source).** Each round the source builds the files
+archive (§8) from the live directory and uploads it when its plaintext
+digest changes, and publishes its own **launch report** to
+`moves/<acct>/<app>/<host>/<move id>/source.json`: the names of every
+variable in its final merged launch environment
+(`ExecBackend.instanceEnv`, `backend_exec.go:1135-1153`, plus the agent-owned
+variables), each with an HMAC-SHA256 of its value under a key derived (HKDF)
+from `CARLOS_SECRETS_KEY`. The report reveals names and equality, never
+values, and only a box holding the key can compute a matching digest.
+
+**Readiness report (target).** The target box's move worker runs these
+checks for every record whose `move.to` is its placement, while the source
+keeps serving, and writes the result to
+`moves/<acct>/<app>/<host>/<move id>/target.json`:
+
+| check | passes when | finding |
|---|---|---|
-| T1 capability | this box runs a build that understands `landing` and the files archive, and its edges file publishes `moves_schema >= 2` | an old target minting a `ready` route with no checks: today's failure mode |
-| T2 secrets key | this box holds `CARLOS_SECRETS_KEY` and its public fingerprint equals the console's | §1.2b, every variant |
-| T3 config render | a dry run of `SyncExec`'s resolution for this host's (account, app, environment) plus the instance overlay decrypts every sealed value; the report lists the variable **names** and the bundle etags, never a value | a bundle the box cannot open, a missing environment |
-| T4 store reach | for every store bound in that render (`<PREFIX>_BUCKET`, `_REGION`, `_ENDPOINT`, `_ACCESS_KEY`, `_SECRET_KEY`, `internal/objstore/objstore.go:214-218`), a `HeadBucket` with the bound credential from this box succeeds | "File storage is unavailable" |
-| T5 files archive | the newest staged archive opens with this box's key and its digest matches its manifest | the key path for S5 and S6 |
-| T6 disk | free space on the data volume (`readDiskFrac`, `internal/host/probes.go:209`) covers twice the replica size, plus the archive, plus 2 GiB, and stays under 85% full afterwards | the 91% Ireland disk of 2026-10-01 |
-| T7 release | the instance's channel head materializes on this box (the check a wake would make; "no adoptable head", `internal/activator/logsink.go:64`) | a channel with nothing promoted for this box's architecture |
-| T8 one owner | exactly one hosting box publishes the target placement | a second same-region box minting the route (the `OwnsRoute` reasoning at `movespass.go:180-185`, from the other side) |
-
-The console's cut door refuses with the failing check's sentence (409)
-unless the newest report passes. The source re-reads the report as the
-first step of every cut pass and refuses before it stops anything
-(`cutting` back to `ready`, with `reason`), because records have other
-writers. An auto-cut (§16) is the source taking this same step itself.
-
-### 7.4 The cut (source)
-
-#331's ordering (`movespass.go:43-51`), with three steps added (marked
-NEW):
-
-1. NEW: re-read the target report; refuse the cut if it no longer passes.
-2. Route status `moving`; stamp `cut_at` once (`:418`).
-3. Wake fence (`:424-431`); wait until `Quiesced` (`:432`).
-4. Wait out a layout journal (`:447`).
-5. Final checkpoint into the source generation (`:470`).
-6. Byte-final mirror round into the move generation (`:476-490`).
-7. NEW: **final files archive**: build it from the stopped instance's
- directory, upload it, record its digest. Export the host's `timers` rows
- into it (§8.1).
-8. Local DB purge (`:492`), with #199's error-returning purge, which
- removes `.lineage` first and fails the pass on any error other than
- not-exist (#199 §5.6). **The instance files and `<host>.env` are not
- touched.** They are the rollback's material until the hold ends.
-9. NEW in content: the flip writes `region = to`, `DB = {to_gen, Root}`,
- `state = landing`, `flipped_at`, `landing_deadline` and `files` in one
- CAS, guarded by the move id and `from_db`.
-
-Every step stays idempotent, and a box that dies mid-cut resumes at the
-top. The archive build is deterministic (§8.1), so a resumed cut re-uploads
-a byte-identical object.
-
-### 7.5 Landing (target)
-
-The target's reconcile mints the route when the record's region names it,
-as today, with #199's `incarnation`, `storage_key` and `storage_gen`
-columns rendered from the record (#199 §2.4), and with one change: a
-record in `landing` is minted with status `landing` instead of `ready`
-(`reconcile.go:374`). That status is the durable fence. It survives an agent restart, which the in-memory activator
-fence does not (`movespass.go:294-300`):
-
-- the edge serves only `ready` rows (`internal/edge/edge.go:595`), so no
- visitor can wake it;
-- ticks refuse any non-`ready` row (`internal/activator/activator.go:2785-2789`).
-
-The target's move worker then runs, in order, each step idempotent:
-
-| step | action | on failure |
+| T1 protocol | this box runs at or above the protocol floor (§11) | codex 7 |
+| T2 secrets key | this box publishes `secrets_key_fp` equal to the console's (§10) | §1.2b |
+| T3 environment | the target renders the bundle for the instance's environment and overlay through the held-render API (§9.1), opens the newest archive, merges exactly as `instanceEnv` will at spawn (`<host>.env` over the rendered config, `backend_exec.go:1231-1246`), adds the agent-owned variables, and every name in the source's launch report is present with an equal value digest, except `CARLOS_REGION`. A variable from `<host>.env` that shadows a rendered one is listed in the report | codex 14 |
+| T4 storage | for each store bound in that **merged** environment (`<PREFIX>_BUCKET/_REGION/_ENDPOINT/_ACCESS_KEY/_SECRET_KEY`, `internal/objstore/objstore.go:214-218`), using the merged credentials: list one key, put, get and delete a probe object at `.carlos-move-probe/<move id>` | codex 14 |
+| T5 archive | the newest archive opens with this box's key, its plaintext digest matches its manifest, and it holds no special file and is under the cap (M5) | |
+| T6 disk | free space (`readDiskFrac`, `internal/host/probes.go:209`) covers twice the replica, plus the archive, plus 2 GiB, and stays under 85% full | |
+| T7 release | the channel head materializes for the held row (§9.2) | codex 13 |
+| T8 one owner | exactly one hosting box publishes the target placement | |
+| T9 no tombstone | no host tombstone for this host is `pending` in this region (§7.12) | |
+| T10 return lane | this box can put and delete `moves/_lane/<move id>` in the **source's** data bucket, so a dirty rollback can carry its writes back (§7.8) | codex 2 |
+| T11 duration | the measured restore time for this replica size, plus the source's measured stop time, is under the operator's window (default 120 s) (M11) | |
+
+**Source readiness (codex 14).** The source proves the probe contract on
+the instance as it runs today: a probe request (§7.7) to its readiness path
+answers 200 to 399 from the instance's own process. An app that fails this
+on its home box is refused with "declare `moves.ready_path`" rather than
+moved and rolled back.
+
+`ready` requires a round at most the 16 MiB bar **and** a passing target
+report and source probe, each under 10 minutes old.
+
+### 7.4 The cut, journaled (codex 4)
+
+The cut is a sequence of phases. **Each phase is written to the record
+(`cut_phase`) before its side effects start**, so a restart resumes
+exactly, and a failure has a defined recovery.
+
+| phase | entered by | action | failure |
+|---|---|---|---|
+| `requested` | cut door (operator) or the source (auto-cut), CAS `ready` → `cutting` | none | |
+| `admitted` | source CAS `requested` → `admitted`, stamping `cut_at`, **only if** the target report and source probe are still fresh | none yet | not fresh: CAS back to `ready` with `reason`. Abort is legal until `admitted` and refused after it, and both are CASes on the same field, so exactly one wins |
+| `stopping` | source | route status `moving`, `hold_op = move id`; `FenceAndStop` (`maintenance.go:50`), retried until it returns stop evidence | bounded: 5 min, then `reverting` |
+| `stopped` | source | final checkpoint into its own `instances/<host>`; read `Replication` and `NextDue` from its row; write `stopped-source.json` (§5.3); snapshot the `timers` rows into `move-snapshots/<host>/<move id>/timers.json` in its own bucket | |
+| `staged` | source | byte-final mirror round into `moves/<host>/<move id>/` (`movespass.go:476-490`) | 3 deferred rounds or 10 min: `reverting` |
+| `archived` | source | build the final archive from the stopped directory, seal it, write it **create-only** to its own bucket, record both digests in `source.files` (§8.3); then copy it and the timers snapshot into the target's staging | over the cap, a special file, or 10 min of upload failures: `reverting` |
+| `flipped` | source, the flip CAS | `region = to`, `state = landing`, `flipped_at`, `landing_deadline = flipped_at + 10 min` | |
+
+The source does **not** purge its local database or files at the cut (rev 1
+did, `movespass.go:492`). They stay, held, until finalization parks them
+(§7.9), so a rollback restarts from them warm, and nothing on the source
+can be read after a completed move because the park removes it from every
+path a wake looks at.
+
+**`reverting` (pre-flip recovery).** The record still names the source, and
+the source holds the execution right. The source keeps the row held as
+`restoring`, checks its files against `source.files.plain` (unchanged, since
+nothing ran), runs the probe (§7.7), and on success opens with the
+conditional write and CASes to `aborting` with the reason. On probe failure
+it retries for 15 minutes, then `stuck` (paged, still held). Staging is
+then deleted by the abort arm.
+
+### 7.5 The flip
+
+One CAS, guarded by move id, incarnation and `cut_phase = archived`.
+Execution right: the source has written stop evidence, so the right passes
+to the target as soon as it reads it. The source row stays held (`moving`):
+reconcile no longer removes a row whose record is mid-move with this box as
+`from` (§9.6), so its timers, `Replication` and local files remain for a
+rollback.
+
+### 7.6 Landing and install (codex 5, 13)
+
+The target's reconcile mints the row `landing` with `hold_op = move id`
+(§9.6), unless a pending tombstone for the host is in this region (§7.12).
+The target's move worker then runs the landing phases, each written to
+`landing_phase` before its side effects:
+
+| phase | action | replay rule |
|---|---|---|
-| L1 | fetch the archive at `moves/_files/<host>/<incarnation>/<to_gen>`; verify it against `move.files.digest`; open it | roll back: `files archive missing or damaged` |
-| L2 | move any existing files directory aside to `<DataDir>/.retired/<incarnation>-<old .files-gen or "unknown">/` (#199 §2.5b's convention, never a delete); extract fresh (§8.3); write `.incarnation` and `.files-gen = to_gen`; write `<host>.env`; import the timers | roll back |
-| L3 | run `SyncExec` for this host; require a config ack from this box for this host written after `flipped_at` (`configack.go:116`), whenever the app has a bundle; require every name in T3's list to be in the file | roll back: `configuration did not render on <target>` |
-| L4 | repeat T4 with the rendered file | roll back: `<target> cannot reach the app's storage` |
-| L5 | **probe wake**: a new activator entry, `ProbeWake(host, moveID)`, legal only on a `landing` row. It restores cold from the row's `storage_key`, which the record's pointer names (#199 §2.4; no `.lineage` exists yet, so no warm file can be used), runs `CheckDB` (`backend_exec.go:313`), spawns, and requires the existing health answer (an HTTP response under 500 on the socket within 30 s, `backend_exec.go:36`, `:1426-1444`). Then it waits a 20 s settle window and asks again. Up to three attempts. Whether the restore reads the staging root directly or a copy under `instances/` is I-1 (§26) | roll back: the member-safe wake reason (`logsink.go:49`) |
-| L6 | **commit**: CAS `landing` → at rest (clear `move`), conditional on the record still saying `landing` with this move id | lost CAS: never open; follow the record (it was rolled back) |
-| L7 | **open**: route status `ready`; bump the serial; log `done` with the timings | retried every pass until true |
-
-INV-2 lives in L6 before L7. A target that crashes between them finds a
-record at rest in its region and a `landing` row on restart, and finishes
-L7. Nothing else writes route status, so this repair is the worker's job
-(#175's P0 notes: "nothing writes a `moving` row back to `ready`").
-
-**What visitors see.** From the source's `moving` until L7, the host has no
-`ready` row anywhere. A host under a platform apps domain gets the 503
-pending page (#174, `internal/edge/pending.go`). A custom domain gets 404
-until P2's holding route lands (Phase 2). That is the window M2 calls
-"tens of seconds of 503". §20.4 measures it on an ips-shaped instance.
-
-### 7.6 Automatic rollback
-
-A failed landing check, or a passed `landing_deadline`, rolls the move
-back. This is the M2 promise and it needs no staging lane, so it works when
-the source is the home region (kpmg's case) without the home placement of
-§9.
-
-**Who writes it.**
-
-- The target, on any L1 to L5 failure: first stop its child and purge its
- local DB (error-returning, `.lineage` first) and config, and move its
- freshly extracted files aside; then CAS `landing` → `rolling_back`,
- setting `region = move.from`, `DB = move.from_db` and `reason`.
-- The source, when `now > landing_deadline` and the record still says
- `landing`: the same CAS. The target may be dead or wedged.
-
-Both are CASes from `landing`, guarded by the move id and
-`DB.Gen == to_gen`, so exactly one of commit (L6) and rollback wins. A
-target that loses to the source's rollback never opened (INV-2), so it
-holds no acknowledged write (INV-3). Region and pointer go back together
-(INV-4b): the rollback is the flip, reversed, in one write. This is why
-rollback needs neither a staging lane into the source nor any copy.
-
-**What the source does** in `rolling_back` (its placement matches the
-record region again):
-
-1. If its `moving` row still exists: set it `ready` and lift its fence.
- Otherwise reconcile re-mints the route. Either way the row ends up
- `ready`, with the source storage key. If the source row had been
- retired and re-minted, #199's retire-on-identity-change (§2.6 there)
- does not fire, because the incarnation and storage key are the ones the
- row was minted with.
-2. **Files check.** The files directory and `<host>.env` are where the cut
- left them (§7.4 step 8). The source re-hashes them against the
- archive's per-file manifest (§8.2), which it wrote at the cut. A
- mismatch means something changed the directory after the cut: go to
- `stuck`, never start (INV-5). The config file is re-rendered by the
- same pass.
-3. Probe wake. The restore comes from the source generation, which equals
- the final staged copy (INV-4). The cut removed the local database and
- its `.lineage`, so the warm-cache path cannot win.
-4. Probe passes: CAS `rolling_back` → at rest; log `rolled_back` with the
- target's reason; page as a warning.
-5. Probe fails three times: CAS → `stuck`; page as an incident.
-
-The source admits traffic as soon as its row is `ready`. That is safe: the
-source copy is the authoritative one in this state.
-
-**What the target leaves** once the record has left its scope: reconcile
-retires the `landing` row (its incarnation is unchanged but the record no
-longer names this region). The move generation
-`moves/<host>/<incarnation>/<to_gen>/` and its archive are **unreferenced**:
-nothing points at them, and no later move can reuse the key, because the
-move id is in it. They are garbage, not a hazard (#199 §4), so residency
-cleanup deletes them on the next pass rather than as part of the
-rollback. A denied delete (`movespass.go:509-512`) is logged as residue.
-The target's moved-aside files directory goes the same way.
-
-**`stuck`** keeps `region = from` and the full `move` object as evidence.
-New moves are refused. An operator resolves it with
-`carlos move resolve --host <h>` after fixing the box (§16). The verb
-re-runs the source probe, and on success clears the move. It never touches
-data. This is the 09-24 record's M2 recovery path.
-
-**Side effects.** An app that does work at boot (sends mail, calls a
-webhook) will have done it on the target during L5, and will do it again on
-the source after a rollback. `docs/site` says so. Apps that care can read
-`CARLOS_MOVE_PROBE=1`, which the probe wake sets, and skip boot-time side
-effects.
-
-### 7.7 Abort
-
-Legal in `preparing`, `ready`, and in `cutting` before the source has set
-the route `moving` (`cut_at` unset). The source's abort arm
-(`movespass.go:396-413`) deletes the move generation and its archive
-before it clears the move. Under v2 that delete is residency hygiene, not
-safety (an unreferenced generation is never read), so a failed delete no
-longer has to hold the abort open; it is logged as residue. For a legacy
-record the abort keeps today's two-phase rule, because legacy staging
-still wins promotion (§7.9). Abort after `cut_at` is refused. Past that point the move
-either completes or rolls back on its own, and both are faster than an
-abort would be.
-
-The 09-24 record's "abort until the purge" (cutting before the purge) is
-not adopted. Automatic rollback covers the same ground with one mechanism
-instead of two.
-
-### 7.8 Crash at any step
-
-| actor crashes in | on restart |
-|---|---|
-| source, `preparing`/`ready` | next round resumes; receipts make it cheap |
-| source, `cutting` before the flip | resumes at step 1 (#331); re-uploads a byte-identical archive |
-| source, after the flip | nothing to do until `rolling_back` or the deadline |
-| target, before L6 | re-runs L1 onwards; extraction and import are idempotent; the probe counts attempts per process, and the deadline bounds the total |
-| target, between L6 and L7 | sees record at rest plus a `landing` row; does L7 |
-| target, during its rollback | record already `rolling_back`: finishes cleanup; or not yet written: re-runs the failed check, fails again, writes it |
-| source, during `rolling_back` | re-runs §7.6 steps 1 to 5 |
-| console, any state | stateless for instance moves; the ledger row is re-read |
-| both boxes dead | deadline passes with no writer; the console's move sweep pages at deadline + 5 min, shows `landing (overdue)`, and offers `carlos move resolve` |
-
-### 7.9 Legacy records
-
-A record is legacy under #199 §2.3 (no `DB` pointer, created before the v2
-epoch). Two cases.
-
-**After the epoch exists**, a move is the record's first v2 event (#199
-§4). The source reads its legacy location, `instances/<host>`, and keeps
-the legacy receipt key, `move-receipts/<host>.json`. Everything it writes
-in the target is v2: the move generation is `g1-<move id>`, staged at
-`moves/<host>/<incarnation>/g1-<move id>/`, with the archive beside it.
-The flip writes the record's first `DB` pointer. `from_db` is null, and a
-rollback writes `DB = null` back, which #199 reads as the legacy location
-again, because CreatedAt is still before the epoch. **The incarnation of a
-legacy record whose CreatedAt is zero** is whatever
-`provision.Incarnation` returns for it, as long as source and target
-compute it identically. §26 I-5 asks the #199 spec to pin that.
-
-**Before the epoch exists**, every record is legacy and #199 writes no
-pointer. The move then keeps main's storage flow exactly: staging at
-`moves/<host>/`, the target promotes it over `instances/<host>` on first
-wake ("staging wins", `backend_exec.go:1581-1588`), and receipts at
-`move-receipts/<host>.json`. Everything else in this spec still applies:
-the archive (at `moves/_files/<host>/<move id>.*`), readiness, `landing`,
-the probe, and rollback. Rollback is still a region flip, since the source
-location never changed. What the legacy flow cannot do is make the
-target's promoted copy unreferenced: after a rollback the target bucket
-holds a full `instances/<host>` lineage that a later restore on that box
-would trust. So, pre-epoch, the target deletes it during the rollback,
-before writing `rolling_back`. If the delete is denied, the rollback goes
-ahead anyway (the instance must not stay offline over cleanup), and the
-residue is logged and shown to operators as a hazard, not as garbage. A
-later move to that box is still safe, because its fresh staging wins
-promotion. What is not safe is anyone pointing the record at that region
-by hand. §23 Q11 asks whether to support this flow at all, or to refuse
-moves until the epoch exists.
-
-## 8. The instance files archive
-
-### 8.1 Format
-
-- A tar stream of the instance files (§5, S5) plus `<host>.env` (S6) plus
- `timers.json` (S11, S12), in sorted path order, with mtimes and owners
- zeroed and modes kept. The output is byte-deterministic for one input.
-- Only regular files and directories. A symlink, device, FIFO or socket in
- the directory refuses the move with its name. The nested layout already
- refuses special files on tenant ground (34beb1b3), and the archive holds
- the same line on both layouts.
-- Size cap: **256 MiB** before compression (§23 Q2). Larger refuses the
- move and points at object storage.
-- zstd, then sealed.
+| `installing` | read `stopped-source.json` (evidence check); bucket-park whatever is at `instances/<host>/` in its own bucket into `superseded/<move id>/<host>/` (an older stay); per-key copy `moves/<host>/<move id>/` into `instances/<host>/`, verifying each size; write `instances/<host>/generation.json` with `gen = move id` last | replayable until `generation.json` exists: the staging is immutable after the flip and the copy is per-key with no destination deletes |
+| `installed` | local-park any warm DB and any existing files directory into `.parked/superseded-<move id>/`; open the archive, verify the plaintext digest, extract; write `.files-gen = move id` last by rename; write `<host>.env`; replace the host's timers with the snapshot (§9.4); set `Replication` and `NextDue` on the row | replayable until `.files-gen` exists |
+| `configured` | render through the held-render API (§9.1) and verify the move-bound ack | replayable |
+| `restored` | restore the database cold from `instances/<host>` (now generation `<move id>`), write `<dbPath>.gen`, `CheckDB` (`backend_exec.go:313`); record `landing.installed_db_sha256` | replayable until `probing` |
+| `probing` | the probe (§7.7) | **never replays an earlier phase** (INV-7). A crash here restarts only the probe, on the same installed files and database, which may now hold target writes |
+| `probed` | probe passed | |
+
+Any failure in `installing` to `probing` triggers a rollback (§7.8). A
+landing past `landing_deadline` triggers one too.
+
+### 7.7 The probe (codex 14)
+
+A new activator entry, `ProbeWake(host, op)`, legal only on a row held with
+`hold_op = op`:
+
+- spawns the instance through the normal path, with `CARLOS_MOVE_PROBE=1`
+ set (advisory; INV-2 does not rely on it);
+- **binds the answer to the process:** the socket's peer credentials
+ (`SO_PEERCRED`) must name a process in the spawned child's cgroup, so
+ another process squatting on the socket cannot pass;
+- requests the app's readiness path, `moves.ready_path` from the instances
+ manifest when declared, `/` otherwise, and requires 200 to 399 within 30 s
+ (the same budget as a wake, `backend_exec.go:36`);
+- waits a 20 s settle window, requires the process to still be running,
+ and asks again;
+- up to three attempts.
+
+The probe's child is registered as **held** in the activator, so an edge
+reload's `RetireExcept` (`internal/edge/edge.go:924`,
+`activator.go:2624`) does not retire it for being absent from the ready set,
+and the edge still never admits a request to it (codex 13).
+
+### 7.8 Rollback (codex 1, 2, 3)
+
+**Triggers:** any landing failure (the target writes it), or
+`landing_deadline` passing (the source writes it, or the console's sweep).
+Both are a CAS `landing` → `rolling_back`, guarded by move id. The commit is
+a CAS from `probed` on the same record, so exactly one of commit and
+rollback wins.
+
+The rollback CAS sets `region = from`, `rollback.id`, `reason` and
+`release_deadline = now + 15 min`. Nothing starts anywhere yet.
+
+**Target release.** The target's worker, on seeing `rolling_back`:
+
+1. `FenceAndStop` until stop evidence (retry; a crashed agent restarts and
+ does the same).
+2. Final checkpoint; sha256 of the database file after a `TRUNCATE`
+ checkpoint; plaintext digest of the files directory; digest of the
+ host's timers rows.
+3. **Clean or dirty.** Clean when all three equal what was installed
+ (`landing.installed_db_sha256`, `source.files.plain`,
+ `source.timers_sha256`) or when no probe ever started. Otherwise dirty.
+4. Write `stopped-target.json` with the digests and the verdict, and CAS
+ `rollback.phase = released`, `rollback.dirty`.
+5. **Dirty only:** carry the writes back. This is the handoff, reversed:
+ mirror its `instances/<host>` into the source bucket's
+ `moves/<host>/<rollback id>/` (T10 proved the lane), build and seal an
+ archive of its files directory, snapshot its timers, publish all three,
+ then CAS `rollback.phase = returned`.
+6. Hold its row until reconcile retires it (the record no longer names its
+ region), then local-park its database and files into
+ `.parked/superseded-<rollback id>/`.
+
+**Source recovery**, on `released` (clean) or `returned` (dirty):
+
+1. Its row is still held (`moving`, `hold_op = move id`, §7.5). It switches
+ it to `restoring` with `hold_op = rollback id`.
+2. Clean: its own database and files are what it stopped with. It checks
+ the files against `source.files.plain`; a mismatch means something
+ touched them, and goes to `stuck` (INV-4).
+3. Dirty: it installs generation `<rollback id>` exactly as §7.6 does
+ (park its own lineage and local files into `superseded/<rollback id>/`,
+ copy, extract, timers, config), because the target's copy is now the
+ authoritative one (INV-2).
+4. The probe (§7.7), still held.
+5. Pass: open with the conditional write on `hold_op = rollback id`; log
+ `rolled_back`; clear `move` (§7.9's last step). Page as a warning.
+6. Three failed probes: `stuck`, still held. Paged as an incident.
+
+The source never opens before its own probe passes, and never starts before
+the target's stop evidence (codex 1, 3).
+
+**Waiting for the target.** If `release_deadline` passes with no evidence,
+the move stays in `rolling_back`, shown as "waiting for uk to release", and
+pages. It is still safe (nothing runs, the source is held) and it converges
+as soon as the target box returns (INV-3). §7.10 covers a box that never
+returns.
+
+### 7.9 Commit and finalization (codex 6)
+
+1. **Commit** (target): CAS `probed` → `committed`, stamping `committed_at`.
+ The `move` object stays, so no new move and no push can start, and all
+ recovery information is kept.
+2. **Open** (target): the conditional write on `hold_op = move id`; stamp
+ `opened_at` (CAS). A restarted target finds `committed` and a held row,
+ and opens it.
+3. **Source park** (source): bucket-park its `instances/<host>/` into
+ `superseded/<move id>/<host>/`; local-park its database, files directory
+ and `<host>.env` into `.parked/superseded-<move id>/`; delete its row
+ (with `hold_op = move id` as the condition); stamp `source_parked_at`.
+ Its timers rows go with the row, and the snapshot in
+ `move-snapshots/<host>/<move id>/` remains.
+4. **Close** (whichever of the two stamps lands second): write the terminal
+ log event with every timing and digest, then CAS-clear `move`, guarded by
+ move id and both stamps.
+
+Until step 4 the record still says `committed`, so every crash between
+steps resumes from the record.
+
+### 7.10 Stuck, held, and a box that never returns
+
+- `stuck`: the source's probe failed on recovery. The row stays held. The
+ operator fixes the cause and runs `carlos move resolve --host <h>
+ --retry`, which reruns the source probe. Nothing is deleted.
+- `rolling_back` past `release_deadline`: as §7.8, waiting. Converges when
+ the target returns.
+- **A box destroyed mid-handoff.** Its stop evidence can never arrive.
+ `carlos move resolve --host <h> --declare-lost <box>` is the one
+ operator act in Phase 1 that may lose data: it records the operator's
+ statement that the box's processes are gone, with a confirmation that
+ names what is lost (target writes since `flipped_at`, if the lost box is
+ the target), and then proceeds as a clean rollback. §18 asks Paul to
+ confirm this.
+
+### 7.11 Abort
+
+Legal in `preparing`, `ready`, and `cutting` while `cut_phase = requested`
+(CAS). The source deletes `moves/<host>/<move id>/` and the archive, then
+clears `move`. A failed delete is logged and retried; the prefix is
+unreferenced (its key names the move id), so it is never read.
+
+### 7.12 Tombstones (#199 rev 3)
+
+- **The instance is deleted mid-move.** The delete door writes the
+ tombstone with the record's **current** region, then deletes the record.
+ Every move worker binds each step to `move.incarnation`; a record that is
+ authoritatively absent (or present with another incarnation) ends the
+ move for both boxes: each stops anything it runs (stop evidence as usual)
+ and **parks its own region's artifacts of this move into the tombstone's
+ `parked/<incarnation>/<host>/`**: the source its `instances/<host>/` (if
+ the tombstone is in the other region) and its receipts and snapshots; the
+ target its staging, `instances/<host>/` if installed, and local files. The
+ box in the tombstone's region parks as #199 §3.3 says. Phase 1 adds the
+ other region's half, because #199's park covers only its own region.
+- **Recreated mid-move.** The new record has a new incarnation and no
+ `move`. No old step matches it. #199 §3.5 defers the new row until the
+ parks are done, and the landing mint obeys the same deferral.
+- **#199's park of `moves/<host>/`.** Staging is
+ `moves/<host>/<move id>/` with a `move.json` naming the incarnation. The
+ tombstone park takes only keys whose `move.json` names the tombstoned
+ incarnation, or legacy keys with none. A live move of a newer incarnation
+ is never swept (§17, I-3). T9 also refuses to start a move into a region
+ with a pending tombstone for the host.
+
+### 7.13 Crash at every step
+
+Every row assumes the crashed actor restarts. "Resumes" means the actor
+re-reads the record and runs the phase it names.
+
+| state / phase | source crash | target crash | console crash |
+|---|---|---|---|
+| preparing, ready | next round | next report | stateless |
+| cutting: requested, admitted | resumes; admitted re-checks nothing (§7.4) | n/a | stateless |
+| cutting: stopping | flat child died with the agent, nested unit stopped on restart; evidence written | n/a | |
+| cutting: stopped to archived | resumes; the sealed archive is reused from its create-only key (§8.3) | n/a | |
+| reverting | resumes the recovery, still held | n/a | |
+| landing: installing, installed, configured, restored | n/a (holds `moving`) | replays the phase (§7.6) | |
+| landing: probing | n/a | restarts the probe only; the child died with the agent or is stopped first | |
+| committed | resumes the park | opens (held row, committed record) | |
+| rolling_back | waits for evidence; resumes recovery | stops, writes evidence, resumes return | the deadline sweep is a CAS, idempotent |
+| stuck | held; operator | | |
+
+## 8. The archive (codex 19)
+
+### 8.1 Content
+
+A tar of the files directory (F4) and `<host>.env` (F5), in sorted path
+order, with owners and times zeroed and modes kept, so equal content gives
+equal plaintext bytes. Only regular files and directories; a symlink,
+device, FIFO or socket refuses with its name. The 256 MiB cap is enforced
+while streaming the tar, so an oversized directory stops early (M5). Then
+zstd.
### 8.2 Sealing
-The archive carries the very key that protects the app's data, so it must
-not sit in a bucket beside that data in the clear. It is sealed to the
-deployment's config keypair, the same one config bundles are sealed to:
-
-- a fresh 256-bit data key; AES-256-GCM over 1 MiB chunks, each chunk's
- nonce derived from its index, with the final chunk flagged so the stream
- cannot be truncated;
-- the data key sealed with ephemeral X25519 to the public half of
- `CARLOS_SECRETS_KEY` (which `appconfig.ParsePrivateKey` already parses),
- exactly as a sealed config value is;
-- the manifest (the `.json` beside the archive) holds the sealed data key,
- the sha256 of the sealed object (the `files.digest` in the record), the
- plaintext entry list (paths, sizes, sha256 of each file), the move id,
- the incarnation and `to_gen`. The target refuses a manifest whose
- incarnation or generation differs from the record's.
-- The source keeps its own copy of the manifest beside the receipts
- (`move-receipts/<host>/<incarnation>/<from_gen>.files.json`), because it
- cannot read the target's bucket and the rollback's files check (§7.6
- step 2) needs it.
-
-Only a box holding `CARLOS_SECRETS_KEY` can open it. T2 proves the target
-is one.
-
-### 8.3 Extraction on the target
-
-Into the path §5 defines for the target row's layout, after moving any
-existing directory aside (§7.5, L2). The target row may be flat when the
-source was nested, or the reverse. The two markers, `.incarnation` and
-`.files-gen`, are written last, by atomic rename, so a crash mid-extraction
-leaves a directory that the next pass recognises as not yet landed and
-replaces. For a nested row,
-writes go through `instancefs` (no symlink follow), as every other write on
-tenant ground does. Every extracted file is checked against the manifest's
-per-file hash. `<host>.env` is written 0600. Timers are upserted with their
-saved `next_at`, `due_at`, `last_*` and `attempts`. Once-timers that came
-due during the cut fire on the first tick after open.
-
-### 8.4 What it does not carry
-
-Any path outside §5's definition, and process state. An app that keeps
-files elsewhere under `/data` loses them on a move. `docs/site` says so
-plainly.
-
-### 8.5 A requirement for P9
-
-The isolated-exec design moves `<DataDir>/<host>.*` into the instance
-directory (isolated-exec design §2 item 1). That glob does not match the
-directory `<DataDir>/<host>/`, and the unit's `TemporaryFileSystem=/data:ro`
-hides everything outside the instance directory (§4, "load-bearing"
-notes). So the layout pass, as designed, would strand every flat app's
-`instance.key` the same way this move did. P9 must carry `<DataDir>/<host>/`
-into `InstanceDir`, and stamp it with #199's `.incarnation` when it does.
-This is recorded here because it is the same fault, and is to be filed on
-#126.
-
-## 9. Moves to and from home (P3)
-
-Automatic rollback (§7.6) does not need this section. Two things do: a
-deliberate move to home (moving back after a successful move away, or an
-app whose region is home), and any move whose source is home and whose
-target needs to stage into home (never; the source stages into the target).
-So P3 is about home as a **target**.
-
-Today a move to home is refused (`apiinstancemove.go:203-209`), because the
-home box publishes no placement: it runs with `CARLOS_REGION` unset, and
-records store `""` for home. `moveTargets` drops any edges file without a
-placement (`movespass.go:345`), and the home's children receive no
-`CARLOS_REGION` (`backend_exec.go:989-993`).
-
-The fix is the 09-24 record's §6.6, which R4 ruled on:
-
-1. **`CARLOS_HOME_PLACEMENT`** on the home box: the console's
- `CONSOLE_HOME_REGION` name (`ie` on carloku). The box publishes it as
- `placement` with `"home": true` in its edges file. Its own
- `CARLOS_REGION` stays unset, so record scope (`""`) and every reconcile
- that compares by equality are unchanged.
-2. **`moveTargets`** maps `move.to = ""` to the edges file marked home.
- **`runMoveRound`** looks up `targets[""]` through that map.
-3. **The target's role** (§7.3 and §7.5) matches `move.to == ""` on the box
- marked home.
-4. **Home children** get `CARLOS_REGION = CARLOS_HOME_PLACEMENT`, so an app
- sees one variable in every region. This is a behaviour change for every
- app at home, so it ships behind `CARLOS_HOME_REGION_ENV=on` on the home
- box, and the roll notes say what it changes.
-5. **The door** (`apiinstancemove.go:203-209`) accepts home once some edges
- file is marked home with staging coordinates. Until then its refusal
- says a move to home is not available yet.
-6. **Infra:** every sibling's `sibling_staging_arns` lists the home data
- bucket. That is production IAM and needs scoped approval per deployment.
-7. **Under #199**, a move to home is an ordinary v2 move: a new generation
- staged in the home bucket and pointed at by the flip. A boomerang
- (ie→uk→ie) lands in a fresh `g<n>-<move id>` and never touches the
- generation the instance left behind in ie, so the 09-24 record's
- staging-wins reasoning for this case is no longer needed. Until that
- earlier generation's hold ends, the home bucket holds both; P5 deletes
- the old one on schedule.
-
-**Rollback and P3 are separate.** Automatic rollback (§7.6) restores the
-source generation where it already is, in the source's own bucket, and
-works today for a move out of home. P3 is needed only to move **into**
-home deliberately: `carlos move back` after a successful move away, or an
-app move whose target is home.
-
-## 10. After a move: the hold, then residency cleanup (P5)
-
-On `done`, the target writes a cleanup task into the move log: "delete the
-source's copy of `<host>` in `<from>` after `<done + hold>`." The hold is 7
-days by default (`CARLOS_MOVE_HOLD`, §23 Q4).
-
-**During the hold** the source keeps:
-
-- the source generation (`from_db`'s location, or `instances/<host>` for
- a record that was legacy), and the source-keyed receipts and files
- manifest beside it (S4);
-- the instance files directory and `<host>.env` (box disk);
-- its quarantine file, if any (S19);
-- its config ack object (S9);
-- for an app move, the old store, write-fenced (§11).
-
-What the hold buys, stated without overselling it. After `done`, the target
-has taken user writes. A **move back** (`carlos move back`, §16) is the
-supported way to return, and it carries the target's data. The kept source
-copy is last-resort evidence for an operator if the target region loses
-data. It is never restored automatically (R4; the 09-24 record's M2).
-
-**After the hold** the source box deletes all of it and logs the count of
-objects and files removed. The 09-24 record's §6.7 fire-time re-check
-applies, and #199 makes it much simpler. A v2 deletion is keyed to one
-storage key, `<Root>/<host>/<incarnation>/<from_gen>/`, so it runs only if,
-at that moment, the record's `DB` pointer does not name that generation
-(and, for a legacy source, the record has a `DB` pointer at all). A
-boomerang can no longer make the deleted prefix live again, because a
-return lands in a new generation. The files directory is deleted only if
-its `.files-gen` names the source generation's era (it was not replaced by
-a later landing on this box) and this box owns no route for the host. Any
-failure cancels the deletion, with a log line.
-
-**#199's generation retention.** #199 keeps the newest five generations and
-deletes older ones after a push commits (§5.4 there). After a move, the
-earlier generations live in the source region's bucket, which the target
-box cannot list or delete. So:
-
-- the retention GC on a box considers only generations in its own bucket;
-- generations a move left in another region are P5's to delete, on P5's
- schedule, never the GC's;
-- `carlos db rollback --to <gen>` refuses a generation that lives in
- another region (#199 R3: push and rollback are in-region only), and
- `carlos db backups` lists such a generation as "in <region>, kept until
- <date>, not restorable here".
-
-**#171 guard.** A box role can forge records (§14), so a forged record must
-not be able to schedule a deletion. A cleanup task is honoured only with a
-**console-signed warrant**: the console signs `(move id, host, from, not
-before)` with its release key, which boxes already verify with
-`CARLOS_RELEASE_PUBKEY` and do not hold (`cmd/console/main.go:236-250`).
-The console signs only for a move in its own ledger whose log shows the
-target's commit.
-
-`carlos move status` and the console show "Ireland copy kept until 9
-October 2026" during the hold and "Ireland copy deleted on …" after it.
-
-## 11. Stores follow their owner (M3, P8)
-
-The rule that composes the two kinds of move:
-
-- **An app-level store follows the app.** It moves in an app move, after
- the app's instances (§12). An instance move leaves it where it is, and
- the moved instance reads it across regions (T4 proves it can).
-- **A private store (#412) follows its instance.** It moves inside that
- instance's move.
-
-That is M3, and it is consistent with "moving one instance does not change
-the app's region".
-
-**Prerequisite: a store backend per placement.** `CARLOS_STORE_REGION` is
-read once, console-wide (`cmd/console/main.go:550-560`). Each armed box
-publishes its store region and bucket prefix in its edges file, and the
-provisioner creates a twin in the target's store region. This is the 09-24
-record's §7.1, unchanged.
-
-**The copy, per store:**
-
-1. **Provision a twin** in the target store region: same entitlement, new
- bucket, new credentials (`internal/objstore/provisioner.go`).
-2. **Pre-copy** in delta rounds while everything serves, with copy receipts
- as the replica mirror uses. The copier is the **move-scoped IAM user**
- ruled in Q6: Get and List on the one source bucket, Put, Delete and
- List on the one twin, an `aws:CurrentTime` condition that ends its
- authority 24 hours after minting, and a reaper for abandoned users. The
- console mints it only for a move in its own ledger (§14).
-3. **Write fence (the 09-24 record's M1).** A bucket-policy `Deny` on
- `s3:PutObject` and `s3:DeleteObject` for every principal except the
- copier. A policy is evaluated per request, so it also stops presigned
- uploads already handed out. It is the only fence that covers every
- credential holder.
-4. **Final delta**, then **rebind**: `appconfig.ApplyEnvironment` rewrites
- the bundle's store coordinates to the twin (default bundle for an app
- store, the instance's environment for a private store). Config sync
- bounces running instances, and hibernating ones read it on wake.
-5. **Lift nothing on the old bucket.** It stays write-fenced, read-only,
- until the hold ends, then P5 deletes it.
-
-**Where the downtime falls.** For a private store, steps 3 and 4 run inside
-the instance's own cut, between §7.4 steps 6 and 7. The instance is already
-down, so the fence costs nothing extra. For an app store, every instance of
-the app writes it, so the final delta needs all of them held. The app move
-holds the whole app at 503 for the store cut (§23 Q6), measured and shown
-to the owner before they confirm.
-
-**Rollback.** Before the rebind, a failed store step lifts the fence and
-leaves the binding alone. After the rebind, the store moves forward only.
-
-## 12. The app move
-
-### 12.1 Shape
-
-`carlos move --app ips --to uk`, or the app's Region section in the
-console. The console runs it as a **sequencer**: a console loop over a new
-`app_moves` table in the console DB. The console owns that DB, so the
-sequencer's state survives restarts, and every step is idempotent.
-
-1. **Validate.** The target is valid, armed, hosting and staging-capable
- (`apiinstancemove.go:227-235`); eligible for the app (nearest-region
- §6.1); allowed by the manifest's `regions` (`apiinstances.go:1797`); and
- not the app's current region. The app is not fleet-placed (the
- placement column governs its instances' regions, nearest-region §12)
- and not pooled. The nearest-region switch is effectively on
- (`CONSOLE_APP_REGIONS=on` and a named home, nearest-region §5.6),
- because otherwise the app region cannot hold a non-home value.
-2. **Plan.** List the app's records. The instances that move are those
- whose `region` equals the app's current region and that pass every
- per-instance rail (§13). Everything else is listed with why it stays.
- The owner sees the plan before confirming.
-3. **Change the app region** (`apps.region = to`, `region_source = moved`,
- a new label beside nearest-region §5.1's values). From this moment, new
- instances created without a region start in the target (the fill,
- nearest-region §7). That is what "the app region changes, then every
- instance moves" means in order.
-4. **Move instances one at a time**, each a full instance move with
- `cut: auto`. One at a time bounds the target's load and keeps every cut
- window separate.
-5. **Move the app store** (§11), if it has one.
-6. **Done.** The app move's log is the list of its instance moves.
-
-### 12.2 Partial failure
-
-- **An instance rolls back.** That instance is back in the old region and
- serving. The sequencer **pauses** the app move (`paused`, with the
- reason). The app region stays at the target, so new instances keep going
- there. The owner chooses: `carlos move resume` (retry the failed one),
- `carlos move skip --host <h>` (leave it in the old region and continue),
- or `carlos move --app ips --to <old>` (move the app back). §23 Q5.
-- **An instance goes `stuck`.** The app move pauses and pages, as for
- `stuck` in §7.6.
-- **The store cut fails before the rebind.** The fence lifts, the store
- stays where it was, and the app move pauses at "store not moved".
-- **Abort** (`carlos move abort --app ips`) aborts the in-flight instance
- move if it is still abortable, and stops the sequencer. Instances already
- moved stay moved. The app region stays at the target. Abort is not undo.
-- **The console restarts mid-move.** The sequencer re-reads `app_moves` and
- the in-flight instance's record, and continues.
-
-### 12.3 Composition with the nearest-region spec
-
-- That spec's R5 says changing an existing app's region is a follow-up,
- "an owner-only verb that affects future creates only". The app move is
- that verb. An app with nothing to move (no instances, or all of them
- elsewhere) is a region change that only affects future creates.
-- An app move writes a non-home `apps.region`, so nearest-region §5.7's
- rollback floor applies to it unchanged.
-- **An instance move never touches `apps.region`** (nearest-region §12,
- "Moves").
-- Explicit per-instance placements survive an app move: an instance whose
- region differs from the app's is not moved, and the plan says so.
-
-## 13. What does not move, and why
-
-Each refusal is checked at the door (fast answer) and again by the box
-(records have other writers).
-
-| shape | answer | why |
-|---|---|---|
-| pooled record (`Pool != ""`) | refused (`apiinstancemove.go:65-68`) | its data lives on the pool's box, not a region box |
-| fleet-placed record or app | refused (`:61-64`; nearest-region §12) | fleets keep state-does-not-follow on purpose |
-| non-hibernating | refused (`:69-72`) | runs outside the activator; nothing can quiesce it |
-| addr-backed, unit-backed, static, status kinds | refused (`:53-60`) | no platform-owned process or data. Static routes are served from the deployment bucket by every edge, so an app move leaves them and has nothing to carry |
-| replica record | refused (`:187-190`, `provision.go:978-980`) | the replica role is placed, not moved (G14) |
-| **home with replicas** | **refused (new)** | Q2. Neither the door nor `MoveRequest` checks `Replicas` today, only `Role`. Phase 1 adds the rail |
-| native sidecar (kind `sidecar`) | refused (`:53-56`) | sidecars carry their own replicated DB (sidecar-members design §4) and need their own move. An app move with sidecars in the source region is refused by default (§23 Q7) |
-| legacy `<host>-sidecar.env` | **refused (new)** | hand-staged on one box; a move would silently drop the sidecar |
-| target not in the manifest's `regions` | **refused (new)** | the app said where it may run. This is also how an operator pins an app that depends on out-of-band host-role grants (S24): set `regions` to its one region |
-| target fails a readiness check (T1 to T8) | cut refused with the check's sentence | §7.3 |
-| instance files over the cap, or containing a special file | refused with the size or the name | §8.1 |
-| another move in flight for the host, or an app move for its app | refused (`provision.go:968-970`) | one at a time |
-
-## 14. Who may move, and the #171 residue
-
-- **Owners** of the account (`RoleOwner`), from a session or a person's CLI
- token. The doors return the family's byte-identical 404 to anyone else,
- as `handleAppMoveAccount` does for its owner check
- (`internal/console/appsettings.go:1258-1261`). Members are not owners.
- Today's move doors admit any member, so this narrows them.
-- **Instance-role service credentials** keep request, cut, abort and status
- on instance moves (`internal/console/authcli.go:625-628`), unchanged:
- Tito's router uses them. They cannot start an app move.
-- **Operators** (`Store.IsOperator`, `internal/console/store.go:1858`) can do
- any of it for any account through an operator route family,
- `/api/cli/ops/accounts/{sqid}/apps/{app}/…`. Non-operators get 404 there.
- Every operator act is written to the target account's audit trail.
-
-**The #171 residue.** Any region box's role can write any record (#171,
-open). A forged `move.to` can already move an instance today, whatever the
-door allows, so M1 does not widen that. What a forged record must not be
-able to do is drive the two privileged acts this spec adds. So the console
-keeps a **move ledger** (console DB: move id, account, app, host, from,
-to, requested by, app move id, outcome) and:
-
-- mints a store-copy credential (§11) only for a ledger move;
-- signs a P5 deletion warrant (§10) only for a ledger move whose log shows
- the target's commit.
-
-A record whose `move.id` is not in the ledger is shown in the console as
-"not started from this console", and nothing privileged follows from it.
-
-## 15. API
-
-All additive. Old clients see today's answers.
-
-| door | change |
-|---|---|
-| `POST …/instances/{host}/move` | body gains `"cut": "auto"`; response gains `move_id`, `from`, and `not_moved` (for example `["app store stays in Ireland with the app"]`). Owners only for people (§14) |
-| `POST …/instances/{host}/move/cut` | refused (409) unless the newest target report passes; the body names the failing check |
-| `DELETE …/instances/{host}/move` | legal until `cut_at`, then 409 "the cut has started; it completes or rolls back on its own" |
-| `GET …/instances/{host}/move` | unchanged by default (titogo depends on it, 09-24 §6.2). `?history=1` adds the newest terminal outcome for 30 days, the target report, timings and `reason` |
-| `POST …/apps/{app}/move` (new) | `{"region":"uk","dry_run":true}` returns the plan; without `dry_run` it starts the app move. People only, owners only |
-| `GET …/apps/{app}/move` (new) | sequencer state, plan, per-instance outcomes |
-| `POST …/apps/{app}/move/resume`, `…/skip`, `DELETE …/apps/{app}/move` (new) | §12.2 |
-| `POST …/instances/{host}/move/resolve` (new) | operator only; §7.6 `stuck` |
-| `/api/cli/ops/accounts/{sqid}/…` (new) | operator mirrors of the above |
-
-## 16. The CLI (G8)
-
-One top-level verb, `carlos move`. It is free in `main.go`'s dispatch, and
-`docs/site/cli.md` gains its heading in the same change (`internal/docsite`
-fails the gate otherwise).
+Sealed for the deployment's config keypair, the one config bundles use
+(P-256 ECDH, `internal/appconfig/crypto.go:44-59`):
+
+- a fresh 256-bit data key; AES-256-GCM over 1 MiB chunks, each nonce the
+ chunk index, the last chunk flagged so truncation is detected;
+- the data key sealed with `appconfig.Seal` (`crypto.go:86`), which uses a
+ fresh ephemeral key and nonce;
+- a manifest: the sealed data key, the sealed object's sha256, the
+ plaintext's sha256, the per-file list (path, size, sha256), the move id
+ and the incarnation.
+
+Sealing is randomized on purpose. Encryption is never made deterministic by
+reusing a key or nonce.
+
+### 8.3 Identity, publication, retention
+
+- **Two identities.** `plain` (the plaintext digest) says what the files
+ are. `sealed` (the ciphertext digest) names one immutable object. Checks
+ of content (T5, rollback, finalization) use `plain`. Fetches use
+ `sealed`.
+- **One sealed object per cut.** In `archived` the source writes the sealed
+ archive and manifest **create-only** to its own bucket at
+ `move-snapshots/<host>/<move id>/files-<sealed>.{sealed,json}`, then
+ records both digests in the record, then copies them to the target's
+ staging. A crash before the record write leaves an unreferenced object
+ and the next attempt builds a new one; a crash after it reuses the
+ recorded one and never rebuilds. So a replay never changes
+ `source.files.sealed` (codex 19).
+- **Retention.** The source's snapshots (archive, manifest, timers) stay in
+ `move-snapshots/<host>/<move id>/` until F3's cleanup, because a rollback
+ and an audit both need them. The target's staged copies are parked with
+ staging at finalization.
+
+## 9. Changes to existing code (codex 13, 15)
+
+### 9.1 Config render for a held row
+
+`SyncExec` skips any row that is not `ready` (`execRouteBase`,
+`internal/appconfig/syncexec.go:643-647`), and writes an ack only when a
+file changes (`syncexec.go:1159`). A held row needs neither behaviour. New:
+`appconfig.RenderHeld(ctx, rt, op)` renders exactly the file `SyncExec`
+would, writes it, and writes a **move-bound ack**
+`control/config-ack/<box>/<host>.json` with `op`, the file's sha256 and the
+bundle etags, whether or not the file changed. The landing reads that ack.
+`SyncExec` continues to skip held rows, so the two never race on one file;
+once the row opens, `SyncExec` owns it again.
+
+### 9.2 Release materialization for a held row
+
+`release.Adoptable` excludes non-`ready` rows (`internal/release/adopt.go:816-819`).
+New: `release.MaterializeHeld(rt, op)` stages the channel head for a held
+row without adopting traffic, used by T7 and before the probe.
+
+### 9.3 The activator
+
+- `ProbeWake` (§7.7).
+- `RetireExcept` keeps held entries (§7.7).
+- The start check (§5.1) in `Start`, `Seed` and `ProbeWake`.
+- Restore writes `<dbPath>.gen` and parks a warm file of another
+ generation (§5.1). `promoteStagedReplica` and its call in `Restore`
+ (`backend_exec.go:290`) are deleted.
+- `FenceAndStop` returns evidence the worker can publish.
+
+### 9.4 Timers frozen and restored
+
+- **Frozen:** the runner skips, with no bookkeeping at all, any row whose
+ status is a hold. Today a blocked delivery still advances `attempts` and
+ state (`internal/schedule/runner.go:345-352`).
+- **Not pruned:** a held row is a route, so the converger's prune
+ (`internal/schedule/converge.go:114`) leaves its timers alone. The source
+ row is held through the whole move (§7.5).
+- **Replaced exactly:** new `Registry.ReplaceTimers(host, rows)` deletes the
+ host's rows and inserts the snapshot in one transaction, preserving
+ `next_at`, `due_at`, `attempts` and `registration`. `UpsertTimer` resets
+ `attempts` and `registration` when it rearms
+ (`internal/registry/timers.go:46-55`), so it is not used for this. The
+ target replaces at install; the source replaces at a dirty rollback.
+- **Declared schedules** then converge as today; an imported row whose spec
+ and path match is kept (`converge.go:223-241`).
+
+### 9.5 Edge
+
+None in Phase 1. A held row is not served (`edge.go:595`). Platform-domain
+hosts get the 503 pending page (#174, `internal/edge/pending.go`); custom
+domains get 404 during the window, until F1's holding route.
+
+### 9.6 Reconcile
+
+- Mint (`routeFor`, `reconcile.go:364-376`): a record in `landing` mints
+ `landing` with `hold_op = move id` on the target. A record in
+ `rolling_back` or `stuck` never mints `ready` (it mints `restoring` on the
+ source if the row was lost).
+- Removal (`reconcile.go:1114-1159`): a row whose host has a record with a
+ `move` naming this box's placement as `from` and not yet at
+ `source_parked_at` is not removed. The source's move worker deletes it at
+ finalization.
+- Deferral: #199 §3.5's tombstone deferral applies to landing mints.
+
+## 10. The secrets key, SSM, and the shared fingerprint
+
+The platform reads no SSM parameter at runtime. An exec instance's secrets
+reach it through its config bundle in the home deployment bucket, opened
+with the box's `CARLOS_SECRETS_KEY`. So a target needs the deployment's key,
+and nothing per app or per instance needs replicating between SSM regions.
+SSM matters only for staging that key on each box, which is Paul's approved
+remediation.
+
+**Shared with nearest-region R9.** Both specs use one edges-file field, so
+the region-default spec can adopt it as written:
+
+- **name:** `secrets_key_fp`, in `edgesFile`
+ (`internal/host/edgesfile.go:155-176`, written at `:603-605`);
+- **format:** `"sha256:"` followed by 64 lowercase hex digits of SHA-256
+ over the public key's bytes, `(*ecdh.PublicKey).Bytes()` (the uncompressed
+ P-256 point) of the box's `CARLOS_SECRETS_KEY`;
+- **not omitempty**, following `hibernation_configured`: absent means the
+ writer predates the field (unknown); `""` means the box has no key;
+- **the console** computes the same over its `CARLOS_SECRETS_PUBKEY`.
+
+A box is key-ready when its value equals the console's. Unknown is never
+ready.
+
+## 11. Versions and the protocol floor (codex 7)
+
+The new states, fields and holds are meaningless to an older binary, and an
+older target would mint a `ready` row for a landing record
+(`reconcile.go:374`). So:
+
+- Every box publishes `moves_protocol: 1` in its edges file; the console
+ reports its own. The request door, and T1, refuse unless every box in the
+ source and target regions and the console are at or above it.
+- Once any Phase 1 move has started, the deployment writes
+ `control/moves-protocol.json` `{"min": 1, "min_release": "vX.Y.Z"}`
+ create-only. `carlos system rollback` and `carlos system apply` (run by
+ the newer binary) refuse to install a release below `min_release`, and
+ refuse any downgrade on a box with an in-flight move it is part of. The
+ console roll recipe states the same floor.
+- The simulation (§14.2) models a target downgraded to a pre-floor binary
+ at every state and asserts the floor stops it.
+
+## 12. The operator surface
+
+**API.** The existing doors (`internal/console/authcli.go:625-628`) with the
+§7.1 body, the cut door's freshness check, and abort per §7.11. In Phase 1
+every move mutation requires an operator (`Store.IsOperator`,
+`internal/console/store.go:1858`); a non-operator gets the family's 404.
+Instance-role service credentials keep the status GET only. This closes the
+member-minted token path (`apiinstances.go:1554`) for moves until F1
+designs owner delegation (codex 11). New: `POST …/move/resolve`
+(§7.10). `GET …/move` is unchanged by default; `?detail=1` adds the phases,
+reports, digests and reasons.
+
+**CLI.** One top-level verb; `docs/site/cli.md` gets its heading in the same
+change (`internal/docsite` fails the gate otherwise).
```
-carlos move --app ips --to uk # app move: shows the plan, asks, then runs
-carlos move --host kpmg.bdn.oncarlos.com --to uk # instance move
-carlos move back --host kpmg.bdn.oncarlos.com # instance move to the region it came from
-carlos move status [--app ips | --host <h>]
-carlos move abort [--app ips | --host <h>]
-carlos move resume --app ips
-carlos move skip --app ips --host <h>
-carlos move resolve --host <h> # operators; a stuck move
+carlos move --host kpmg.bdn.oncarlos.com --to uk [--manual-cut] [--no-wait]
+carlos move status --host <h>
+carlos move cut --host <h>
+carlos move abort --host <h>
+carlos move resolve --host <h> --retry | --declare-lost <box>
```
-- Region names are codes or the home's name (`homeRegionIn`,
- `apiinstancemove.go:136-141`). Output uses place names (nearest-region
- §14).
-- `--wait` is the default for a terminal: it prints each transition with
- its time and exits 0 on `done`, 3 on `rolled_back`, 4 on `stuck`, 5 on
- refusal. `--no-wait` returns after the request.
-- `--manual-cut` stops at `ready`. `carlos move cut --host <h>` cuts.
- Without it, the CLI asks for `cut: auto`.
-- `--yes` skips the app move's confirmation, for scripts.
-- Every fact it prints comes from the console (the 2026-08-08 rule). No
- step needs box access.
-
-## 17. The console
-
-**Instance page** (`internal/console/templates/app_instance.html`) gains a
-**Region** section above Danger: the region's place name, "Move to another
-region" with a region select (eligible targets only, each with a reason
-when greyed out), and a confirmation page (M-C10 to M-C13) that says what
-moves, what stays, and the expected 503 window.
-
-**App settings** (`templates/app_settings.html`) gains a **Region** section
-beside the existing Account section. Its button says "Change region", never
-"Move", because the Account section's "Move to another account"
-(`server.go:1209`) already uses that word.
-
-**During a move**, both pages show a progress list (M-C20 to M-C27):
-Copying data (with bytes copied), Checking UK (each check, green or the
-failing sentence), Stopping in Ireland, Starting in UK, Done. Each step has
-its time. The Instances table (`app_instances.html:86-91`) shows
-"Moving to UK" in the Region column. The page polls `GET …/move?history=1`.
-
-**After a move:** "Moved from Ireland to UK on 2 October 2026. 48 seconds
-offline." and the hold line from §10. After a rollback: the reason and
-"Back in Ireland and serving. Nothing was lost." After `stuck`: an
-incident banner for the owner, and the `resolve` button for an operator.
-
-## 18. Observability (P4)
-
-- **Move log.** One object per event under
- `moves/<acct>/<app>/<host>/<move-id>/` in the deployment bucket, named
- `<unix-nanos>-<actor>.json`. One object per event means three writers
- (console, source, target) never contend. The target report (§7.3) lives
- beside them. Events: requested, round (sampled one in ten, plus the
- last), ready, cut-refused, cut stages, flipped, each landing step,
- committed, opened, rollback (with the failing step), rolled_back, stuck,
- cleanup scheduled, cleanup done.
-- **Timings** in the terminal event: prepare, cut (from `cut_at` to the
- flip), land (flip to open), and **offline**, from `cut_at` to open (or to
- the source's `ready` after a rollback). Offline is the number owners
- care about.
-- **Paging:** `rolled_back` is a warning; `stuck` and `landing (overdue)`
- are incidents; `preparing` with no promotable round for 60 minutes is a
- warning (finding 1's shape).
-- **Box logs** keep the `moves: <host>: …` shape (`movespass.go`) and add
- one line per step with its duration.
-- **Member log stream:** each transition is an event on the app's platform
- stream (`emitEvent`, `internal/activator/logsink.go:31-36`), so `carlos
- logs` shows the move beside the app's own output.
-
-## 19. Failure modes
-
-| failure | detected by | outcome |
+`--wait` is the default: it prints each phase with its time, and exits 0
+on done, 3 on rolled back, 4 on stuck or waiting, 5 on refusal.
+
+## 13. Observability
+
+- **Move log:** one object per event under
+ `moves/<acct>/<app>/<host>/<move id>/` in the deployment bucket
+ (`<unix-nanos>-<actor>.json`), beside the reports and evidence. Every
+ phase entry and exit, every check result, every reason.
+- **Timings:** prepare, cut (`cut_at` to `flipped_at`), land (`flipped_at`
+ to `opened_at`), and **offline** (`cut_at` to `opened_at`, or to the
+ source's reopen after a rollback).
+- **Paging:** `rolled_back` (warning); `stuck`, `rolling_back` past
+ `release_deadline`, and any start refused by the start check (incident);
+ `preparing` with no promotable round for 60 minutes (warning).
+- **Member stream:** each transition is an event on the app's platform log
+ (`emitEvent`, `internal/activator/logsink.go:31-36`).
+
+## 14. Tests
+
+### 14.1 Unit and integration
+
+- Every transition and its guard, including each illegal edge and a stale
+ move id or incarnation.
+- Archive: equal plaintext for equal content; randomized sealed bytes;
+ truncation and reordering detected; wrong key refused; special files
+ refused; cap enforced mid-stream.
+- Each readiness check failing alone, against fakes, and T4 against MinIO.
+- Each landing phase crashing and replaying; `probing` never replaying an
+ earlier phase.
+- Held render, held materialization, `RetireExcept` keeping a held entry,
+ the runner's freeze, `ReplaceTimers` exactness.
+- The start check: mismatched markers refuse; absent markers are origin.
+- Reconcile: landing and restoring mints; source row not removed mid-move.
+
+### 14.2 The randomized crash simulation
+
+A Go test driving the **real** transition functions and the real worker
+phase functions through injected interfaces, never a re-implementation.
+Actors: console, source agent, target agent, deadline sweep, and **the
+application process** on whichever box runs it.
+
+The model includes what rev 1's model missed (codex findings, "simulation"
+note):
+
+- the app's **own background work**: it writes the database and an
+ external ledger on its own schedule while running, including during a
+ probe, and an external acknowledgement counts as acknowledged (INV-2);
+- **stalled and surviving processes**: a worker can stall indefinitely
+ while its child keeps running; an agent crash kills flat children and
+ leaves nested ones running;
+- **scheduler bookkeeping**: timers fire, advance and are refused;
+- **config, adoption and retirement**: held render, `RetireExcept` on edge
+ reloads, reconcile mints and removals;
+- **versioned buckets**: deletes leave noncurrent versions;
+- **forged metadata**: another box writes a fake evidence or report object
+ with a wrong move id or incarnation;
+- **tombstones**: delete and recreate at any step;
+- **downgrades**: a box replaced by a pre-floor binary at any step.
+
+A seeded scheduler picks the next atomic step, injects crashes (losing
+memory, keeping disk and buckets), stalls, and the faults of §7. After
+every step it asserts INV-1 and INV-5; at the end of every run INV-2,
+INV-3 (every run with only restarting crashes terminates), INV-4 (every
+start's markers matched), INV-7, INV-8 and INV-9.
+
+2,000 seeds in the gate; 200,000 nightly.
+
+**Mutation checks**, run in a tree copy, each of which must fail the
+simulation within the gate's seeds: start the source on a deadline without
+evidence; open before commit; open without the `hold_op` condition; treat a
+lost CAS as stop evidence; discard target writes on rollback; replay
+install after `probing`; re-extract files after `probing`; skip the start
+check; let the runner advance a held row's timers; restore from staging;
+re-seal the archive on replay; let reconcile remove the source row mid-move;
+let a tombstone park sweep another incarnation's staging; skip the protocol
+floor. A mutation the simulation cannot catch is a defect in the
+simulation.
+
+### 14.3 Cross-version
+
+The request door refuses below the floor; a pre-floor target cannot be
+installed during a move; abort on every new state is refused by v0.22
+consoles (P0's allowlist, `provision.go:1027-1035`).
+
+### 14.4 Rehearsal (M12)
+
+`moveprobe`, a test app in this repo, is shaped like ips:
+
+- writes `instance.key` into its files directory at first boot, seals a
+ row with it, and refuses to start on a mismatch;
+- requires a store binding and a sealed secret at boot, and writes and
+ reads an object every boot;
+- declares a schedule and registers a once-timer, logging each run;
+- writes a row from a background loop every few seconds (INV-2's case);
+- fails its readiness path in a named region on request, before or after
+ writing (to force clean and dirty rollbacks).
+
+Runs, each recording every timing: dev, IE to uk, done; dev, forced clean
+rollback; dev, forced dirty rollback; then the same on the flagship with
+scoped approval; then a disposable ips instance in `bdn` with scoped
+approval; then kpmg with Paul's approval. A run passes only if the key
+verifies, the object reads back, the secret is present, every timer ran
+exactly once around the cut, and no background row is missing.
+
+## 15. Rollout and gates
+
+1. **Ops (in progress):** `CARLOS_SECRETS_KEY` on every region box.
+2. **Release with `secrets_key_fp`** (shared with nearest-region R9).
+ Verify every box's value equals the console's.
+3. **Phase 1 release, dormant.** Consoles first, then every box. The door
+ refuses until every box in both regions publishes `moves_protocol: 1`.
+4. **Infra, scoped approval:** each target region's `sibling_staging_arns`
+ lists each source region's data bucket (T10's return lane). For kpmg
+ that is uk listing ie's bucket.
+5. **Rehearsals** in §14.4's order. Each gates the next.
+6. **Write `control/moves-protocol.json`** before the first production
+ move.
+
+## 16. Follow-on specs
+
+Each is its own spec. No design here, only what it must satisfy.
+
+| spec | scope | must meet |
|---|---|---|
-| target lacks the secrets key | T2 | cut refused; source never stops |
-| bundle cannot be opened on target | T3 | cut refused |
-| store unreachable from target | T4, L4 | cut refused, or rollback |
-| target disk too full | T6 | cut refused |
-| target has nothing promoted for its architecture | T7 | cut refused |
-| old target build | T1 (capability in edges file) | door refuses the request |
-| files archive damaged or missing at landing | L1 | rollback |
-| app starts, crashes after binding | L5 settle window | rollback |
-| app needs more than 30 s to start | L5 | rollback. The bar is the same one every wake meets, so the app would fail ordinary wakes too |
-| target box dies during landing | source, at `landing_deadline` | rollback |
-| source dies after the flip, landing succeeds | none needed | done; P5 waits for the box, or for an operator if the box is gone |
-| source dies before rollback finishes | sweep at deadline + 5 min | `landing (overdue)` incident; `resolve` once the box is back |
-| both fail | §7.6 | `stuck`; incident; data intact in the source prefix |
-| staging delete denied on target | cleanup | v2: unreferenced garbage, logged; legacy pre-epoch: logged as a hazard (§7.9) |
-| a stale generation or files directory from an earlier stay on the target | landing | never read: the pointer names the move generation, and L2 moves the old directory aside |
-| a db push requested during a move, or a move during a push | door and box | refused both ways (§26, I-7) |
-| a forged record requests a move | ledger mismatch | no store credential, no deletion warrant; the console flags it |
-| app writes outside its files dir | not detectable | documented loss (§8.4) |
-| app does boot-time side effects | not preventable | they may run twice on a rollback; `CARLOS_MOVE_PROBE=1` lets the app skip them |
-
-## 20. Tests
-
-### 20.1 Unit and integration
-
-- `MoveState` transitions, including the new states, every illegal edge
- (409 matrix), and old-record parsing.
-- Archive: deterministic bytes for one input; special files refused;
- truncation and chunk-reorder detected; wrong key refused; per-file hashes
- checked on extraction; flat-to-nested and nested-to-flat extraction.
-- Target report: each of T1 to T8, failing and passing, against fakes and
- against MinIO for T4.
-- Landing: L1 to L7 each failing in turn leads to rollback, never to open.
- An L6 lost to a rollback never opens.
-- Reconcile mints `landing` for a landing record, and `ready` otherwise.
-- The source's `rolling_back` arm with the `moving` row present and with it
- retired.
-- Timers carried: a once-timer due during the cut fires once after open; a
- declared schedule keeps its `next_at`.
-- Door: owner-only, the operator family, home with replicas, `regions`
- allow-list, legacy sidecar env.
-
-### 20.2 The state-machine simulation
-
-A Go test that models the console, the source box, the target box, the
-sequencer, the record store (with CAS), the source and target buckets, a
-client issuing writes through whichever edge serves, and a clock. Each step
-of each actor is an atomic action. A seeded scheduler picks the next
-action, and can crash any actor (losing its memory, keeping its disk and
-buckets) or delay it. Faults are injected at the same points as §19: each
-landing check failing, box death, deadline expiry, denied deletes.
-
-After every action it asserts INV-1, INV-2, INV-4, INV-4b and INV-6. At
-the end of every run it asserts INV-3 (every acknowledged client write is
-in the authoritative copy), INV-5 (every start saw the authoritative files
-version), INV-7 (terminal within the bound) and INV-8.
-
-The model carries #199's storage: generations keyed by incarnation, the
-record's `DB` pointer, `.lineage` and the two directory markers, and the
-epoch, which the scheduler can write at any moment, so legacy, first-v2
-and v2 moves all occur. Its runs include moves interleaved with a
-`carlos db push` request, and boomerangs (ie→uk→ie, including with an
-earlier move's generation and files directory still in place).
-
-- 2,000 seeds in the ordinary gate; 200,000 in a nightly job.
-- **Mutation checks**, run in a copy of the tree: open before commit; skip
- the cut's report re-check; flip `region` and `DB` in two writes; let the
- target restore from a generation the pointer does not name; let rollback
- leave `DB` at the move generation; let a landing reuse an existing files
- directory; extract files after the probe; let the source purge the files
- directory at the cut. Each mutation must
- make the simulation fail within the gate's seed count. A mutation it
- cannot catch is a defect in the simulation.
-- The simulation drives the real transition functions
- (`provision.Move*`) and the real worker step functions through injected
- interfaces, not a re-implementation of them. A fake that agrees with
- itself proves nothing.
-
-### 20.3 Cross-version
-
-An old target (no capability) is refused at the door. A v0.22 console
-refuses abort on every new state (P0's allowlist). A new console against
-old boxes refuses every move.
-
-### 20.4 Rehearsal with an ips-shaped app
-
-The `bdf/hello` rehearsal passed because hello has none of the state that
-failed. The rehearsal app, `moveprobe` (a test app in this repo), has every
-piece of it:
-
-- at first boot it writes `instance.key` into its files directory and seals
- a row with it; every boot verifies the seal and **refuses to start** on a
- mismatch, so a missing or regenerated key fails the health check;
-- a store binding it requires at boot (refuses to start without it), and an
- object it writes and reads back on every boot;
-- a sealed config secret it requires;
-- a declared schedule and a once-timer, each logging its runs to the DB;
-- a config switch, `MOVEPROBE_FAIL_IN=<placement>`, that makes it fail
- health in one region, to force a rollback.
-
-Rehearsals, each with timings recorded in the move log:
-
-1. dev deployment: moveprobe IE to uk, done; then forced failure, rolled
- back; then uk to home after Phase 3.
-2. flagship, with scoped approval: a disposable moveprobe instance under the
- operator's account, the same three runs.
-3. flagship, with scoped approval: a disposable ips instance in `bdn`,
- seeded through the app's own seed, with a key and a store. This is the
- shape that failed. (§23 Q9.)
-4. Only then kpmg, at a time Paul chooses.
-
-Each run passes only if the key verifies, the object reads back, the
-secret is present, every timer ran exactly once around the cut, and the
-offline time is recorded.
-
-## 21. Rollout and gates
-
-0. **#199's release N first** (its §7): every box and console understands
- `DB`, `StorageKey`, `.lineage` and the epoch. Phase 1's code reads and
- writes storage only through `StorageKey` (§26, I-2), so it is built on
- top of N, not beside it. Its own rollback floor (#199 §7 step 4) then
- covers moved records too: a binary older than N would drop a moved
- record's `DB` pointer and send it back to its first generation.
-1. **Consoles first.** They learn the new states, the cut door's report
- check, owner-only doors, and the ledger. Moves keep working as today
- against old boxes, because the door refuses any target that does not
- publish `moves_schema >= 2`. In practice that refuses every move until
- step 2.
-2. **Boxes, every region.** The source and the target roles ship together.
- A box publishes `moves_schema: 2` only when it runs the code. Mixed
- fleets are safe: the door checks both boxes.
-3. **Infra check, per deployment:** every region box publishes a
- `secrets_key_fp` equal to the console's. Where not, stage the key (the
- SSM note in §6). This is production configuration and needs scoped
- approval.
-4. **Rehearsals** (§20.4) in order. Each one gates the next.
-5. **Owner self-serve** turns on with `CONSOLE_MOVES_SELF_SERVE=on`, and
- only after Phase 3 (P5 deletion) is live, so a move that promises
- residency delivers it. Until then the doors serve operators and
- instance-role services only.
-6. **App moves** turn on after Phase 4 and its rehearsal: an app with two
- moveprobe instances and an app store.
-
-## 22. Phases
-
-Each phase lands dormant and is useful alone.
-
-| phase | contents | gate to start | done when |
+| **F1 Owner surface** | owner self-serve from the console and `carlos move`; auto-cut for persons (M6); holding route for custom domains (P2); move history | owners only (M13); **move-capable service authority is explicitly owner-delegated**, including the legacy mint path (`apiinstances.go:1554`), with a stated treatment of existing member-minted tokens that keeps approved router credentials working (codex 11) |
+| **F2 Moves into home (P3)** | `CARLOS_HOME_PLACEMENT`, home as a target, `carlos move back` | the handoff of §5 unchanged; home children's `CARLOS_REGION` change gated |
+| **F3 Residency cleanup (P5)** | delete `superseded/<move id>/`, `move-snapshots/`, `.parked/superseded-*`, receipts, after the 7-day hold (M7), with owner early deletion | **no deletion of a prefix that is, or is an ancestor of, a live location**, and selection is excluded atomically before deletion, not by a fire-time read (codex 8); **the complete residue inventory**, including noncurrent object versions and delete markers in versioned data buckets (`infra/modules/deployment-buckets/main.tf:27-30`), earlier parks and local parks, with scoped version-delete authority and verification of actual removal before reporting residency complete (codex 9); **a deletion warrant that binds the exact scope**: account, app, incarnation, move id, bucket and prefixes, issued from an immutable console ledger and never from box-writable records or logs (codex 10) |
+| **F4 Stores** | private stores with their instance, app stores with their app (M3), consumer hold (M9) | **a joint instance and store transaction** with binding versions on both sides, health checks and compensation, so a rollback never leaves the source bound to an unusable twin (codex 12); **the store record transitions with config** (`walk.go:427`, `:448`), and grant, rotate, revoke and purge are serialized against the move, with security policy preserved and a named principal deleting the fenced old bucket (codex 17); copy credentials issued under F3's ledger binding (codex 10) |
+| **F5 App moves** | the sequencer, app region change, pause (M8), sidecar refusal (M10) | **durable child move ids persisted before dispatch**, with recovery of their outcomes (codex 18); **an app and store cut barrier** covering every consumer, including concurrent creates, skipped and explicitly placed instances (codex 18, M9); defined transitions for pause, abort and move-back of the app operation |
+| **F6 Graceful cuts** | freeze, thaw, verify hooks (the 09-24 P1); sidecar moves | hooks never replace stop evidence |
+| **F7 `carlos db push`** | #199's deferred push, on §5's handoff with source and target the same box | the same four invariants |
+
+## 17. Interface points with #199 rev 3
+
+| id | point | owner | agreement needed |
|---|---|---|---|
-| **1. Safe instance moves** | journal check of §1.2b's cause; files archive (§8) with `<host>.env` and timers, keyed by incarnation and generation; target readiness T1 to T8 and the cut's re-check (§7.3); `landing` with the durable fence, L1 to L7, automatic rollback and `stuck` (§7.5, §7.6); `MoveState` fields and the generation-carrying flip and rollback (§7.1, with #199 §4); the legacy path (§7.9) if Q11 keeps it; edges file `moves_schema` and `secrets_key_fp`; new rails (home with replicas, legacy sidecar env, `regions`, no move during a push); the minimal move log (events and `reason`); the simulation (§20.2) | #199 release N built (rolled before Phase 1 rolls); the §26 interface points agreed by both specs | rehearsals 1 to 3 pass; kpmg moves with Paul's approval |
-| **2. Owner surface** | `carlos move` (G8); console Region sections and progress; auto-cut; owner-only and operator doors; the ledger; P2 holding route (503 for custom domains and the source's holding answer); full P4 log, history, paging; copy review | Phase 1 | an owner can move an instance without help, with `self-serve` still off |
-| **3. Home and residency** | P3 (§9); `carlos move back`; P5 hold and deletion with signed warrants (§10) | Phase 2; infra approval for the home staging grant | rehearsal 1's uk-to-home run passes; a hold expires and deletes; `self-serve` on |
-| **4. App moves and stores** | the sequencer (§12); `apps.region` writes; store backend per placement; private and app store moves with the write fence and the move-scoped credential (§11) | Phase 3; nearest-region spec built | the two-instance app rehearsal passes |
-| **5. Graceful cuts** | the 09-24 record's P1 hooks (freeze, thaw, verify; verify becomes an extra L5 check); sidecar moves | Phase 4 | |
-
-Phase 1 is the smallest set that makes an ips-shaped move safe. Without
-the archive the key is lost (finding 2a). Without T2 and L3 the config is
-missing (finding 2b). Without L5 and §7.6 a bad target is an outage rather
-than a 503 and a rollback (M2). Without the simulation the next
-interleaving is found in production. Everything else is surface,
-convenience or residency, and none of it is needed for a move to be safe.
-
-## 23. Open questions for Paul
-
-Each is a choice, with a recommendation.
-
-1. **What the archive carries.** (a) Only the fixed set in §5 (the files
- directory, `<host>.env`, timers). (b) Also paths an app declares in its
- manifest. **Recommend (a).** One rule is easy to document and to
- verify, and every app we run fits it. (b) can be added later without
- changing the format.
-2. **Archive size cap.** (a) 256 MiB, refused above it. (b) 1 GiB. (c) No
- cap. **Recommend (a).** Key files and tokens are kilobytes. Anything
- large belongs in object storage, and the cap keeps the cut window
- predictable.
-3. **Cut timing for person-started moves.** (a) Auto-cut when ready and the
- target passes, the default in the CLI and the console. (b) Always ask.
- **Recommend (a)**, with `--manual-cut`. The API default stays manual,
- because titogo holds its own freeze before cutting.
-4. **Hold period.** (a) 7 days. (b) 30 days. Separately, may an owner end
- the hold early ("delete the Ireland copy now")? **Recommend 7 days, and
- yes to early deletion for owners**, as an explicit, confirmed act.
-5. **An app move when an instance rolls back.** (a) Pause, keep the app
- region at the target, owner decides. (b) Continue with the rest and
- report. (c) Revert the app region. **Recommend (a).** It never flaps the
- app region, and the owner sees the failure before more cuts happen.
-6. **Downtime for an app store's final copy.** (a) Hold every instance of
- the app at 503 for the store's final delta. (b) Keep instances up with
- the bucket write-fenced, so uploads fail with storage errors for that
- window. **Recommend (a).** A 503 is honest and retried; (b) shows users
- app errors such as "File storage is unavailable". The plan shows the
- expected length before the owner confirms.
-7. **Apps with native sidecars in the source region.** (a) Refuse the app
- move until sidecar moves exist (Phase 5). (b) Move the instances and
- leave the sidecars, listed in the plan. **Recommend (a).** A sidecar
- that talks to its app across regions is a surprise nobody chose.
-8. **Pre-warming the target** (restore staging into a scratch DB during
- `ready`, so the cut only applies the last delta). (a) Later, if
- rehearsals show the window above about a minute. (b) In Phase 1.
- **Recommend (a).** It adds a second restore path to Phase 1, which is
- exactly where the risk should not grow.
-9. **Rehearsal subject on the flagship.** (a) moveprobe, then a disposable
- ips instance in `bdn`, then kpmg. (b) moveprobe, then kpmg. **Recommend
- (a).** Finding 3 is that a rehearsal must have the moved app's shape,
- and moveprobe has the shape we know about but not ips's own startup.
-10. **Members.** Today's move doors admit any member of the account. M1
- names owners. (a) Owners only, as written in §14. (b) Owners plus a new
- "can move" grant for members. **Recommend (a)**, matching app delete
- and the account move.
-11. **Moves before #199's epoch exists.** (a) Refuse every move until the
- epoch is written, so production only ever runs the v2 landing. (b)
- Support the pre-epoch legacy flow of §7.9 as well. **Recommend (a).**
- #199's rollout writes the epoch as soon as every box and console runs
- release N, which Phase 1 needs anyway. (b) keeps "staging wins" and a
- rollback that has to delete a live-looking lineage in the target, which
- is the hazard the generations exist to remove. Choose (b) only if kpmg
- must move before #199's epoch can be written. Either way, a legacy
- record moved after the epoch takes the first-v2 path of §7.9.
-12. **Where a landed generation lives** (#199 §4's open point, I-1 in
- §26). (a) The pointer names the staging root (`Root: "moves"`), with no
- copy. (b) Landing copies the staged generation into
- `instances/<host>/<incarnation>/<to_gen>/` before the probe, and the
- flip points there. **Recommend (b)**, for the reason in I-1: under (a)
- every moved instance's live replica sits in the one prefix that every
- other region's box role may write and delete.
-
-## 24. Copy (pending copy review)
-
-Drafts. None of these is approved. Every string goes through the
-copy-review gate before it is written into code or templates. Place names
-come from nearest-region §14. `<…>` marks a value.
-
-### CLI
+| I-1 | **Incarnation.** Every move artifact and guard uses `provision.Incarnation(rec)`, including the legacy identity for a zero CreatedAt (#199 §2). | #199 | one shared function; this spec calls it |
+| I-2 | **Tombstone region during a move.** The delete door writes the record's current `region`. | #199 | confirm; this spec adds the other region's park (§7.12) |
+| I-3 | **Parking `moves/<host>/`.** #199 §3.3 parks every key under it. | shared | #199 parks only keys whose `moves/<host>/<move id>/move.json` names the tombstoned incarnation, or legacy keys with no `move.json` |
+| I-4 | **Park destinations.** #199 parks to `parked/<incarnation>/<host>/` and `.parked/<incarnation>/<host>/`; this spec to `superseded/<move id>/<host>/` and `.parked/superseded-<move id>/<host>/`. | shared | both use the same copy-manifest-delete and rename helpers; the two namespaces never overlap |
+| I-5 | **Deferral.** #199 §3.5 defers a recreated host's row while a park is pending. | #199 | the landing mint (§9.6) calls the same predicate |
+| I-6 | **Stale-row retire.** #199 §4 retires a row whose `(account, app, host)` record is absent. | #199 | its retire stops the child and deletes the row only; it must never park or purge the files directory or database of a row held by a move (`hold_op` set) while the move's record exists |
+| I-7 | **Local parks and the files directory.** #199 §3.4 parks the nested `InstanceDir`; this spec defines the files directory for both layouts (§6.1). | shared | #199 parks the flat `<DataDir>/<host>/` too, by this spec's definition, so a recreated flat host does not inherit an `instance.key` |
+| I-8 | **`generation.json` and `lock.json`.** #199 §3.3 copies every key except `lock.json`. | #199 | `generation.json` is copied like any other key; a park never deletes `lock.json` early |
+| I-9 | **One listing per pass.** The tombstone listing and the moves pass share a converge trigger. | shared | each destructive step re-reads its own record and tombstone |
+| I-10 | **Push.** Deferred by #199 to build on §5. | this spec | §5 is the contract F7 uses |
+
+## 18. Question for Paul
+
+**Declaring a box lost (§7.10).** If a box is destroyed during a handoff it
+holds, its stop evidence can never arrive, and the move waits, paged. Choose
+one:
+
+- **(a)** An operator verb, `carlos move resolve --declare-lost <box>`,
+ records that the box's processes are gone and proceeds as a clean
+ rollback. If the lost box was the target, anything it wrote after the
+ flip is lost, and the confirmation says so.
+- **(b)** No verb. Recovery from a destroyed box is a manual, below-the-API
+ procedure written in the runbook.
+
+**Recommend (a).** It is the only exception to INV-2 and INV-3, it needs a
+person, and it keeps the recovery inside the API (the 2026-08-06 rule). The
+loss it can cause is bounded to a probe that never served traffic, unless
+the box was destroyed after the commit, in which case the verb is refused
+(the target is authoritative and the source is parked).
+
+## 19. Copy (pending copy review)
+
+Drafts for Phase 1's operator CLI. None is approved; every string goes
+through the copy-review gate before it is written into code. Place names come
+from nearest-region §14. `<…>` marks a value. No em dashes.
| id | string |
|---|---|
| M-C1 | Moving <host> from <from> to <to>. |
| M-C2 | Copying data. <host> keeps serving while this runs. |
-| M-C3 | Copied <bytes>. Waiting for the copy to catch up. |
-| M-C4 | Checking that <to> can run <host>. |
-| M-C5 | <to> is ready. Starting the cut. <host> will answer 503 for about <estimate>. |
+| M-C3 | Checking that <to> can run <host>. |
+| M-C4 | <to> cannot take this move yet: <check>. |
+| M-C5 | <to> is ready. Starting the cut. <host> will be offline for about <estimate>. |
| M-C6 | Stopped in <from>. Starting in <to>. |
| M-C7 | Moved. <host> is served from <to>. It was offline for <seconds> seconds. |
-| M-C8 | The <from> copy is kept until <date>, then deleted. |
-| M-C9 | <to> could not start <host>: <reason>. It is back in <from> and serving. Nothing was lost. |
-| M-C10 | <host> could not start in <to> or in <from>. It is offline. An operator has been paged. |
-| M-C11 | Ready to cut. Run carlos move cut --host <host> when you are ready. |
-| M-C12 | This move has started its cut. It will finish or move back on its own. |
-
-### App move
-
-| id | string |
-|---|---|
-| M-C13 | Moving app <app> to <to>. New instances start in <to> from now on. |
-| M-C14 | <n> instances will move, one at a time. Each is offline for about a minute. |
-| M-C15 | <n> stay where they are: |
-| M-C16 | <host>, already in <region>. |
-| M-C17 | Its files will move to <to> after the instances. The whole app is offline for about <estimate> while they finish copying. |
-| M-C18 | Paused. <host> could not move: <reason>. It is serving in <from>. Run carlos move resume --app <app> to try again, or carlos move skip --app <app> --host <host> to leave it there. |
-| M-C19 | Moved app <app> to <to>. |
-
-### Refusals
-
-| id | string |
-|---|---|
-| M-C30 | <to> cannot take this move yet: <check>. |
-| M-C31 | <to> cannot read this app's configuration. An operator needs to install the deployment's secrets key there. |
-| M-C32 | <to> cannot reach this app's storage. |
-| M-C33 | <to> does not have enough free disk for <host>. |
-| M-C34 | This instance has replicas. Instances with replicas cannot move yet. |
-| M-C35 | This app only runs in <regions>. Change that in its instances settings first. |
-| M-C36 | <host> keeps a file this move cannot copy: <path>. Only regular files and folders move. |
-| M-C37 | <host> keeps <size> of files beside its database. Moves carry up to 256 MiB. Keep large files in object storage. |
-| M-C38 | <host> is already moving. Wait for it to finish, or run carlos move abort --host <host>. |
-| M-C39 | Moves into <home> are not available yet. |
-| M-C40 | This app has sidecars in <from>. Apps with sidecars cannot move yet. |
-
-### Console
-
-| id | string |
-|---|---|
-| M-C50 | Region |
-| M-C51 | Served from <region>. |
-| M-C52 | Move to another region |
-| M-C53 | Change region |
-| M-C54 | Moving copies this instance's data to <to>, stops it here, and starts it there. It is offline for about a minute. If it cannot start in <to>, it comes back here on its own. |
-| M-C55 | What moves: the database, the files beside it, its settings and its scheduled jobs. |
-| M-C56 | What stays: the app's files storage stays in <region> with the app. |
-| M-C57 | Move <host> to <to> |
-| M-C58 | Moving to <to> |
-| M-C59 | Copying data |
-| M-C60 | Checking <to> |
-| M-C61 | Stopping in <from> |
-| M-C62 | Starting in <to> |
-| M-C63 | Done |
-| M-C64 | Moved from <from> to <to> on <date>. Offline for <seconds> seconds. |
-| M-C65 | The <from> copy is kept until <date>. |
-| M-C66 | The <from> copy was deleted on <date>. |
-| M-C67 | Back in <from> and serving. Nothing was lost. <to> could not start it: <reason>. |
-| M-C68 | This move needs an operator. The instance is offline. |
-| M-C69 | Not started from this console. |
-
-## 25. Gaps this spec closes, against today's code
-
-| # | gap | where |
+| M-C8 | <to> could not start <host>: <reason>. Moving it back to <from>. |
+| M-C9 | <host> is back in <from> and serving. Nothing was lost. |
+| M-C10 | Waiting for <box> in <to> to confirm it has stopped <host>. <host> is offline. |
+| M-C11 | <host> could not start in <to> or in <from>. It is offline. Run carlos move resolve --host <host> --retry once the cause is fixed. |
+| M-C12 | Ready to cut. Run carlos move cut --host <host> when you are ready. |
+| M-C13 | This move has started its cut. It will finish or move back on its own. |
+| M-C14 | <host> keeps a file this move cannot copy: <path>. Only regular files and folders move. |
+| M-C15 | <host> keeps more than 256 MiB of files beside its database. Keep large files in object storage. |
+| M-C16 | <host> does not answer its readiness path. Declare moves.ready_path for this app. |
+| M-C17 | <host> uses private storage. Instances with private storage cannot move yet. |
+| M-C18 | This declares <box> lost. Anything <host> wrote there after <time> is lost. Type the host name to confirm. |
+
+## 20. Round 1 dispositions
+
+| # | finding | disposition |
|---|---|---|
-| X1 | Instance files directory not carried, not backed up | `movespass.go:476-494`; no reference anywhere in the repo |
-| X2 | `<host>.env` not carried: new admin token, box-local overrides lost | `prepare.go:49`; `admintoken.go:115` |
-| X3 | Config rendered only where `CARLOS_SECRETS_KEY` is staged, and absence is silent at spawn | `agent.go:881-886`, `:1812`; `backend_exec.go:1146-1149` |
-| X4 | Nothing checks the target before the source stops | `movespass.go:415-505` |
-| X5 | Target opens at mint, with no health gate | `reconcile.go:374`; `edge.go:595` |
-| X6 | No automatic rollback; recovery was a hand edit of the record | #175, 2026-10-02 post |
-| X7 | Home with replicas passes the move gate (Q2 unimplemented) | `apiinstancemove.go:187-190`; `provision.go:966-983` |
-| X8 | The manifest's `regions` allow-list is not applied to moves | `apiinstancemove.go:179-237` reads no manifest |
-| X9 | Timers lost or reset (G15) | `registry.go:565-579`; `converge.go:223-241` |
-| X10 | Any member can move | `authcli.go:625-628` |
-| X11 | The isolated layout pass, as designed, strands `<DataDir>/<host>/` | isolated-exec design §2 item 1 |
-| X12 | Moves to home refused | `apiinstancemove.go:203-209`; `movespass.go:345`; `backend_exec.go:989-993` |
-| X13 | Source replica, files and env never deleted | `movespass.go:492` (DB artifacts only) |
-
-## 26. Interface points with #199 (replica ownership)
-
-The #199 session will not change `movespass.go`, `promoteStagedReplica`
-or `MoveComplete` until the two specs agree. These are every point where
-they meet. Each says who owns it and what this spec needs. Both specs
-should be reviewed against this list together.
-
-| id | point | owner | what this spec needs |
-|---|---|---|---|
-| I-1 | **Where a landed generation lives.** #199 §4 says `DB = {Gen, Root: "moves"}` and no copy; the coordinator reports it as still undecided. | #199 | **Recommend a copy into `instances/<host>/<incarnation>/<to_gen>/` on landing**, as a step between L4 and L5, with the flip writing `Root: "instances"`. The reason is IAM. Every sibling region's box role holds `s3:PutObject` and `s3:DeleteObject` on `moves/*` in this bucket (`infra/modules/region-host/main.tf:217-221`), so under `Root: "moves"` a moved instance's **live** replica sits in the one prefix that every other box, and anything that obtains a box role (#171), may overwrite or delete. The copy is an in-bucket server-side copy into a key nothing else uses (the move id is in it), so it reintroduces none of the staging-wins hazards, and it costs what today's promotion costs (`backend_exec.go:1580-1606`). If #199 keeps `Root: "moves"`, this spec works unchanged (L5 restores whatever the pointer names), and the IAM exposure is recorded as accepted. |
-| I-2 | **One function for every location.** `movespass.go:135`, `:364`, `:365` and `:369` build the checkpoint prefix, the mirror source, the staging destination and the receipts key from the host alone. | #199 defines; this spec calls | The moves code computes the source with `StorageKey(rec, epoch)` (#199 §2.3) and the destination with a new `provision.MoveStagingKey(rec, toGen)` that #199 defines beside it, so the layout lives in one package. The cut's checkpoint goes into the source storage key. |
-| I-3 | **The flip, the commit and the rollback CAS.** | shared | `MoveComplete` writes `Region = to`, `DB = {to_gen, Root}`, `state = landing` in one CAS, guarded by move id and `DB == from_db`. A new `MoveCommit` (target) clears `move`, guarded by move id and `DB.Gen == to_gen`. A new `MoveRollback` writes `Region = from`, `DB = from_db`, `state = rolling_back`, with the same guard. #199's push commit is guarded by request id, incarnation and `FromGen` (#199 §5.4 step 5). The two families must never both succeed on one record: see I-7. |
-| I-4 | **Generation allocation for a move.** #199 §4 says a move allocates `g<seq+1>-<moveID>` but not who. | this spec | The console allocates `to_gen` in `MoveRequest` from `from_db`'s sequence (0 for legacy or pointerless), with the console-minted move id as the nonce. The move id's alphabet must be valid in #199's `Gen` grammar; #199 states that grammar. |
-| I-5 | **Incarnation of a legacy record with zero CreatedAt.** | #199 | `provision.Incarnation(rec)` must return the same value on every box for such a record, or #199 must say that a zero-CreatedAt record is refused a move until it is backfilled. The first-v2 landing (§7.9) keys the staged copy, the archive and the files markers by it. |
-| I-6 | **The files directory and its markers.** #199 §2.5b gives the nested `InstanceDir` an `.incarnation` marker and moves a mismatch aside to `.retired/<old-incarnation>/`. | shared | (a) Extend the same check to the flat `<DataDir>/<host>/`, which this spec makes a platform-defined path (§5); a recreated flat host otherwise inherits the deleted instance's `instance.key`. (b) This spec adds `.files-gen`; #199's push must leave it unchanged. (c) `.retired/` names need the generation too, because one incarnation can be moved aside more than once by successive landings on one box: `.retired/<incarnation>-<files-gen>/`. (d) #199's wake check must treat a directory without markers as "not landed yet" during `landing`, not as a mismatch to retire, because L2 writes the markers last. |
-| I-7 | **A push and a move never overlap.** | shared | `MoveRequest` refuses while the record has an in-flight push request; #199's push request refuses while `move != nil`, in any state including `stuck`. The route statuses stay distinct: `db-push` is #199's, `moving` and `landing` are this spec's. #199's restart rule ("a route left at `db-push` with no in-flight request is released") never touches a `landing` row, and the landing worker's repair (§7.5, L7) never touches a `db-push` row. |
-| I-8 | **Receipts and the source's files manifest.** | this spec, on #199's key | `move-receipts/<host>/<incarnation>/<from_gen>.json` (#199 §4) and `….files.json` beside it (§8.2); legacy records keep `move-receipts/<host>.json`. Both are cleared at a terminal state, and P5 deletes anything left with the source generation. |
-| I-9 | **Purge with errors.** | #199 | The cut (`movespass.go:492`) and the target's rollback use `PurgeLocalDBArtifactsErr` (#199 §5.6), which removes `.lineage` first; an error fails the pass and is retried. Neither purge touches the files directory. |
-| I-10 | **Registry columns and retire-on-identity-change.** | #199 | The landing row is minted with #199's `incarnation`, `storage_key` and `storage_gen` and status `landing`. After the flip, the source's row can be retired twice over (out of scope by region, and a storage-key mismatch under #199 §2.6). Both are idempotent, and **#199 §2.6's retire must purge only the database, never the files directory**, which is the rollback's material on the source (§7.4 step 8). On rollback the source row's storage key equals the record's again, so no retire fires. |
-| I-11 | **Generation retention across regions.** | #199 | The keep-five GC (#199 §5.4) considers only its own bucket. Generations a move left in another region are P5's (§10). `carlos db rollback` and `carlos db pull --generation` refuse an out-of-region generation (#199 R3) and `carlos db backups` labels it. |
-| I-12 | **The epoch and moves.** | shared | Q11: refuse moves until `control/storage-v2.json` exists, or support §7.9's pre-epoch flow. Either way #199 §7's rollback floor (never below release N once the epoch exists) also protects moved records. |
-| I-13 | **One record listing per pass.** | shared | The moves pass's source and target roles share #199 §5.7's single `ListAllRecords`. Every destructive step re-reads its own record, as both specs already require. |
-| I-14 | **Reads during a move.** | #199 | The pull door (`resolveReplica`) refuses an instance in `landing` or `rolling_back`, because the pointer names a generation that is not yet authoritative (INV-4). In other states it serves the pointer, as #199 specifies. |
-| I-15 | **Staging wins, retired.** `promoteStagedReplica` (`backend_exec.go:1580`) is called from `Restore` (`:290`). | #199 removes; this spec agrees | It runs only for a legacy row on a pre-epoch deployment (§7.9). For v2 rows `Restore` never lists `moves/<host>/`. |
+| 1 | deadline does not prove the target stopped | Accepted. Execution right moves only on stop evidence (§5.3); a deadline only requests release (§7.8) |
+| 2 | probe writes are lost on rollback | Accepted. Clean/dirty verdict by digests; a dirty rollback carries the target's state back over a return lane checked by T10 (§7.8); INV-2 covers background and external work |
+| 3 | rollback opens before checking | Accepted. Recovery runs held as `restoring`, opens only after its probe; `rolling_back`/`stuck` never mint `ready` (§5.2, §7.8) |
+| 4 | no recovery between stop and flip | Accepted. Journaled `cut_phase`, `reverting`, bounded phases; admission and abort are one CAS field (§7.4) |
+| 5 | replayed landing mixes old files with a newer DB | Accepted. Journaled `landing_phase`, no replay after `probing`, generation markers and the start check, unique park destinations (§5.1, §7.6) |
+| 6 | clearing `move` before finalization | Accepted. `committed` keeps `move` through open and source park; opens are conditional on `hold_op` (§5.2, §7.9) |
+| 7 | rollback floor does not cover the landing fence | Accepted. A move-protocol floor with a box-enforced downgrade refusal (§11) |
+| 8 | P5 can delete a live generation | Phase 1 deletes nothing (INV-8) and parks under unique move ids; the deletion rules are F3's invariants (§16) |
+| 9 | P5 leaves versions behind | F3 invariant (§16) |
+| 10 | warrant does not bind its scope | F3 and F4 invariant (§16) |
+| 11 | member-minted token bypasses the owner gate | Phase 1 move mutations are operator-only (§12); delegation is F1's invariant (§16) |
+| 12 | private-store and rollback commit points conflict | Phase 1 refuses private stores (§7.1); the joint transaction is F4's invariant (§16) |
+| 13 | helpers exclude held rows | Accepted. Held render with move-bound acks, held materialization, `RetireExcept` exemption (§7.7, §9.1 to §9.3) |
+| 14 | readiness checks the wrong environment and process | Accepted. Merged-environment equality by HMAC digests, read/write store probe, process-bound health with an explicit readiness path, source probed too (§7.3, §7.7) |
+| 15 | timers neither frozen nor restored | Accepted. Runner freeze, no prune of held rows, source-readable snapshot, exact `ReplaceTimers` (§9.4) |
+| 16 | inventory misses registry state | Accepted. Every column classified; `Replication` and `NextDue` carried; `Caps` refused (§6.2) |
+| 17 | store record omitted | F4 invariant (§16) |
+| 18 | app-move child identity and store barrier | F5 invariant (§16) |
+| 19 | archive replay contradicts encryption | Accepted. Plaintext and sealed identities; one create-only sealed object per cut, reused on replay; retention stated (§8.3) |
+| 20 | cause still unconfirmed | Accepted. Confirmed from the UK, US and AU journals; remediation and fingerprint verification (§1.2, §10, §15) |
+
+Astra's interface dispositions on rev 1's §26 are superseded by #199 rev 3,
+which dropped storage keys, generations as record fields, the epoch and
+`Root: "moves"`. Their substance carries into rev 2: one key function
+(I-1 above), exclusion through finalization (§7.9), blocking reads during
+transitional states (F7 inherits it), no "staging wins" (§5.1), and the
+landed copy installed into `instances/` (M15).
+
+Astra's §23 recommendations matched rev 1's and Paul accepted them as M4 to
+M15, with Astra's additions folded in: M5's streaming enforcement (§8.1),
+M11's duration gate (T11), and M14's single landing path.