carlos / platform Public

spec: replica ownership rev 3, park a deleted instance's data at delete time

Rev 2 failed Astra review (13 P1s). Paul ruled (R5): fix #199 at delete
time with a host tombstone and an in-region park; push waits for the
move-any-app spec.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 66f1dad366530c85f9c3946792b9b56cbbd62c7b parent ef0b7e4
2 files changed, +242 −398
  • docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md +0 −398
  • docs/superpowers/specs/2026-10-02-replica-ownership-design.md +242 −0

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
deleted file mode 100644
index bba43870c..000000000
--- a/docs/superpowers/specs/2026-10-02-replica-ownership-and-db-push-design.md
+++ /dev/null
@@ -1,398 +0,0 @@
-# Replica ownership (#199) and `carlos db push`
-
-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 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 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. 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`.
diff --git a/docs/superpowers/specs/2026-10-02-replica-ownership-design.md b/docs/superpowers/specs/2026-10-02-replica-ownership-design.md
new file mode 100644
index 000000000..fb8a9de04
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-02-replica-ownership-design.md
@@ -0,0 +1,242 @@
+# Replica ownership (#199): park a deleted instance's data
+
+Design, 2026-10-02, **rev 3**. Closes amadan #199. `carlos db push` is
+**deferred**: it will be designed on top of the move-any-app spec's
+fencing and generation handoff (`design-moves-any-region`), not here.
+
+History. Rev 1 stamped host-keyed storage with an owner and inferred
+ownership at wake time. It failed Astra's review with 16 P1s. Rev 2 rekeyed
+all storage by incarnation and generation. It failed with 13 P1s, mostly
+from old writers erasing a storage discriminator, and from cross-box
+fencing that push needs and moves is now designing. Both revs tried to
+**infer**, at create or wake time, whose data a host's leftovers are. Rev 3
+acts at **delete** time instead, the one moment the owner is known for
+certain.
+
+Rulings (Paul, 2026-10-02), not to reopen:
+
+- **R1.** A recreated host never sees an earlier incarnation's data. That
+ data is kept in-region (parked), and the new instance starts empty.
+- **R2.** Data whose deletion was never recorded is adopted by the record
+ that owns the host today (unchanged behaviour).
+- **R5.** Fix #199 at delete time. Push waits for moves.
+
+## 1. The problem
+
+Storage identity is the host alone:
+
+- the replica at `instances/<host>` (activator.go:138), plus any move
+ staging at `moves/<host>/` (backend_exec.go:1549);
+- the local database at `DefaultDBPath` / `NestedDBPath` (provision.go
+ :1219, :1277);
+- a nested row's working directory `InstanceDir(host)` (provision.go:1270,
+ used as the app's data dir by activator/paths.go:37), which holds app
+ files such as titogo's `instance.key`;
+- the restore-drill verdict, keyed by host (dbverify).
+
+Every record-deleting path leaves all of these behind:
+
+- `handleAPIInstanceDelete` → `DeleteRecord` (provision.go:1071);
+- the sidecar delete (apisidecars.go:347);
+- `PurgeApp` (trash.go:182), called by the console's purge door
+ (ownership.go:442) and the boxes' auto-purge (domains/reconcile.go:232).
+
+Reconcile then drops the registry row (reconcile.go:1155) and nothing
+else. Whoever next creates the host inherits all of it, on wake or through
+`carlos db pull`. The same account does this routinely (titogo's
+per-customer slugs).
+
+Second defect. A host deleted under app A and recreated under app B
+**between two reconcile passes** keeps A's row forever:
+
+- `convergeOwned` runs only for a matching owner (reconcile.go:947);
+- the mismatch branch leaves the row alone (:1031);
+- removal skips the host because it has a record (:1141).
+
+So A's app keeps serving B's host. Sidecars have the same shape: the
+ownership branch is sidecar_reconcile.go:400-412.
+
+## 2. The host tombstone
+
+Every path that deletes an instance or sidecar record first writes a
+**host tombstone**, create-only:
+
+ control/host-tombstones/<host>/<incarnation>.json
+ {"v":1, "host":"…", "account":"…", "app":"…", "kind":"instance|sidecar",
+ "incarnation":"…", "created_at":"<record CreatedAt>",
+ "region":"<record Region>", "deleted_at":"…", "by":"<actor>",
+ "state":"pending"}
+
+- **Incarnation.** `incarnation = provision.Incarnation(rec)`. That is
+ `maintenanceguard.RecordIdentity(account, app, host, CreatedAt)` for a
+ record with a CreatedAt. Otherwise it is a domain-separated legacy
+ identity, `sha256("carlos-legacy-incarnation-v1\0" + account + "\0" +
+ app + "\0" + host)`, so every deletion is recordable.
+- **Ordering.** The tombstone is written **before** the record is deleted.
+ A crash in between leaves a tombstone beside a live record, and §3.1
+ ignores it, because the record with that incarnation still exists.
+- **Writers.** The instance delete door, the sidecar delete door, and
+ `PurgeApp` (one tombstone per instance and sidecar record of the app,
+ written before the prefix delete) in both its console and box callers.
+- **Lifecycle.** A tombstone is never deleted by this change. It moves
+ `pending → parked` (§3.4). It is a permanent, cheap record that a given
+ incarnation of a host was deleted, and where its data went.
+
+No record field changes, so no old writer can erase anything this design
+relies on. The cost: a deletion made by an old binary (before this
+release, or by an un-rolled console or box) writes no tombstone. Its
+leftovers are adopted by the next incarnation, as today (R2). That is the
+rollout window.
+
+## 3. The park
+
+### 3.1 Who acts
+
+The **box in the tombstone's region** owns parking the replica. **Every
+box** parks its own local leftovers for the host: the local database,
+`-wal`, `-shm`, litestream meta directories, and the nested working
+directory. A box that served the host before a move has leftovers too.
+
+A tombstone is **actionable** only when the record for
+`(account, app, host)` is authoritatively absent, or present with a
+different CreatedAt. "Authoritatively" means a successful read that
+returns not-found. A read error, a malformed record, or any doubt means
+**wait**, never act. This is the only ownership question the design asks,
+and it is answered by a direct read of the one key that matters.
+
+The park runs from the converge loop, on the same trigger as the moves
+pass. Tombstones are found by listing `control/host-tombstones/` once per
+pass.
+
+### 3.2 Before touching data: the row is gone, the child is stopped
+
+The park for a host does not start until this box holds **no** registry
+row for that host under the tombstone's account and app, and no running
+child for it. Reconcile removes the row (§4 fixes the case where it
+didn't). The park also requires that the replica's `lock.json` is absent
+or expired. If a holder is still renewing, the park waits for it.
+
+### 3.3 The replica park (tombstone's region only)
+
+Source: every key under `instances/<host>/` and `moves/<host>/`.
+Destination: `parked/<incarnation>/<host>/` in the same bucket.
+
+1. **Manifest absent:** copy every source key except `lock.json` to the
+ destination. Then write `parked/<incarnation>/<host>/manifest.json`,
+ listing every copied key with its source size and the destination's
+ size. Write the manifest only after every copy reports success and
+ every destination size equals its source size. Sizes, not ETags:
+ CopyObject changes a multipart ETag.
+2. **Manifest present** (a retry): skip copying entirely. The manifest is
+ the record of what was parked.
+3. **Delete the source keys named in the manifest, then `lock.json`.**
+ - A source key that is **not** in the manifest means something wrote
+ after the copy. Stop, alert, and leave the tombstone pending for an
+ operator. This cannot happen while §3.2 holds, so it is a defect
+ signal, not a branch to handle.
+ - A retry deletes whatever is left. A delete of an absent key is
+ success.
+
+`MirrorPrefix` is **not** used. It deletes destination keys absent from
+the source, which is the rev 1 archive hazard. The copy is plain per-key
+`Copy`, and it never deletes at the destination.
+
+### 3.4 The local park (every box)
+
+Move the database files and, for a nested row, the working directory,
+into `<DataDir>/.parked/<incarnation>/<host>/` by `rename` on the same
+filesystem. A rename is atomic, and a retry finds the source already gone.
+Nothing local is deleted. Then delete this host's dbverify verdict.
+
+The box records its local park in `<DataDir>/.parked/<incarnation>/
+<host>/done`. When the box in the tombstone's region has finished §3.3,
+it CASes the tombstone to `"state":"parked"`, adding `"parked_at"` and
+`"box"`. The local parks on other boxes don't gate that state; each box
+gates only itself (§3.5).
+
+`.parked` takes disk on the box (today's bdn/ips outage was a full
+volume). The box reports `.parked` bytes in its status, and listing and
+purging parked data is #412's operator tooling.
+
+### 3.5 Creating the host again
+
+Reconcile **does not create a row** for a host while this box has an
+actionable tombstone (§3.1) for it whose incarnation differs from the new
+record's, and either:
+
+- the tombstone is still `pending` and this box is in its region, or
+- this box has local leftovers for the host not yet parked.
+
+The host shows as `provisioning` until both clear. This is the "create
+refuses while pending" rule, enforced where the data is. The console
+create door stays as it is: a create succeeds, and the box defers serving
+it.
+
+Leftovers with **no** tombstone are adopted (R2).
+
+## 4. The stale-row fix
+
+In the **outer** loops of instance reconcile (around reconcile.go:947 and
+the removal at :1141) and sidecar reconcile (around :400-412), the
+removal decision is keyed by **(account, app, host)**, not host. A
+provisioned row whose own `(account, app, host)` record is
+authoritatively absent is retired even when another account's or app's
+record now names the host:
+
+1. Fence the host, then stop the child. Retry next pass until a stop is
+ proven: the unit is inactive, or no process exists.
+2. Delete the row.
+
+Scope guards, unchanged from today:
+
+- only rows this pass minted (`Provisioned`, carrying account and app)
+ are retired;
+- unmanaged, hand-added and platform routes are never touched;
+- the existing unmanaged-route conflict checks stay as they are;
+- a read error on the row's own record means wait (the existing `protect`
+ set).
+
+The new record's row is then created only after §3.5 allows it.
+
+## 5. What this does not do
+
+- **Leftovers from before the release, or from an old binary's delete.**
+ With no tombstone, they are adopted (R2). Writing the release promptly
+ shrinks this window. A one-off operator sweep can tombstone known-dead
+ hosts by hand.
+- **A flat row's app files** written anywhere under the shared `/data`
+ cannot be attributed to a host. They are not parked. The nested layout
+ (#126) is what scopes them.
+- **The source region's replica a completed move leaves behind**
+ (movespass.go never deletes it; the comment at backend_exec.go:285 is
+ stale). It belongs to the live incarnation, so it is not parked here.
+ The moves spec owns cleaning it up.
+- **`carlos db push`.** Deferred, with Tony's branch kept as reference.
+
+## 6. Tests
+
+- Delete, then recreate, for each of: the same app, another app in the
+ same account, another account, a sidecar, and an app purge (console and
+ auto-purge). In every case the new incarnation starts empty: no warm
+ file, no restore, no `carlos db pull`, no old working directory.
+- Delete and recreate within one reconcile pass, under another app: the
+ old row is retired and its child stopped before the new row exists.
+- A crash at every step of §3.3 and §3.4, then a retry: nothing is lost,
+ and nothing is copied twice over a written manifest.
+- A source key written after the manifest: stop, alert, tombstone stays
+ pending.
+- A record read error leaves the tombstone pending and touches nothing.
+- A tombstone beside a still-live record (crash between the tombstone and
+ the record delete): ignored.
+- A legacy record with zero CreatedAt: the legacy incarnation, parked the
+ same way.
+- No tombstone: adopted, as today.
+
+## 7. Rollout
+
+- **IAM:** the box role already covers its whole own data bucket
+ (main.tf:166, :174), including `parked/*`. No console IAM change.
+- **Order:** roll consoles and boxes in any order. A tombstone written by
+ a new console is only acted on by new boxes. An old box ignores it, and
+ reconcile on that old box doesn't defer, so the protection starts when
+ the boxes roll. No record field changes, so a rollback is safe.