carlos / platform Public

spec: replica ownership and db push, rev 2 (storage keyed by incarnation and generation)

Rev 1 failed Astra review (16 P1s, one cause: host-keyed storage). Rev 2
keys replicas and local state by incarnation and generation; push becomes
replicate-into-a-fresh-generation then a guarded pointer CAS. Folds in the
moves session's notes: per-host working directory, receipts by generation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ef0b7e45eae62af2d8dc39b55b5d3c374bf52a03 parent 2fef212
1 file changed, +385 −386
  • docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md +385 −386

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md b/docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md
index 637f34d4a..bba43870c 100644
--- a/docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md
+++ b/docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md
@@ -1,399 +1,398 @@
# Replica ownership (#199) and `carlos db push`
-Design, 2026-10-02. Part A closes amadan #199. Part B rebuilds `carlos db
-push` from `feat/db-push-pull` (Tony Wood, b014d57d) on top of Part A,
-fixing the review in #194. Part B depends on Part A; Part A ships alone.
+Design, 2026-10-02, **rev 2**. Part A closes amadan #199. Part B rebuilds
+`carlos db push` from `feat/db-push-pull` (Tony Wood, b014d57d), fixing
+the review in #194. Rev 1 (stamps on host-keyed storage) failed Astra's
+review with 16 P1s, all from one cause: data was still keyed by host, so
+every path that touched the shared location needed its own ownership
+check. Rev 2 removes the cause instead: **data is keyed by incarnation and
+generation, and the instance record says which generation is current.**
+Rev 1's findings are answered in §6.
Rulings (Paul, 2026-10-02), not to reopen:
-- **R1.** A recreated host whose replica belongs to a deleted earlier
- incarnation gets that lineage **archived** in-region under
- `parked/<old-incarnation>/` and starts empty. If the earlier
- incarnation's record still exists, the wake is **refused** (two live
- owners).
-- **R2.** A replica or local database with no stamp is **adopted** by the
- record that owns the host today.
-- **R3.** A pushed database and its pre-push backups live in the
- instance's **own region's data bucket**. Push is in-region only, like
- pull.
+- **R1.** A recreated host never sees an earlier incarnation's data; that
+ data is kept in-region and the new instance starts empty.
+- **R2.** Data that exists before this change is adopted by the record
+ that owns the host today.
+- **R3.** A pushed database and its backups live in the instance's own
+ region. Push is in-region only, like pull.
+- **R4.** Storage is rekeyed by incarnation and generation (rev 2's
+ approach), rather than stamping host-keyed storage (rev 1).
## 1. The problem
-Storage identity is the host, alone:
-
-- the replica: `activator.HibernatePrefix(prefix, host)` =
- `instances/<host>` (activator.go:138);
-- the local database: `DefaultDBPath` / `NestedDBPath` (provision.go:1219,
- 1277);
-- the restore-drill verdict: `dbverify`, keyed by host.
-
-Deleting an instance (`DeleteRecord`, provision.go:1071) removes the
-record only. Reconcile then drops the registry row (reconcile.go:1155) but
-not the local file. Nothing ever deletes the replica (no production caller
-of `DeleteAll`/`DeletePrefix` on `instances/`). Nothing on the wake path
-checks who a replica or local file belongs to.
-
-So whoever next creates the same host inherits the old database: on first
-wake (`ExecBackend.Restore` returns the warm local file at
-backend_exec.go:267, or restores the replica), and through `carlos db
-pull` without any wake at all (apidb.go:70).
-
-Who can recreate a host:
-
-- **The same account, commonly.** Every app in an account shares the
- `<label>.<sqid>.<AppsDomain>` namespace (apiinstances.go:693). titogo's
- signup creates and deletes instances per customer slug, so a reused slug
- inherits the previous customer's data. This is the realistic case.
-- **Another account, less commonly.** Store-mode `carlos instances enable
- --domain` (instances_cli.go:135) and operator `carlos add` check host
- shape only. Account sqids are never reused (ownership.go:650), so a
- platform-domain host cannot cross accounts.
-- **Sidecars**: `<name>.<app>.<sqid>.sidecar.internal`, recreated after a
- sidecar delete or an app purge and a same-named new app.
-
-A related wrinkle: if a host is deleted and recreated between two
-reconcile passes, the old registry row survives (reconcile.go:1141) and a
-running child keeps serving the old data under the new record.
-
-## 2. Part A: incarnation identity and the stamp
-
-### A1. Incarnation
-
-An instance's **incarnation** is
-
- maintenanceguard.RecordIdentity(rec.Account, rec.App, rec.Host, rec.CreatedAt)
-
-Account, App, Host and CreatedAt are immutable under every record update
-(`protectedFieldsEqual`, maintenance.go:81), and CreatedAt is set at create
-(apiinstances.go:888, domainattach.go:183, replica.go:60). Sidecar records
-use the same function over their own fields.
-
-A record with a zero CreatedAt (written before CreatedAt existed) gets the
-fixed sentinel incarnation `legacy:<sha256(account\0app\0host)>`. A later
-recreate always has a real CreatedAt, so its incarnation differs from the
-sentinel and the check below still fires.
-
-### A2. Where it is recorded
-
-- **Registry row:** a new column `routes.incarnation`, added by an
- idempotent `ALTER TABLE` at start, like `routes.replicas`. `routeFor`
- renders it from the record and `convergeOwned` converges it in both
- directions. It is **not** in `Fingerprint()`.
-- **Replica stamp:** `<prefix>/owner.json`:
-
- {"v":1,"incarnation":"…","account":"bab","app":"jam",
- "host":"…","created_at":"…","stamped_at":"…","box":"<machine id>"}
-
- litestream lists only `<prefix>/%04x/` per level and skips non-LTX
- names, so the stamp is invisible to replication, restore, retention and
- the db door's coordinate listing. `MirrorPrefix` copies it (a move
- carries the stamp to the target), and `DeletePrefix` deletes it.
-- **Local stamp:** `<dbPath>.owner`, the same JSON, written beside the
- local database whenever a restore or seed completes for an incarnation.
- `PurgeLocalDBArtifacts(full)` removes it with the database.
-
-### A3. An incarnation change on a live row
-
-When `convergeOwned` sees a row's incarnation change (the delete-and-
-recreate-between-passes window), it treats this like a retire: stop the
-running child, fence it, then converge. The wake gate (A4) then deals
-with the old local file and replica. A process never keeps serving one
-incarnation's data under another's record.
-
-### A4. The wake gate
-
-`doWake` order today is: registry refresh, Classifier refusal (activator.go
-:1090, the precedent for a pre-lease gate), `Acquire` (writes `lock.json`),
-`Restore`, `Seed`. The gate lives inside `ExecBackend.Restore`, after the
-lease is held, in three steps.
-
-**Step 1, before the warm-cache return (before backend_exec.go:267).** If
-the local database exists, read `<dbPath>.owner`:
-
-- equal to the route's incarnation: warm path, as today;
-- absent: **adopt** (R2), writing the stamp, then the warm path;
-- different: the local file belongs to an earlier incarnation. If the
- replica stamp (read now) names that same earlier incarnation, replicate
- the local tail into it under the held lease first, so the archive in
- step 3 is complete. Then purge the local file with the error-returning
- purge (B6) and fall through to the cold path. If the replica belongs to
- neither incarnation, move the local file aside to
- `<DataDir>/.parked/<old-incarnation>/` (never deleted automatically,
- logged loudly) and fall through.
-
-**Step 2, move staging.** `promoteStagedReplica` runs as today. Staging
-carries the source's `owner.json`. If the staged stamp differs from the
-route's incarnation, refuse the wake (`ErrOwnerConflict`). A move never
-changes incarnation.
-
-**Step 3, the replica stamp.** Read `<prefix>/owner.json`:
-
-- absent and the prefix holds no LTX: a new instance. Put the stamp
- create-only (`If-None-Match: *`), then restore finds nothing and Seed
- runs.
-- absent and the prefix holds LTX: **adopt** (R2). Put the stamp
- create-only.
-- equal: restore.
-- different: an earlier incarnation's lineage.
- - Look up the earlier incarnation's record:
- `RecordKey(stamp.account, stamp.app, host)`. If it exists and its
- CreatedAt equals the stamp's, **refuse** the wake (`ErrOwnerConflict`,
- R1).
- - Otherwise **archive** (R1). `MirrorPrefix` the prefix to
- `parked/<old-incarnation>/<host>/`, skipping `lock.json`, then verify
- that every source key exists at the destination with the same ETag.
- Write `parked/<old-incarnation>/<host>/parked.json` (who, when, from
- which incarnation). Delete every key under the prefix except
- `lock.json`. Put the new stamp. Restore then finds nothing, and Seed
- runs.
-
-A lookup error, a failed verify or a failed put refuses the wake with a
-retryable error. The gate never restores or seeds on uncertainty.
-
-`ExecBackend` has no deployment store today. The gate takes an injected
-`OwnerLive(account, app, host string, created time.Time) (bool, error)`,
-which `agent.go` wires from the release store.
-
-### A5. The db door
-
-`resolveReplica` (apidb.go:70) reads `owner.json` before serving
-anything:
-
-- different from the record's incarnation: refuse, as "nothing replicated
- for this instance yet" (the same answer an empty replica gets);
-- absent: allow (legacy; the box adopts it on the next wake or sweep);
-- equal: serve.
-
-### A6. The adoption sweep
-
-A legacy instance that never wakes would stay unstamped, and readable
-through the door by a reused host, indefinitely. Once per agent start, the
-box walks its owned rows. For each prefix that holds LTX but no stamp, it
-puts the stamp create-only with the row's incarnation. That is one HEAD
-per route and a PUT where needed. A stamp that already exists is left
-alone.
-
-### A7. IAM and infra
-
-The box role needs `s3:PutObject`/`GetObject`/`DeleteObject`/`ListBucket`
-on `parked/*` in its own data bucket (infra/modules/region-host/main.tf
-:160-226 grants `instances/*` today). The console role needs `GetObject`
-on `instances/*/owner.json`, which it already has through the pull door's
-`instances/*` grant. The data bucket is versioned with no lifecycle rules,
-so deletes leave noncurrent versions (a safety net, and a storage cost).
-
-### A8. Out of scope, recorded
-
-- A completed move leaves the source region's `instances/<host>/` behind
- (movespass.go never deletes it; the comment at backend_exec.go:285 is
- wrong). The stamp makes it harmless. Cleaning it up is #412's "list what
- deleted instances left behind".
-- `dbverify` verdicts are keyed by host, so a reused host inherits the
- previous verdict. Key them by incarnation later.
-- Listing and purging `parked/` is operator tooling, not part of this
- change.
-
-## 3. Part B: `carlos db push`
-
-### B1. Scope
-
-The same gate as the pull door:
-
-- members only, with the shared 403 for every service principal;
-- in-region, non-pooled, activator-managed exec instances only;
-- refused for a `role: replica` record;
-- refused while a move is in flight, and a move is refused while a push
- is in flight;
-- typed confirmation: the `X-Carlos-Confirm-Host` header must name the
- host.
-
-### B2. The request record
-
-This reuses Tony's `provision.DBTransfer` and its CAS transitions
-(dbtransfer.go), with three changes:
-
-- the pull op is removed (pull is the read door);
-- a new Step, `replacing`, sits between `backed-up` and `replaced` (B4.7);
-- `Incarnation` is copied onto the request at request time, and every
- box-side transition checks it.
-
-### B3. Upload, in-region (R3)
-
-- The console streams the body straight to the data bucket, at
- `db-uploads/<incarnation>/<id>.db`. It never buffers the whole file.
-- **Cap:** 1 GiB to start. The cap is enforced by counting bytes as they
- stream.
-- **Header check:** the first 100 bytes must be a SQLite header, checked
- while streaming. A bad header aborts the upload.
-- **IAM:** the console gets a narrow grant, `PutObject` and `DeleteObject`
- on `db-uploads/*` in its own region's bucket.
-- **Request write, and an ambiguous outcome (#194 P1-4):**
- - After the upload completes, the console writes the request with CAS.
- - If the write returns an error other than `ErrDBConflict`, the console
- re-reads the record.
- - If the record carries the request id, the write landed: answer 202.
- - If the record definitely lacks it, delete the upload.
- - If the re-read itself fails, leave the upload in place. B4.13's sweep
- removes it.
-
-### B4. The box worker
-
-This runs from the shared pass (B5). Steps run in this order, and each
-resumes from the record's Step.
-
-1. **Hold the route.** Set its status to `db-push`. This word is distinct
- from `maintenance`, which the maintenance feature owns
- (provision/maintenance.go). Everything that serves or wakes requires
- `ready`. The status persists in the registry across restarts.
-2. **Fence and quiesce**, as today.
-3. **Take the lease, once, for the whole swap (#194 P1-1).**
- - Call `lease.Acquire` on the prefix and keep the `Held` until step 12.
- - If another holder has it, return and retry next pass with the route
- still held.
- - Every write below goes through this `Held`. Nothing calls
- `ReplicateWithLease`, which takes its own lease.
-4. **Check ownership.** The replica stamp, the route's incarnation and
- the request's incarnation must all agree. If any differ, fail the
- request and release the route. Nothing has changed yet at this point.
-5. **Resolve move staging (#194 P1-2).**
- - If `moves/<host>/` is non-empty, run the activator's promotion under
- the held lease (B7), then drop the staging.
- - Do this before the backup, so the backup is of what a wake would
- actually restore.
-6. **Checkpoint the local tail.** If the local database exists and its
- stamp matches, replicate its tail under the held lease and wait for
- sync.
-7. **Back up (server side).**
- - `MirrorPrefix` the prefix to `db-backups/<incarnation>/<backup-id>/`,
- skipping `lock.json`.
- - Verify every key and ETag at the destination.
- - Record the backup id with Step `backed-up`.
- - Nothing is restored on the box, so the size cap and the scratch-disk
- problem (#194 P2-6) do not apply to the backup.
-8. **Download the upload to scratch.**
- - First check free disk is at least twice the upload size. If not,
- the push fails before anything changes.
- - Run `CheckDB` on the downloaded file. A bad file fails the push
- before anything changes.
-9. **Mark the point of no return.** Write Step `replacing` to the record
- **before** any delete.
-10. **Clear the old lineage.** Delete every key under the prefix except
- `lock.json` and `owner.json`.
-11. **Replicate the upload** from scratch under the held lease, then wait
- for sync.
-12. **Purge the local database** with the error-returning purge (B6).
- - Verify the database and its sidecars are gone, and remove the local
- stamp.
- - Then write Step `replaced` and release the lease.
-13. **Finish.**
- - Delete the upload and prune backups to the newest five.
- - Set the route's status back to `ready`. **A failure here returns an
- error and keeps the request in flight (#194 P2-5).** Only after
- that succeeds: lift the fence and mark the request done.
- - Upload objects that no in-flight request names and that are older
- than an hour are deleted here too.
-
-**Resuming from Step `replacing`:**
-
-- Redo steps 8 to 12. The delete is idempotent, and the replicate starts
- the same fresh lineage.
-- **If the upload is missing at `replacing`**, never mark the push failed
- and never release the route: the replica may be empty. Keep the route
- held, log loudly, and raise an alert. An operator restores the backup
- (B7).
-
-**After a restart**, the worker lifts any route left held for a request
-that is no longer in flight (#194 P2-5). It finds those routes by their
-`db-push` status, which is durable in the registry, not by in-memory
-fence state. For each one it sets `ready` and lifts the fence.
-
-### B5. One record listing per pass (#194 P2-7)
+Storage identity is the host, alone: the replica at
+`activator.HibernatePrefix(prefix, host)` = `instances/<host>`
+(activator.go:138), the local database at `DefaultDBPath` /
+`NestedDBPath` (provision.go:1219, 1277). Deleting an instance removes the
+record only (provision.go:1071); nothing deletes the replica or the local
+file. Nothing checks ownership on wake or in the pull door. So whoever next
+creates the host inherits the data. The same account does this routinely:
+every app in an account shares `<label>.<sqid>.<AppsDomain>`, and titogo
+creates and deletes instances per customer slug. Sidecars
+(`<name>.<app>.<sqid>.sidecar.internal`) have the same exposure.
+
+Separately, a host deleted and recreated between two reconcile passes
+under a different app keeps its old registry row forever: `convergeOwned`
+runs only for a matching account and app (reconcile.go:947), the mismatch
+branch leaves the row alone (:1031), and removal skips it because the host
+still has a record (:1141). The old app keeps serving the new owner's
+host. Sidecars have the same shape (sidecar_reconcile.go:131, :467).
+
+## 2. The storage key
+
+### 2.1 Incarnation
+
+ incarnation = RecordIdentity(account, app, host, createdAt) (hex, 64)
+
+`maintenanceguard.RecordIdentity` (types.go:62), factored into one shared
+`provision.Incarnation(rec)` for instance and sidecar records. Account,
+App, Host and CreatedAt are immutable under every update
+(`protectedFieldsEqual`, maintenance.go:81). `CreateRecord` and
+`CreateSidecarRecord` (provision.go:820, sidecar.go:322) now **refuse** a
+zero CreatedAt, so every record created from here on has an incarnation. A
+record with zero CreatedAt can only be legacy (§2.3).
+
+### 2.2 Generation
+
+A record gains one field:
+
+ DB *DBPointer `json:"db,omitempty"`
+ type DBPointer struct {
+ Gen string `json:"gen"` // "g<seq>-<nonce>", e.g. "g2-7f3a91c0"
+ Root string `json:"root"` // "instances" | "moves"
+ }
+
+The nonce is the id of the request that made the generation (a push, a
+move) and is never reused, so two generations can never share a location
+even if a sequence number is replayed. `Root` exists so a moved instance
+can live where the move landed it without a copy (§4).
+
+### 2.3 Where a record's data lives
+
+ v2: <Root>/<host>/<incarnation>/<Gen>/ (replica)
+ legacy: instances/<host> (replica, as today)
+
+A record is **legacy** when `DB == nil` and its CreatedAt is before the
+deployment's **v2 epoch**. Otherwise it is v2. A v2 record with `DB ==
+nil` uses `{Gen: "g0", Root: "instances"}`.
+
+The v2 epoch is one object, `control/storage-v2.json` `{"since": "<RFC3339>"}`,
+written create-only by an operator once every box and console runs a
+release that understands it (§7). Before it exists, **every record is
+legacy** and nothing changes. CreatedAt is immutable and every old writer
+preserves it, so no old binary can turn a v2 record legacy or the reverse.
+
+One function, `provision.StorageKey(rec, epoch) (StorageKey, error)`,
+returns the location. **Every reader and writer goes through it.** There
+are 17 call sites that build the prefix from the host today
+(activator.go ×3, backend_exec.go ×9, movespass.go ×3, apidb.go,
+hibernate_verify.go). The registry row carries the computed key (§2.4), so
+the activator never derives it.
+
+### 2.4 The registry row
+
+New columns, added by idempotent `ALTER TABLE` at start (like
+`routes.replicas`): `incarnation`, `storage_key` (the full replica
+prefix), `storage_gen`. `routeFor` and `sidecarRouteFor` render them from
+the record and the epoch; `convergeOwned` and `convergeSidecar` converge
+them in both directions. A change of `incarnation` or `storage_key` on a
+live row is a **retire**, not a column update (§2.6).
+
+### 2.5 The local database
+
+The tenant-visible `DBPath` does not change: the app's environment, and
+the isolated-exec nested layout, stay as they are. Beside it, the box
+keeps `<dbPath>.lineage`:
+
+ {"incarnation":"…","storage_key":"…"}
+
+It is written by atomic rename **after** a restore or a seed succeeds and
+the first replication sync has completed, and it is removed **first**,
+before any purge.
+
+On wake (`ExecBackend.Restore`, before the warm-cache return at
+backend_exec.go:267):
+
+- **v2 row:** the warm local file is used only if `.lineage` equals the
+ row's `incarnation` and `storage_key`. Anything else (absent, different,
+ unreadable) purges the local file with the error-returning purge
+ (§5.6), and the wake fails if the purge fails. Then it restores cold
+ from `storage_key`.
+- **legacy row:** as today, and `.lineage` is written on first sight
+ (R2).
+
+A database a fresh v2 instance creates in `Start` (activator.go:1168),
+before any `.lineage` exists, is being replicated into its own
+generation's location. After a crash it is restored from there, never
+adopted, so there is no "file before stamp" window.
+
+### 2.5b The instance's working directory
+
+The database is not the only host-keyed local state. A nested row's
+working directory, `InstanceDir(host)` (provision.go:1270, used as the
+app's data dir by activator/paths.go:37), holds whatever the app writes
+there, for example titogo's `instance.key` and `report.token`. A recreated
+host inherits these today (reported by the moves session, 2026-10-02).
+
+The two pieces of local state have different lifetimes:
+
+- **The working directory belongs to the incarnation.** It carries
+ `.incarnation`, which is written when the directory is created and
+ checked on every wake before the app starts. If it is absent on a v2
+ row, or different, the box moves the whole directory aside to
+ `<DataDir>/.retired/<old-incarnation>/` and creates it fresh. Nothing is
+ deleted automatically; operator purge is #412's tooling. A legacy row
+ adopts (R2).
+- **The database belongs to the generation** (§2.5). A push or rollback
+ replaces the database and keeps the directory, so `instance.key`
+ survives a push.
+
+A flat row's app writes wherever it chooses under the shared `/data`, and
+the platform cannot attribute those files to an incarnation. Flat rows
+get the database protection only; the nested layout (#126) is what
+protects the rest. Carrying the working directory across a move is the
+moves spec's concern: it doesn't move today, and that caused the
+2026-10-02 kpmg outage (#175).
+
+### 2.6 Retiring a row whose identity changed
+
+In both `convergeOwned` and `convergeSidecar`, **before** the ownership
+branches: if the record for a host has a different incarnation or storage
+key from the row, the row is retired.
+
+1. Fence the host.
+2. Stop the child, and require stop evidence (a stopped unit, or no
+ process), retrying next pass until it holds.
+3. Purge the local file with the error-returning purge.
+4. Replace the row from the record.
+
+Until step 4 the host is not served. The fence is lifted only after step 4.
+This also fixes the stale-other-app row in §1.
+
+## 3. Part A: #199, by construction
+
+With §2 in place:
+
+- A recreated host has a new CreatedAt, so a new incarnation and a new
+ location. It cannot read the old replica: a different key, never listed.
+ Its old local file fails the `.lineage` check and is purged. The old
+ replica stays where it was, in-region, untouched (R1). There is no
+ archive step, no ownership lookup and no stamp to race.
+- The pull door (apidb.go `resolveReplica`) uses `StorageKey(rec)` and
+ serves only coordinates under that prefix. An old owner's request
+ resolves the old record. Once that record is gone, the gate 404s before
+ any key is built, and the new owner's location is a different key.
+ Between the gate and the read, a request can only reach the prefix its
+ own record named.
+- Legacy records keep `instances/<host>`, adopted by the current record
+ (R2). A legacy host deleted **after** the epoch and recreated gets a v2
+ record, so it is protected. A legacy host recreated before the epoch
+ keeps today's behaviour. That is unchanged risk until the epoch is
+ written, and it is the reason to write the epoch promptly.
+
+Out of scope, recorded: listing and purging what deleted instances left
+behind (#412); `dbverify` verdicts still keyed by host (key them by
+storage key later); moves leave the source region's lineage behind
+(movespass.go never deletes it; the comment at backend_exec.go:285 is
+wrong). Under v2 that lineage is never pointed to, so it is garbage, not
+a hazard.
+
+## 4. Moves under v2 (coordinated with the moves session)
+
+Today a move mirrors the source replica into the target's `moves/<host>/`
+in rounds, and the target's first wake promotes staging over
+`instances/<host>` ("staging wins", backend_exec.go:1580). Under v2:
+
+- A move to region T allocates `Gen = g<seq+1>-<moveID>` and mirrors into
+ `moves/<host>/<incarnation>/<Gen>/` in T's bucket. The source still
+ writes only `moves/*` cross-region, so IAM is unchanged.
+- `MoveComplete` CASes `Region = T` and `DB = {Gen, Root: "moves"}` **in
+ one write**. T's box restores that location directly and replicates into
+ it from then on. There is **no promotion** and no copy for v2 records.
+- A stale copy from an earlier stay in T (ie→uk→ie) has an older Gen and
+ is never pointed to.
+- An abort leaves an unreferenced location, which is garbage.
+- The next push moves the instance back under `instances/`.
+- Copy receipts (`move-receipts/<host>.json` in the source bucket, added
+ in 7edd4b33 for multipart ETags) are keyed by the source storage key
+ instead: `move-receipts/<host>/<incarnation>/<Gen>.json`. A stale
+ receipt cannot vouch wrongly, since it matches ETags exactly, but it
+ would otherwise outlive its lineage.
+- The cut and landing paths are being redesigned on
+ `design-moves-any-region` (move-any-app spec). That spec builds on this
+ section, and the generation bump at the cut is agreed with its author.
+- Legacy records keep today's flow until their first v2 event. A move of
+ a legacy record after the epoch is that event: it lands as v2 at
+ `moves/<host>/<incarnation>/g1-<moveID>/`.
+
+This section needs the moves session's review before any code; it
+touches `movespass.go`, `promoteStagedReplica` and `MoveComplete`.
+
+## 5. Part B: `carlos db push`
+
+### 5.1 Scope
+
+The same as pull: members only, with the shared 403 for every service
+principal; in-region, non-pooled, activator-managed exec instances only;
+**v2 records only** (push refuses a legacy record with "not available for
+this instance yet"); refused for a `role: replica` record, while a move is
+in flight, and the reverse; the typed confirmation header must name the
+host. Enabled only when the v2 epoch exists.
+
+### 5.2 Request
+
+Tony's `provision.DBTransfer` (b014d57d dbtransfer.go), with the pull op
+removed. A request records `ID`, `Incarnation`, `Region`, `FromGen` (the
+record's current generation at request time) and `ToGen` (=
+`g<seq+1>-<ID>`), plus `Step`, `Error` and `Bytes`. The acceptance CAS
+**refuses** unless the record's incarnation, region and generation still
+equal what the console saw before the upload began. That binds the upload
+to the instance as it was.
+
+### 5.3 Upload (R3)
+
+The console streams the body to the instance's region's data bucket at
+`db-uploads/<incarnation>/<ID>.db`:
+
+- The upload is multipart, and published only on complete.
+- A byte counter aborts it, and the multipart upload with it, past the
+ cap (1 GiB).
+- The first 100 bytes must be a SQLite header.
+- The completed object's size and checksum go onto the request.
+
+It needs a console grant for `PutObject`, `AbortMultipartUpload` and
+`DeleteObject` on `db-uploads/*` in its own region's bucket.
+
+The request CAS comes after the upload. On an error other than a
+conflict, the console re-reads: if the id is on the record, answer 202;
+otherwise delete the upload. An orphaned upload is storage garbage, not a
+safety problem: no step below destroys anything. The box deletes uploads
+that are older than a day and that no request names.
+
+### 5.4 The box worker
+
+All work happens on the box that owns the route. Nothing is deleted
+until after the commit.
+
+1. **Hold the route.** Set its status to `db-push` (distinct from
+ `maintenance`, which the maintenance feature owns). Fence it and wait
+ until it is quiesced.
+2. **Note where the live lineage ends.** Read the current generation's
+ newest TXID (one listing) and record it on the request.
+3. **Download and check the upload.** Download it to scratch, but only if
+ free disk is at least twice its size. Then verify the size and checksum
+ against the request and run `CheckDB`. A bad file fails the request and
+ releases the route; nothing has changed.
+4. **Replicate into ToGen.** `lease.Acquire` the ToGen location, then
+ check the record under the lease. If the record already points at
+ ToGen, stop: this request has already committed. Otherwise replicate
+ scratch into ToGen, wait for sync, and release. Writing into ToGen is
+ harmless at any time before the commit: nothing points at it, and its
+ nonce makes it this request's alone. A replay writes the same bytes
+ again.
+5. **Commit.**
+ - Re-read the current generation's newest TXID. **If it moved since
+ step 2, abort** (something wrote despite the hold): fail the request
+ and release.
+ - Otherwise CAS the record with `DB.Gen = ToGen, Root = "instances"`,
+ guarded by request ID, incarnation and `FromGen`. **This CAS is the
+ commit.** Before it, the instance is exactly as it was; after it, the
+ new generation is live.
+6. **Converge.**
+ - Update the row's `storage_key`, then purge the local file with the
+ error-returning purge, so the next wake restores ToGen cold.
+ - A purge error keeps the route held and retries next pass. The
+ `.lineage` check (§2.5) would refuse the old file anyway.
+7. **Release.**
+ - Set the route's status back to `ready`. A failure returns an error
+ and keeps the request in flight.
+ - Then lift the fence, mark the request done and delete the upload.
+
+**The previous generation is the backup.** The newest five generations
+are retained; older ones are deleted after a commit. They are never the
+current pointer, so deleting them cannot touch live data.
+
+**Restart:**
+- The worker re-derives everything from the record and the row.
+- A route left at `db-push` whose record has no in-flight request is
+ released, but only if the record is readable and well-formed. An
+ unreadable or malformed record keeps the hold and raises an alert.
+- Before step 5, releasing on any failure is always safe, because the
+ current generation was never touched.
+
+### 5.5 Undo and backups
+
+- `carlos db backups --instance <h>` lists the retained generations.
+- `carlos db rollback --instance <h> --to <gen>` is a push with no upload:
+ hold, then CAS the pointer to `<gen>` (guarded as in 5.4), then converge
+ and release. The generation being left is retained, so a rollback can be
+ undone.
+- `carlos db pull --instance <h> --generation <gen> --out <path>`: the door
+ serves a retained generation's coordinates, validated against the
+ retained list.
+
+### 5.6 Purge with errors
+
+Add `activator.PurgeLocalDBArtifactsErr(dbPath, full) error`. It removes
+`.lineage` first, then returns the first removal error other than
+not-exist. Used by §2.5, §2.6, push step 6, and the move cut
+(movespass.go:492, which ignores purge errors today).
+
+### 5.7 One record listing per pass
The moves pass and the db pass share one `ListAllRecords` per converge
-pass, folded once and handed to both.
-
-### B6. Purge with errors (#194 P1-3)
-
-Add `activator.PurgeLocalDBArtifactsErr(dbPath, full) error`, which
-returns the first removal error other than not-exist, and its stamp
-removal. The push and the A4 gate use it. The existing best-effort
-function stays for its current callers.
-
-### B7. Undo
-
-- `carlos db backups --instance <h>` lists the backups under
- `db-backups/<incarnation>/`.
-- `carlos db pull --instance <h> --backup <id> --out <path>`:
- - the pull door serves a backup's LTX coordinates by `?backup=<id>`;
- - the backup id is validated, and nothing outside
- `db-backups/<incarnation>/<id>/` is reachable;
- - the CLI restores it read-only, exactly as a normal pull.
-- To put a backup back, pull it, then push it. The push takes its own
- backup first.
-
-The activator's promotion logic (`promoteStagedReplica`) is factored so
-step B4.5 and the wake share one implementation.
-
-### B8. CLI
-
-`carlos db push --instance <h> <file>`, with these rules:
-
-- `--instance` is required for push and is never inferred. Replacing the
- wrong instance's database is the failure to avoid.
-- An interactive confirmation names the host.
-- `--yes` skips the prompt but still requires `--instance`.
-
-`carlos db status --instance <h>` shows the request, its step and any
-error. All user-facing strings go through copy review.
-
-### B9. Tests that pin the review's findings
-
-Each is a test:
-
-- two workers on one prefix: the second cannot take the lease, so it
- neither deletes nor replicates (P1-1);
-- a push with move staging present backs up and replaces what a wake
- would restore, and the next wake does not resurrect staging (P1-2);
-- a failing purge keeps the route held and the request in flight (P1-3);
-- a CAS write whose response is lost (the record has the id) keeps the
- upload, and the push completes (P1-4);
-- an upload missing at `replacing` keeps the route held and never seeds
- (P1-4);
-- a `SetStatus("ready")` failure keeps the request in flight, and a later
- pass completes it (P2-5);
-- restart with a cleared record and a `db-push` route: released (P2-5);
-- an upload over the cap is refused while streaming; insufficient disk
- fails before step 9 (P2-6);
-- one `ListAllRecords` per pass (P2-7).
-
-Part A's own tests:
-
-- a recreated host (same account, other app, other account, sidecar)
- never sees the old data:
- - not through the warm path;
- - not through restore;
- - not through the door;
-- archive happens only when the old record is gone, and its ETags are
- verified before the delete;
-- the two-live-owners case refuses;
-- legacy adoption, by the sweep and by a wake;
-- a move carries the stamp, and a staged stamp that disagrees refuses;
-- the incarnation change on a live row stops the child.
-
-## 4. Rollout
-
-1. **Part A, IAM first:** the `parked/*` grant goes into the region-host
- module. Dev tracks main. For flagship, the change rides the next
- release tag through carloku-infrastructure. Target the change; never
- apply the whole root (user_data restarts every box).
-2. **Part A, boxes:** the stamp, the gate and the sweep. The console door
- check (A5) can roll at any time: an absent stamp is allowed.
-3. **Part B:**
- - IAM: the console's `db-uploads/*` grant and the box's `db-uploads/*`
- and `db-backups/*` grants.
- - Roll boxes before the console. The console exposes push, and a box
- without the worker would leave requests queued forever.
- - CLI release, and copy review of every string.
+pass. This is a cost change only; every destructive decision re-reads its
+own record (5.4 steps 4 and 5).
+
+### 5.8 CLI
+
+`carlos db push --instance <h> <file>`, `status`, `backups`, `rollback`,
+and `pull --generation`. `--instance` is always required for push and
+rollback, never inferred, and an interactive confirmation names the host
+(`--yes` skips the prompt only). All strings go through copy review.
+
+## 6. Rev 1's findings, answered
+
+| # | Rev 1 finding | Rev 2 |
+|---|---|---|
+| 1, 2 | other-app or sidecar row survives recreate | §2.6, both families, retire before ownership branches |
+| 3, 7, 9 | warm file, file before stamp, sweep races | no stamps; `.lineage` is per storage key, written after sync; no sweep |
+| 4, 16 | archive retries; ETag verification | no archive, no copy |
+| 5 | OwnerLive on wrong record family | no ownership lookup |
+| 6 | staging overwrites before validation | v2 has no promotion (§4) |
+| 8 | zero CreatedAt still creatable | creates refuse it (§2.1) |
+| 10 | another box's warm cache, same incarnation | `.lineage` carries the storage key, which every push and move changes |
+| 11 | door check and read are separate | the door reads only its record's key |
+| 12 | lease contract incomplete | nothing destructive runs under a lease; replicating into an unreferenced ToGen is harmless |
+| 13 | another box seeds an empty replica | nothing is ever emptied; the current generation is untouched until the commit CAS |
+| 14 | stale worker replays | ToGen is unique per request; the commit is a guarded CAS; re-check under lease |
+| 15 | release on absent request | release only on a readable, well-formed record; hold otherwise |
+| 17 | upload not bound to residence | acceptance CAS binds incarnation, region and generation |
+| 18 | GC deletes a committed upload | an upload is never load-bearing after step 3's checksum, and GC is a day old |
+| 19 | streaming completion, recovery | §5.3 multipart rules; recovery is rollback |
+| 20 | mixed-version writers | the epoch object gates v2 and push (§7) |
+
+## 7. Rollout
+
+1. **Release N** contains all of this, dormant. With no epoch object,
+ every record is legacy, push is refused, and only the new columns, the
+ `.lineage` adoption on legacy rows, the §2.6 retire fix, the zero-
+ CreatedAt refusal and the purge-with-errors change take effect.
+2. Roll **every** box and console to N. Confirm with `carlos system
+ status` and each console's `x-carlos-version`.
+3. Write `control/storage-v2.json` create-only, with `since` = now. From
+ then on, new records are v2 and push works.
+4. **Never roll a box or console back below N once the epoch exists.** An
+ older binary rewriting a record drops `DB` (it doesn't know the field),
+ which would point a pushed or moved instance back at its first
+ generation. The README release note says so in bold, and N's binaries
+ refuse to start if the epoch exists and their own release is older than
+ the epoch's recorded `min_release` (a field written with the epoch).
+
+IAM: the region-host box role already covers the whole own data bucket
+(main.tf:166, :174). The console needs the §5.3 grant in its own region.
Credit: Part B reuses Tony Wood's request record, console door and CLI
shape from `feat/db-push-pull`.