carlos / platform Public

design: host ownership rev 5, answers Astra round 4 (D-1 to D-17)

One shared CommitOwnership for the door and the recovery pass, conflicts
classified by acquisition and incarnation; the app's own record at its
apex as an instance block inside the apex object; an observed per-console
drain instead of a check-then-write fence; platform-flagged objects need
the operator at every release and mutation door; route account stamping
inventoried and bound to ownership; index entries keyed by acquisition
id; purge cancels pending acquisitions; alias and apex release recovery;
rename journal phase after SQL finalisation; provisional app rows;
capabilities on record-backed custom domains; index publication on every
routing mutation with publisher fencing and a reader acceptance rule;
box-published route inventory and per-class completion; journaled
quarantine resolution with an AdoptExisting ask; cost split into
ownership writes and total requests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 27903735bd83356873cbfa05423dd3728aeaa59a parent b2cdc50
1 file changed, +671 −661
  • docs/superpowers/specs/2026-10-06-host-ownership-design.md +671 −661

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-06-host-ownership-design.md b/docs/superpowers/specs/2026-10-06-host-ownership-design.md
index 26690dc09..0e9f23d23 100644
--- a/docs/superpowers/specs/2026-10-06-host-ownership-design.md
+++ b/docs/superpowers/specs/2026-10-06-host-ownership-design.md
@@ -1,782 +1,792 @@
# Host-scoped atomic ownership
-Design, 2026-10-06, **rev 4**. Closes amadan #232. Design only; nothing
+Design, 2026-10-06, **rev 5**. Closes amadan #232. Design only; nothing
here is built.
**Ruling (Paul, 2026-10-06): one ownership record per hostname.**
`domains/<host>.json` is the single authoritative ownership object for
-every family. Astra's round 3 (`~/.claude/tmp/hostown-r3.md` on DOCS-T1,
-§3 and §4) recommended it. Rev 4 is a rewrite around that model; revs 1
-to 3 (b70d4d30, 519d25e9, 5c4c31f1) carried a two-family design whose
-cross-family verification this ruling removes. Their review findings are
-cited `A-n`, `B-n`, `C-n` where rev 4 still answers them.
+every family. Astra's round 3 (`~/.claude/tmp/hostown-r3.md` on DOCS-T1)
+recommended it. Rev 4 (b2cdc501) was the rewrite around that model; it
+went to Astra for round 4 (`~/.claude/tmp/host-ownership-review.md`,
+findings cited `D-n`): 5 P1, 11 P2, 1 P3, verdict needs fixes. Rev 5
+answers all seventeen: one shared commit procedure for the door and the
+recovery pass, with conflicts classified by acquisition and incarnation
+(D-1, D-2); the record-backed apex inside the apex object (D-3); an
+observed drain instead of a check-then-write fence (D-4); platform
+authorization in the shared release entry (D-5); the domains pass's
+account stamping inventoried and bound to ownership (D-6); index entries
+keyed by acquisition id (D-7); purge cancels pending acquisitions (D-8);
+alias and apex release recovery (D-9); a journal phase after SQL
+finalization (D-10); `appDeployEmpty` excludes the apex entry (D-11);
+provisional app rows (D-12); capabilities for record-backed custom
+domains (D-13); index publication on every routing mutation with
+publisher fencing (D-14); a box-published route inventory and separate
+completion rules (D-15); journaled quarantine resolution with a new
+lifecycle ask (D-16); the cost table split into minimum writes and total
+requests (D-17). **Rev 5 has not been reviewed.** Revs 1 to 3 carried a
+two-family design this ruling retired; their findings are `A-n`, `B-n`,
+`C-n` where still cited.
Related specs: `2026-10-02-host-lifecycle-claim-design.md` **rev 6.16**
-(1915bac2) on `design-replica-ownership`, library at `internal/hostclaim`
-on `feat/hostclaim-library` (47426cc1); `2026-08-12-domain-attach-design.md`
-(the global hostname claim, #151, #82).
+(1915bac2) on `design-replica-ownership`, library `internal/hostclaim` on
+`feat/hostclaim-library` (47426cc1); `2026-08-12-domain-attach-design.md`.
## 0. Summary
1. **One object.** `domains/<host>.json` owns every hostname: instance
- and replica records, static records (including first-route attaches),
- sidecars, aliases, platform domains, and the minted app apex. It is
- acquired with an `If-None-Match` create. A canary host
- `<name>.<app>.<sqid>.<apps>` is covered by its app's apex ownership
- and has no object of its own.
+ and replica records, static records (first-route attaches included),
+ sidecars, aliases, platform domains, and the minted app apex. Acquired
+ with `If-None-Match`. A canary host `<name>.<app>.<sqid>.<apps>` is
+ covered by its app's apex object. An app's own record at its apex is
+ a sub-acquisition inside the apex object.
2. **Acquisitions** carry an immutable id and family and move
- `pending → committed → releasing → gone`. Every update and delete is
- conditional on the ETag read. A same-owner retry resumes its own
- acquisition or takes over a stale one by CAS, which fences the writer
- it displaces.
-3. **The lifecycle claim** (`control/hosts/<host>.json`) keeps
- execution, parking and retirement. It is bound to the ownership
- acquisition: the create's `op.intent` names the acquisition id, and a
- serving holder's incarnation is the one the committed ownership
- records. It is no longer an ownership authority, and its fencing
- history is never deleted when a hostname is released (C5a).
-4. **Release** follows lifecycle retirement: ownership goes `releasing`
- before the lifecycle delete starts and is deleted only once the
- lifecycle claim is free. A paused creator whose pending reservation was
- reclaimed cannot reach serving: its commit CAS fails and the lifecycle
- recovery pass refuses to complete a create whose acquisition the
- ownership object does not name.
+ `pending → committed → releasing → gone`, every step conditional. A
+ same-owner retry resumes its own acquisition or takes over a stale
+ one by CAS. **One commit procedure** serves the door and the recovery
+ pass, and it checks the app row, the index and the record identity.
+3. **The lifecycle claim** keeps execution, parking and retirement, bound
+ to the acquisition by `op.intent.ownership` and by the holder
+ incarnation. It is not an ownership authority and is never deleted.
+4. **Release** follows lifecycle retirement and is recoverable from a
+ durable `releasing` state. A paused creator whose reservation was
+ reclaimed cannot reach serving.
5. **Detach dispatches by family.** `Route == ""` grants nothing.
-6. **Per-app index.** `apps/<acct>/<app>/owned/<host>.json` lists the
- hosts an app owns, written before commit and removed after release, so
- purge and materialization enumerate completely and fail closed.
-7. **App incarnation.** Every app row carries a random incarnation;
- ownership records it; a late writer for a purged and recreated name is
- refused at commit.
-8. **Fleet folds** filter by family, and a console-derived alias index
- replaces the per-box listing once `domains/` exceeds one page.
-9. **Migration** is a freeze with a demonstrated write fence, a
- reconciliation of records, aliases, app rows and routes, quarantine
- for every collision, and an operator-chosen winner. Destructive
- orphan cleanup is disabled until the backfill marker exists.
+ Objects acquired through the platform door carry `platform: true`,
+ and every door that can release or mutate them requires the operator.
+6. **Index.** `apps/<acct>/<app>/owned/<host>.json`, keyed by acquisition
+ id, lets purge and materialization enumerate completely and fail
+ closed.
+7. **App incarnation** on every app row; a late writer for a purged and
+ recreated name cannot commit.
+8. **Fleet folds** read a console-published index, rebuilt on every
+ routing mutation, with publisher fencing and a reader acceptance rule.
+9. **Migration**: freeze, observed drain, box-published route inventory,
+ reconciliation and quarantine, journaled operator resolution, marker.
+ Destructive orphan cleanup is disabled until the marker.
## 1. The problem, in the code
`handleAPIInstanceCreate` (apiinstances.go:772-792) asks `hostHolder`
-(one `GetRecord` per other app of the account, this box's registry) and
-`hostClaimHolder` (`domains/<host>.json`), then `provision.CreateRecord`
-(provision.go:820) writes create-only on a key that includes the app. Two
-apps racing for one host both pass and both write; the provisioning pass
-keeps the record it lists first (`duplicate host`, provision/reconcile.go:819).
-Alias attach (`domains.AttachClaim`, apidomains.go:444, domainattach.go:153)
-and instance create check each other's object before writing their own:
-the same window. The checks run before later validation (:887, :1092), an
-occupancy oracle. The scan sees only SQL-listed apps of one account and
-costs N sequential GETs.
+(one `GetRecord` per other app, this box's registry) and
+`hostClaimHolder`, then `provision.CreateRecord` (provision.go:820)
+writes create-only on a key that includes the app. Two apps racing for
+one host both write; the provisioning pass keeps the first it lists
+(`duplicate host`, provision/reconcile.go:819). Alias attach
+(apidomains.go:444, domainattach.go:153) and instance create check each
+other's object before writing their own. The checks run before later
+validation (:887, :1092), an occupancy oracle, and cost N sequential GETs.
## 2. Writer inventory
-Every path that puts a hostname on the platform or takes one off, and its
-place in the new model. **Acquires** means it writes the ownership object;
-**derives** means it reads ownership and materialises; **operator** is
-outside the guarantee.
+**Acquires**: writes the ownership object. **Derives**: reads it and
+materialises. **Operator**: outside the guarantee.
-| Writer | Hosts | Writes today | Rev 4 |
+| Writer | Hosts | Writes today | Rev 5 |
|---|---|---|---|
-| Instance create (`handleAPIInstanceCreate`) | `<label>.<m.Domain>` | record | acquires, family `instance` (§4.2) |
-| Instance delete (:1675) | | `DeleteRecord` | releases (§4.4) |
-| Replica create (same door) | | record | acquires, family `instance` |
-| Sidecar create and delete (apisidecars.go:285, :347; `CreateSidecarRecord`, `DeleteSidecarRecord`) | `<name>.<app>.<sqid>.sidecar.internal` | sidecar record | acquires and releases, family `sidecar` |
+| Instance create (`handleAPIInstanceCreate`) | `<label>.<m.Domain>` | record | acquires `instance` (§4.2), or the apex's `instance` block at the app's own apex (§4.6) |
+| Instance delete (:1675), update (PATCH), move, steering | | record | release (§4.4) and mutation; platform-flagged objects need the operator (D-5) |
+| Replica create | | record | acquires `instance` |
+| Sidecar create and delete (apisidecars.go:285, :347) | `<name>.<app>.<sqid>.sidecar.internal` | sidecar record | acquires and releases `sidecar` |
| Canary (`carlos canary`) | `<canary>-<app>.<sqid>.<apps>` | the create door | via the door |
-| Platform apex mint (domains/reconcile.go:266; candidates from routes, :757) | `<app>.<sqid>.<apps>` | registry `Upsert` | derives from the `apex` ownership of (account, app) |
-| Static canary mint (domains/reconcile.go:328-337; `Upsert` rewrites app and account, registry.go:990) | `<name>.<app>.<sqid>.<apps>` | registry `Upsert`, no `byHost` check | derives from the app's `apex` ownership; `byHost` check added |
+| Platform apex mint (domains/reconcile.go:266; `candidateApps` :757) | `<app>.<sqid>.<apps>` | registry `Upsert` | derives from the committed `apex` object; skipped when the object has a committed `instance` block |
+| Static canary mint (domains/reconcile.go:328-337; `Upsert` rewrites app and account, registry.go:990) | `<name>.<app>.<sqid>.<apps>` | registry `Upsert`, no `byHost` check | derives from the app's `apex`; `byHost` check added |
+| **Route account stamping** (domains/reconcile.go:168-193, `resolveRouteAccount` :715, `appKeyOwner` :655) | every route with an app | `reg.SetAccount` from bucket app-prefix evidence | derives: above the floor a route whose host has a committed object is stamped only with that object's account; `owned/` keys are not ownership evidence for `appKeyOwner`; paused during the freeze (D-6) |
| Trash and retired-canary route removal (:207, :356) | | `reg.Delete` | derives, unchanged |
-| Instance and sidecar route mint and removal (provision/reconcile.go:965, :1178; sidecar_reconcile.go:403, :450) | | registry rows | derives from records and lifecycle admission, unchanged |
-| App create, rename, move (server.go:3667, :3707; appsettings.go:802-840, :1305-1350; store.go:1801-1905) | the apex | SQL app row and related rows | acquires, family `apex` (§4.6) |
-| Startup owners backfill (store.go:676, run at server.go:1357 on every start) | the apex | `INSERT OR IGNORE` ops rows | retired above the floor; replaced by `carlos ops apps register` (§4.6, C-1) |
-| `console migrate-app` (cmd/console/main.go:228, `MoveApp`) | the apex | SQL row move | goes through the apex gate's move path (C-1) |
-| Console materialization (materialize.go:391, `PurgeAppRow`) | the apex | releases a trashed row | releases only after the app's `owned/` index is empty (§5) |
-| Domain attach, alias branch (apidomains.go:444; domainattach.go:153; converge, claims.go:150) | any | `domains/<host>.json` | acquires, family `alias` (§4.3) |
-| Domain attach, first-route branch (domainattach.go:183-192) | any | claim then static record | acquires, family `instance` with `first_route` (§4.3) |
-| Platform attach (`--platform`) | deployment zones | the same two branches | the same, `platform` recorded |
+| Instance and sidecar route mint and removal (provision/reconcile.go:965, :1178; sidecar_reconcile.go) | | registry rows | derives from records and lifecycle admission |
+| App create, rename, move (server.go:3667-3720; appsettings.go:802-840, :1305-1350; store.go:1380-1395, :1801-1905) | the apex | SQL rows | acquires `apex` with a provisional row and a journal (§4.6) |
+| Startup owners backfill (store.go:676, server.go:1357) | the apex | `INSERT OR IGNORE` | retired above the floor (C-1) |
+| `console migrate-app` (cmd/console/main.go:228) | the apex | `MoveApp` | runs the apex gate's move path (C-1) |
+| Console materialization (materialize.go:391) | the apex | `PurgeAppRow` | runs the purge `released` phase first (§5) |
+| Domain attach, alias branch (apidomains.go:444; domainattach.go:153; converge :150) | any | `domains/<host>.json` | acquires `alias` (§4.3) |
+| Domain attach, first-route branch (domainattach.go:183-192) | any | claim then static record | acquires `instance` with `first_route` (§4.3) |
+| Platform attach (`--platform`) | deployment zones | the same | the same, `platform: true` |
| Attach rollback (domainattach.go:483-499) | | unconditional deletes | conditional (§4.1) |
| Domain detach (apidomains.go:853; domainattach.go:288-314) | | `DeleteRecord` when `Route ""`, `DetachClaim` | dispatch by family (§4.5) |
-| Claim capabilities and paths (claims.go:302, :398) | | fields of an existing claim | unchanged; refuse unless committed `alias` |
-| Domains pass (`ReconcileAliases`, aliases.go:89-133) | | `route_aliases`, wildcard, catch-all, paths | derives from committed `alias` ownership (§4.8) |
-| Operator routes (cli.go:255, :278) | any | registry on one box | operator; covered by the fleet fold |
+| Claim capabilities and paths (claims.go:302, :398) | | fields of a claim | allowed on committed `alias` and committed `first_route` objects (D-13) |
+| Domains pass (`ReconcileAliases`, aliases.go:89-164) | | aliases, wildcard, catch-all, paths | derives from the index or the fold (§4.8) |
+| Operator routes (cli.go:255, :278) | any | registry on one box | operator; in the route inventory (§8.2) |
| Moves, placement, pools | unchanged host | record fields | no change to ownership |
-| Account migrate (accounts.go:312-470; records copied unchanged :416, refused by `GetRecord` provision.go:1145) | unchanged | copies objects, stamps routes | refused for apps with records; quiesced through admission (§4.7) |
-| App purge (trash.go:182-278; console door ownership.go:442; box auto-purge domains/reconcile.go:232) | all the app's hosts | deletes the app prefix | releases every owned host first; `PurgeApp` keeps `owned/` (§5) |
-| Mail domains (`email/domain-claims/`) | sending domains | its own claim | separate namespace, out of scope |
+| Account migrate (accounts.go:312-470) | unchanged | copies objects, stamps routes | refused for apps with records; no route stamping (§4.7) |
+| App purge (trash.go:182-278; ownership.go:404-450; auto-purge domains/reconcile.go:232) | all the app's hosts | deletes the app prefix | `released` phase first; `PurgeApp` keeps `owned/` (§5) |
+| Mail domains | sending domains | own claim | out of scope |
## 3. The objects
### 3.1 Ownership: `domains/<host>.json`
-Today's domains.Claim, `{schema 1, host, account, app, route,
-created_at, created_by, wildcard, catchall, paths}`, extended with
-additive `omitempty` fields. The key is the same for every family, so one
-`If-None-Match` create decides the host.
+Today's domains.Claim with additive `omitempty` fields. One key per
+hostname.
```
{
- "schema": 1,
- "host": "shop.bab.oncarlos.com",
- "account": "bab", "app": "jam",
- "app_incarnation": "<hex, from the apps row>",
+ "schema": 1, "host": "...", "account": "bab", "app": "jam",
+ "app_incarnation": "<hex>",
"family": "instance" | "sidecar" | "alias" | "apex",
- "acq": {
- "id": "<random 128-bit>",
- "state": "pending" | "committed" | "releasing",
- "started_at": "<RFC3339>",
- "by": "<principal>",
- "record_key": "apps/bab/jam/instances/shop....json", // instance, sidecar
- "incarnation": "<hex>", // instance, sidecar, set at commit
- "release_op": "<lifecycle op id>" // releasing, instance and sidecar
- },
- "first_route": true, // instance acquired through the domains surface
- "platform": true, // acquired through the --platform door
- "route": "...", "wildcard": ..., "catchall": ..., "paths": ... // alias only
+ "acq": { "id": "<random 128-bit>", "state": "pending"|"committed"|"releasing",
+ "started_at": "...", "by": "...",
+ "record_key": "...", "incarnation": "<hex>", "release_op": "..." },
+ "instance": { ...the same acq shape, for the app's own record at its apex... },
+ "first_route": true, "platform": true,
+ "route": "...", "wildcard": ..., "catchall": ..., "paths": ...
}
```
-Rules:
-
- `family`, `acq.id`, `account`, `app`, `app_incarnation`, `first_route`
- and `platform` are **immutable** for the life of the object. A takeover
- (§4.1) replaces the object by CAS and so replaces `acq.id`; nothing
- else does.
-- `acq.state` moves only `pending → committed → releasing`, each by CAS
- on the ETag read, and `releasing` ends in `DeleteIfMatch`. A `pending`
- object may also be deleted by its own writer's rollback or by the
- sweep (§5). Nothing moves backwards.
-- **Held** means the object exists in any state. Only a `committed`
- object is **owned**: materialised by readers, reported as attached,
- eligible for repoint, capability and path changes, and for release.
+ and `platform` are **immutable**. A takeover (§4.1) replaces the whole
+ object by CAS and so replaces `acq.id`; nothing else does.
+- `acq.state` moves `pending → committed → releasing` by CAS and ends in
+ `DeleteIfMatch`. A `pending` object may be deleted by its writer's
+ rollback, by the sweep (§5) or by purge. Nothing moves backwards.
+- **Held**: the object exists in any state. **Owned**: `committed`.
+- `instance` is the sub-acquisition of family `apex` for the app's own
+ record at the apex host (D-3). It has the `acq` shape and its own
+ lifecycle, and is acquired and released by CAS on the apex object. The
+ apex object outlives it: releasing the record keeps the name.
+- `wildcard`, `catchall` and `paths` live on committed `alias` objects
+ and on committed `instance` objects with `first_route` (a record-backed
+ custom domain), exactly the two shapes that carry them today (D-13).
- A legacy object (no `family`) is an alias when `route != ""` and a
- first-route marker when `route == ""`. The backfill stamps every legacy
- object (§8); above the marker none remains.
-- Readers below this release refuse nothing (schema stays 1); the floor
- (§8) guarantees no such reader folds a pending object.
-
-### 3.2 The per-app index: `apps/<acct>/<app>/owned/<host>.json`
-
-`{host, acq_id, family}`. Written **before** the ownership commit and
-deleted **after** the ownership delete, both conditional. An index entry
-without an owned object is harmless (release finds nothing); an owned
-object without an index entry cannot arise except by a crash between
-index write and commit, which the sweep repairs from the full fold (§5).
-Purge and materialization enumerate an app's hosts from this prefix with
-a plain listing and a GET per entry, and **fail closed**: any read error
-aborts the purge step (C-3).
-
-### 3.3 The app row's incarnation
-
-`apps` gains `incarnation TEXT` (random, set on insert) and
-`apex_acq TEXT` (the apex acquisition id, §4.6). Every door reads the
-app's incarnation at its gate and writes it into the acquisition. A
-commit re-reads the row and refuses when the incarnation differs or the
-row is not live (C-3): a writer paused across a purge and a recreate of
-the same name cannot commit against the new app.
-
-### 3.4 The lifecycle claim, `control/hosts/<host>.json` (rev 6.16)
-
-Unchanged in shape. This spec uses it as rev 6.16 specifies: §5.1 create
-(admission registration as a lease, `CreatedAt` assigned by the console,
-CAS from free or absent, `CreateRecord`, CAS to serving, recovery after
-five minutes), §5.2 delete and park, §5.3 purge with its reservation
-journal, drain and purge fence, §6.2a the holderless sweep, §9 adoption,
-§10 the floor. Its holder's incarnation is the binding to ownership:
-`acq.incarnation == holder.incarnation` for every serving host. The
-object is never deleted (C5a), so a released hostname keeps its
-`retired_incarnations`, `quiet_after` and `lost` history. What this spec
-asks of that spec is in §6.
+ first-route marker when `route == ""`; the backfill stamps every one.
+
+### 3.2 The index: `apps/<acct>/<app>/owned/<host>.json`
+
+`{host, acq_id, family}`, **keyed by acquisition** (D-7):
+
+- Created create-only after the pending acquire and before commit. If an
+ entry exists with another `acq_id`: when the ownership object's current
+ `acq.id` is that id, this writer was displaced, roll back; when it is
+ ours (a stale entry from a displaced writer), replace it by CAS on the
+ entry's ETag.
+- **Commit requires** the entry to exist with our `acq_id` (re-read).
+- **Release and purge require** `entry.acq_id == object.acq.id` before
+ acting on an entry; a mismatching entry is deleted and the object is
+ handled from the full fold. An old entry can never authorise releasing
+ a newer acquisition.
+- Deleted after the ownership delete, conditional.
+
+Purge and materialization enumerate from this prefix with a listing and a
+GET per entry, and **fail closed** on any read error (C-3).
+
+### 3.3 The app row
+
+`apps` gains `incarnation TEXT` (random, allocated **before** the apex
+acquisition, D-12), `apex_acq TEXT`, `pending TEXT` (the acquisition id
+while provisional) and `closed TEXT` (an op id while a rename or move
+runs). Doors read incarnation, `pending` and `closed` at their gate and
+again at commit (§4.1 step 4). A provisional or closed row admits no new
+work and reserves the name.
+
+### 3.4 The lifecycle claim (rev 6.16)
+
+Unchanged in shape; used as rev 6.16 specifies (§5.1 create with
+admission lease, §5.2 delete and park, §5.3 purge with reservation,
+drain and fence, §6.2a holderless sweep, §9 adoption, §10 floor). Binding:
+`acq.incarnation == holder.incarnation` for every serving host, and
+`op.intent.ownership == acq.id` for every `creating` op. Never deleted
+(C5a). Asks in §6.
### 3.5 Readers
-- **Boxes.** `ReconcileAliases` derives aliases, wildcards, catch-alls
- and paths from `committed` objects of family `alias` only. The apex
- mint and the static canary mint run only when the `apex` object for
- (account, app) is `committed`, and the canary mint checks `byHost`
- before `Upsert`. Instance and sidecar routes derive from records and
- lifecycle admission as today; boxes never read `instance` or `sidecar`
- ownership. Fold cost and the alias index are §4.8.
+- **Boxes.** `ReconcileAliases` derives from the index (§4.8), or from
+ the fold as fallback, taking committed `alias` objects and the
+ capability fields of committed `first_route` objects. The apex and
+ canary mints need a committed `apex` object; the canary mint checks
+ `byHost`. The account stamping step follows §2's row. Boxes never read
+ `instance` or `sidecar` ownership.
- **Console.** Settings and `domains list` show `pending` as
- "attaching" and `releasing` as "detaching". `SetClaimCapabilities` and
- `SetClaimPaths` refuse anything but a committed `alias`. `apexHolder`,
- `hostHolder` and `ReportApexSquats` are replaced by one GET.
+ "attaching" and `releasing` as "detaching"; capability and path doors
+ accept committed `alias` and committed `first_route` objects;
+ `apexHolder`, `hostHolder`, `ReportApexSquats` become one GET.
## 4. The design
### 4.1 The acquisition protocol
-Every door that puts a hostname on the platform runs the same four
-steps; the family-specific work sits in step 3.
-
-1. **Gate and validation.** Every check the door makes today, in its
- order, including the app gate (which yields `app_incarnation`), shape,
- policy (`domainHostPolicy`), the namespace rule (§4.6), and for
- aliases `deploymentRouteTaken`. No ownership read happens before this
- step completes (A-13, B-14).
-2. **Acquire.** `GET domains/<host>` with its ETag `E0`, classify (§7),
- then one of:
+1. **Gate and validation**, every check in today's order, including the
+ app gate (incarnation, not provisional, not closed), shape, policy,
+ the namespace rule (§4.6) and, for an alias, `deploymentRouteTaken`
+ immediately before the write it guards. No ownership read before this
+ step completes.
+2. **Acquire.** `GET domains/<host>` with `E0`, classify per §7, then:
- **absent:** `Put(..., {family, account, app, app_incarnation, acq:
- {id, pending, started_at, by}}, "")`. Keep `E1`. A conflict re-reads
- once and re-classifies.
- - **own `pending`, own id:** resume with `E1 = E0`.
- - **own `pending`, same family and owner, other id, `started_at`
- older than the acquisition timeout (60 s), and not referenced by a
- lifecycle `creating` op (§4.2):** **takeover.** CAS the whole object
- on `E0` with this request's id and `started_at`; keep `E1`. The
- displaced writer's later commit, rollback and index write fail their
- conditions.
- - **own `committed`, same family:** the idempotent branch of the
- family (repoint, no-op, or "already exists").
- - **anything else:** refuse per §7. No write.
-3. **Write the index entry** `owned/<host>.json {host, acq_id, family}`,
- then do the **family's work** (§4.2, §4.3, §4.6). The family's work
- ends with the object's `acq.incarnation` known where the family has
- one.
-4. **Commit.** Re-read the app row: live, same `app_incarnation`,
- otherwise roll back. `Put(..., {state: committed, incarnation}, E1)`.
- A conflict means a takeover or a sweep moved the object: roll back the
- family's work, refuse "attach in progress, retry".
-
-**Rollback** of a failed acquisition undoes the family's work in reverse,
-then `DeleteIfMatch(owned/<host>, its ETag)`, then
-`DeleteIfMatch(domains/<host>, E1)`. Every delete is conditional
-(`release.DeleteIfMatch`, conditional_delete.go:28) and refuses where the
-backend cannot enforce it. A rollback that cannot finish leaves a
-`pending` object for the sweep (§5).
-
-**Why it is atomic.** There is one key per hostname and one create-only
-write. Two writers for one host, any families, cannot both get `E1`.
-There is no cross-authority verification and no interleaving proof to
-maintain. The remaining races are same-owner retries and reclaimed
-reservations, which the immutable id, the CAS takeover and the lifecycle
-binding (§4.2) handle.
-
-### 4.2 Family `instance` and `sidecar` (record-backed hosts)
-
-The family's work is the lifecycle create protocol, rev 6.16 §5.1, in
-full, with the ownership acquisition bound into it:
+ {id, pending, started_at, by}}, "")`; keep `E1`.
+ - **own `pending`, own id:** resume, `E1 = E0`.
+ - **own `pending`, same family and owner, other id, older than 60 s,
+ not named by a lifecycle `creating` op:** takeover by CAS on `E0`;
+ keep `E1`.
+ - **own `committed`, same family:** the family's idempotent branch.
+ - **own `apex`, instance create at the apex host:** CAS to add
+ `instance: {id, pending}` on `E0` (§4.6); keep `E1`.
+ - **anything else:** refuse per §7.
+3. **Index entry** (§3.2), then the **family's work** (§4.2, §4.3,
+ §4.6).
+4. **Commit**, by the one procedure below.
+
+**`CommitOwnership(host, acqID, I)`** is the single commit for the door
+and the recovery pass (D-1, D-2). In order, every read fresh:
+
+1. `GET domains/<host>` with `E`. Require `acq.id == acqID` (or
+ `instance.id` for an apex sub-acquisition). If `state == committed`
+ with `incarnation == I`: already committed, return success. If
+ `state != pending`: return `reclaimed`.
+2. Read the app row: live, `incarnation == app_incarnation`, not
+ `pending`, not `closed`. Otherwise return `app-gate`.
+3. Read the index entry: `acq_id == acqID`. Absent: create it. Another
+ id that the object does not name: replace. Otherwise return
+ `reclaimed`.
+4. For a record-backed family, `GET` the record: present, its
+ `Incarnation == I`. Otherwise return `no-record`.
+5. CAS `{state: committed, incarnation: I}` on `E`. A conflict re-runs
+ from step 1 once.
+
+**Rollback** undoes the family's work in reverse, then
+`DeleteIfMatch(owned/<host>, its ETag)`, then
+`DeleteIfMatch(domains/<host>, E1)`. Every delete is
+`release.DeleteIfMatch` and refuses where the backend cannot enforce it.
+What is left by a rollback that cannot finish is the sweep's (§5).
+
+**Why it is atomic.** One key, one create-only write. Two writers for
+one host cannot both get `E1`. The remaining races are same-owner retries
+and reclaimed reservations, handled by the immutable id, the CAS
+takeover and the lifecycle binding.
+
+### 4.2 Family `instance` and `sidecar`
+
+The family's work is rev 6.16 §5.1 with the acquisition bound in:
1. **Lifecycle step 0.** `hostclaim.Read`, keep the ETag or absence;
- register the create in `admission.json` as a lease.
-2. **Lifecycle step 1.** Assign `CreatedAt`. The incarnation
- `I = hostclaim.Incarnation(account, app, host, CreatedAt)` is now
- known.
-3. **Lifecycle step 2.** `Update(BeginCreate)` conditional on step 1's
- read, with `op.intent.ownership = acq.id` (§6 ask 1). On
- `ErrConflict`: re-read once; a hold or a purge fence refuses per §7;
- free again restarts from step 1 with a new registration and
- `CreatedAt`, at most once per request. Never wait in the handler.
-4. **Lifecycle step 3.** `CreateRecord`, create-only.
-5. **Commit ownership** (§4.1 step 4) with `acq.incarnation = I`. If the
- CAS fails, the reservation was reclaimed (a takeover or the sweep):
- `Update(CancelCreate)` (legal, the op is still `creating`),
- `DeleteIfMatch` the record, release the registration, refuse. Nothing
- reaches serving under a reservation it does not hold.
-6. **Lifecycle step 4.** `Update(CompleteCreate)`. From here the host is
- serving and the only way back is release (§4.4).
-
-**Binding invariant (O1).** A `serving` lifecycle holder exists only
-while `domains/<host>` is `committed` or `releasing` with
+ register in `admission.json` as a lease.
+2. **Step 1.** Assign `CreatedAt`; `I = Incarnation(account, app, host,
+ CreatedAt)`.
+3. **Step 2.** `Update(BeginCreate)` conditional on step 1's read, with
+ `op.intent.ownership = acq.id` (§6 ask 1). On `ErrConflict`: re-read;
+ a hold or a purge fence refuses per §7; free again restarts from step
+ 1 once with a new registration and `CreatedAt`.
+4. **Step 3.** `CreateRecord`, keep the record ETag `R`.
+5. **Commit** via `CommitOwnership(host, acq.id, I)`:
+ - `success`: continue.
+ - `reclaimed`, `app-gate`, `no-record`: **establish cancellation
+ first** (D-1): `hostclaim.Read`. If the op is `creating` with our op
+ id: `Update(CancelCreate)`, then `DeleteIfMatch(record, R)`, release
+ the registration, roll back, refuse per §7. If the op is `serving`
+ with holder `I`: the recovery pass completed us; re-run
+ `CommitOwnership` once (it returns success when the object is
+ committed for us) and otherwise report "ownership and lifecycle
+ disagree" for the operator and refuse; **never delete the record**.
+ If the claim no longer names us: nothing of ours is live; roll back
+ the object and entry only.
+6. **Step 4.** `Update(CompleteCreate)`. Idempotent: `serving` already is
+ success.
+
+**Binding invariant O1.** A `serving` holder exists only while the
+ownership object is `committed` or `releasing` for that family with
`acq.incarnation == holder.incarnation`. Maintained by: commit before
-`CompleteCreate` (step 5 before 6); the recovery pass's completion rule
-(§6 ask 1): a `creating` claim with its record present is completed only
-if the ownership object's `acq.id` equals `op.intent.ownership` and its
-state is `pending` or `committed`; otherwise the pass cancels and deletes
-the record. And by release (§4.4), which deletes ownership only once the
-lifecycle claim is free.
-
-**A delayed creator** that acquired `pending`, paused, lost its
-reservation (sweep after 60 s, or takeover) and resumes: its
-`BeginCreate` may still succeed if the lifecycle claim is free, and its
-record may land, but its commit CAS fails (step 5) and it cancels. If it
-pauses again between steps 4 and 5 for over five minutes, the recovery
-pass finds `op.intent.ownership` naming an acquisition the ownership
-object no longer has and cancels it. A successor meanwhile meets a
-`creating` claim and is refused "in progress"; it retries. The sweep
-never deletes a `pending` object that a `creating` lifecycle op names
-(§5), so a live creator's reservation is not reclaimed under it.
-
-Sidecars: the same steps with `CreateSidecarRecord` and family
-`sidecar`; `holderKeyMatches` already binds the sidecar key and host.
+complete; the recovery rule below; release deleting ownership only at
+lifecycle free.
+
+**Recovery** (rev 6.16 §5.1 step 5, modified by §6 ask 1): for a
+`creating` op older than five minutes with its record present, the pass
+runs `CommitOwnership(host, op.intent.ownership, I)` **with the same
+procedure and gates as the door**, then `CompleteCreate` on success. On
+`reclaimed`, `app-gate` or `no-record` it cancels: `CancelCreate`,
+`DeleteIfMatch` the record by the ETag it read. The pass runs on the
+console and has the SQL the app gate needs (D-2).
+
+**A delayed creator** that acquired `pending`, paused past 60 s, lost its
+reservation and resumes: `BeginCreate` may succeed and the record may
+land, but `CommitOwnership` returns `reclaimed`, the claim is still
+`creating` with its op id, so it cancels and deletes its own record. If
+it pauses between steps 4 and 5 past five minutes, the recovery pass
+runs the same procedure, gets `reclaimed`, and cancels. If recovery
+committed and completed us while we paused, our step 5 sees `serving`
+with `I` and re-runs the commit, which returns success (D-1). The sweep
+never deletes a `pending` object that a `creating` op names (§5), so a
+live creator's reservation is not reclaimed under it.
+
+Sidecars: the same with `CreateSidecarRecord`.
### 4.3 Family `alias`, and the first-route attach
-**Alias.** Steps 1 and 2 of §4.1; `deploymentRouteTaken` stays
-immediately before the acquire write, as the Task 5 ordering rule
-requires. The family's work is nothing: the index entry, then commit
-(§4.1 step 4), whose app-row re-read is what refuses a writer paused
-across a purge (C-3). Two PUTs on a fresh attach; the committed object
-carries `route`, and `wildcard`, `catchall`, `paths` are written later by
-their own owner-gated CAS, which requires `committed`.
-
-Idempotent branch (own committed `alias`): same route, no write, 200;
-different route, CAS the repoint on `E0` preserving every immutable
-field. This is titogo's reload steady state: one GET per host.
-
-**First-route.** The host becomes record-backed, so the family is
-`instance` with `first_route: true`, and the work is §4.2 for a static
-record (`attachWrote` shape: kind static, no slug, region or pool). The
-flag is what the domains surface dispatches on at detach (§4.5). An own
-committed `instance` without the flag at that host is the instance
-door's, and the attach refuses (§7 row 5). Conversion between alias and
-first-route is detach then attach.
-
-**Platform domains** (`--platform`) take either branch with
-`platform: true` recorded; the operator gate stays name-derived
-(`domainHostPolicy`) and is applied before step 2.
-
-### 4.4 Release (instance and sidecar delete)
-
-Release follows lifecycle retirement and is recoverable. The
-instance delete door, the sidecar delete door, first-route detach
-(§4.5), purge (§5) and `provision.RetireRecord`'s callers all run it.
-
-1. `GET domains/<host>` with `E0`: `committed`, this app, this
- `app_incarnation`, family `instance` or `sidecar`, incarnation `I`.
- Anything else per §7.
-2. `hostclaim.Read` with its ETag: `holder.incarnation == I`, op
- serving. Otherwise refuse (§7 row 7): the record serving is not the
- one this ownership names.
-3. **CAS ownership `committed → releasing`** on `E0`, writing
- `release_op = <new lifecycle op id>`. From here the host is "being
- released" to every door and nothing can repoint or re-attach it.
-4. **Lifecycle §5.2**: `BeginDelete(release_op, ...)` conditional on the
- ETag read in step 2, `DeleteIfMatch` the record, `parking` with
- obligations, each owner's chore, `free`.
-5. When the lifecycle claim is **free**, or names a different holder
- with no op naming `release_op`: `DeleteIfMatch(domains/<host>, E)`,
- then `DeleteIfMatch(owned/<host>)`. The lifecycle claim itself is
- never deleted (C5a).
-
-Recovery is the **ownership sweep** (§5): a `releasing` object whose
-lifecycle claim is still `serving` with holder `I` restarts step 4 with
-`release_op` (the member already asked, durably); one whose lifecycle
-claim is `deleting` or `parking` under `release_op` waits for lifecycle
-recovery; one whose lifecycle claim is free does step 5. A new creator
-for the host meets a held object (`releasing`) and is refused "being
-released" until step 5 completes; a lifecycle claim in `parking` would
-refuse it anyway.
+**Alias.** Steps 1 and 2 of §4.1, `deploymentRouteTaken` immediately
+before the acquire write and only when a write follows (a no-op has no
+write, D-17). The family's work is nothing: index entry, then
+`CommitOwnership` (its app-row step is what refuses a writer paused
+across a purge, C-3). Idempotent branch: same route, 200, one GET;
+different route, CAS the repoint on `E0` preserving immutables, then
+publish the index (§4.8).
+
+**First-route.** Family `instance` with `first_route: true`; the work is
+§4.2 for a static record of `attachWrote` shape (kind static, no slug,
+region or pool). Detach dispatches on the flag (§4.5). An own committed
+`instance` without the flag at that host refuses (§7 row 5). Conversion
+between alias and first-route is detach then attach. Capabilities and
+paths on a committed `first_route` object are set by the existing
+owner-gated CAS doors and derived as today (D-13).
+
+**Platform domains** (`--platform`): either branch with `platform:
+true`. The operator gate is `domainHostPolicy` by name before step 2,
+and the flag is what every later door checks (§4.4).
+
+### 4.4 Release (instance and sidecar)
+
+The shared release entry, run by instance delete, sidecar delete,
+first-route detach, purge and `provision.RetireRecord`'s callers:
+
+0. **Platform authorization** (D-5): after the app gate and before any
+ ownership disclosure, `GET domains/<host>`; if `platform: true` and
+ the principal is not a platform operator, 403 `platformOperatorMsg`.
+ The same check guards every record mutation door (PATCH, move,
+ steering, environment) for a flagged object. The flag is immutable
+ and set only by the operator door, so a member cannot clear it; the
+ name-derived `domainHostPolicy` check stays on the domains doors as
+ today.
+1. The object is `committed`, this app and incarnation, family
+ `instance` or `sidecar` (or the apex's `instance` block), incarnation
+ `I`; else §7.
+2. `hostclaim.Read` with its ETag: holder `I`, op serving; else §7 row 7.
+3. **CAS `committed → releasing`** with `release_op`.
+4. **Rev 6.16 §5.2**: `BeginDelete(release_op)` conditional on step 2's
+ ETag, `DeleteIfMatch` the record, `parking`, chores, `free`.
+5. At lifecycle **free** (or a different holder and no op naming
+ `release_op`): `DeleteIfMatch(domains/<host>)` (or CAS the `instance`
+ block out of the apex object), then the index entry.
+
+Recovery is the sweep's `releasing` rows (§5). A host already under
+`purging` is retired by rev 6.16 §5.3 step 4, never by `BeginDelete`
+(D-8).
### 4.5 Detach dispatches by family
-`handleAPIDomainDetach` and `detachDomain`, after the control-plane gate
-and the name-derived platform gate:
+After the control-plane gate and the name-derived platform gate:
-| `GET domains/<host>` says | Action |
+| Object | Action |
|---|---|
-| absent, another owner, another `app_incarnation`, or `pending` of another request | 404 "not attached" (today's neutrality) |
-| own `alias`, committed | CAS `releasing` on `E0`, then `DeleteIfMatch`, then the index entry. Audit, serial |
-| own `alias`, pending, own request | roll back as §4.1 |
-| own `instance`, `first_route: true`, committed | release (§4.4) |
-| own `instance` without `first_route`, or `sidecar`, or `apex` | 409 "this hostname is an instance (or sidecar, or the app's address) of this app; use its own door" |
-| own, `releasing` | 200 "already detaching" |
-
-`Route == ""` is read by nothing (C-5's root). The `releasing` state is
-what makes a crashed first-route detach finishable: a retry finds
-`releasing` and the sweep drives §4.4 to its end whether or not the
-lifecycle holder still matches, because the intent was recorded before
-the lifecycle delete began.
-
-### 4.6 Family `apex`: app names, and the namespace rule
-
-The apex `<app>.<sqid>.<apps>` is owned by the app, and the ownership
-object is acquired by the console when the name is taken. A canary host
-`<name>.<app>.<sqid>.<apps>` is **covered** by the apex object: boxes
-mint it only when the apex object is committed for (account, app), and
-every door refuses hosts under another app's apex. No per-canary object.
-
-**App create** (`claimApp`):
-
-1. Gate and validation as today.
-2. Acquire `domains/<app>.<sqid>.<apps>` as family `apex`, `pending`
- (§4.1 step 2). A hold by another app or an alias at the apex refuses
- the name (today's `apexHolder` answer, from one GET).
-3. Family work: insert the app row in one transaction with
- `incarnation` and `apex_acq = acq.id`. A unique-constraint failure
- (the name was taken between gate and insert) rolls back the
- acquisition.
-4. Commit (§4.1 step 4), `app_incarnation` now known.
-
-**Rename and move** reserve both names until finished (B-9, C-4):
-
-1. Acquire the **new** apex `pending`, as above.
-2. In one transaction: insert `pending_app_ops {op_id, kind, app_id,
- from, to, journal}` and mark the source row `closed = op_id`. A closed
- row keeps its name and its apex object but **every door refuses new
- work for it** (create, attach, ship, add), and in-flight work is
- drained: the door re-reads `closed` at its commit step and rolls back
- if set. Then re-validate the source is empty (`appDeployEmpty`,
- appsettings.go:832's check, now after the close).
-3. Finalise in one transaction conditional on `op_id`: rename or re-home
- the row, apply the journaled related rows (credentials, grants),
- clear `closed`, delete the op row. Then CAS the **old** apex object
- `releasing` and delete it; commit the new one with the row's
- incarnation.
-4. Compensation, conditional on `op_id`: clear `closed`, delete the op
- row, roll back the new apex acquisition.
-
-The app-name sweep settles pending ops older than a minute by the same
-conditional transactions. `console migrate-app` (cmd/console/main.go:228)
-runs the move path; it does not call `MoveApp` directly (C-1).
-
-**Trash and purge.** A trashed app keeps its apex object until purge
-releases it (§5), so its name stays reserved as today.
-
-**The namespace rule**, from the name and ownership alone:
+| absent, foreign, another `app_incarnation`, or `pending` of another request | 404 "not attached" |
+| own `alias`, committed | CAS `releasing`, `DeleteIfMatch`, index entry, publish index; audit, serial |
+| own `alias`, pending, own request | roll back |
+| own `instance` with `first_route`, committed | §4.4 |
+| own `instance` without the flag, `sidecar`, `apex` | 409 "use its own door" |
+| own, `releasing` | **finish the release** (alias: delete now; instance: the sweep's rule), 200 (D-9) |
+
+`Route == ""` is read by nothing.
+
+### 4.6 Family `apex`
+
+The apex `<app>.<sqid>.<apps>` is owned by the app; a canary host under
+it is covered by the apex object; the app's own record at the apex is
+the object's `instance` block (D-3).
+
+**App create** (D-12):
+
+1. Gate. Allocate `incarnation` and `acq.id`.
+2. Acquire the apex object `pending` (§4.1 step 2), with
+ `app_incarnation` set.
+3. In one transaction insert the row `{incarnation, apex_acq: acq.id,
+ pending: acq.id}`. A unique-constraint failure rolls back the
+ acquisition. A provisional row reserves the name and admits no work.
+4. `CommitOwnership` (its app-row step accepts a `pending` row whose
+ `pending == acqID`), then clear `pending` conditional on it.
+
+**The apex `instance` block.** An instance create whose host is the
+app's own apex finds its own committed `apex`: it CASes `instance: {id,
+pending, started_at}` in (§4.1), runs §4.2 with `CommitOwnership`
+addressing the block, and the mint skips the host while the block is
+committed (`byHost` catches the record's route anyway). Release follows
+§4.4 and ends by CASing the block out; the apex stays. Backfill writes
+apex plus block for the apex-with-own-record shape (D-3).
+
+**Rename and move** (B-9, C-4, D-10, D-11):
+
+1. Acquire the new apex `pending`.
+2. One transaction: `pending_app_ops {op_id, kind, app_id, from, to,
+ old_acq, new_acq, phase: reserved, journal}`; mark the source row
+ `closed = op_id`. A closed row admits no new work, and in-flight work
+ fails its commit on the `closed` re-read. Then re-validate emptiness:
+ `appDeployEmpty` **excluding exactly `owned/<old apex>.json`**, which
+ every app has, and refusing any other key (D-11); `appHasRoutes` as
+ today.
+3. One transaction, conditional on `op_id` and `phase == reserved`:
+ rename or re-home the row, apply the journaled related rows, set
+ `apex_acq = new_acq`, clear `closed`, set `phase = sql-final`. The
+ journal **stays**.
+4. `CommitOwnership(new apex, new_acq)`; CAS the old apex `releasing`,
+ `DeleteIfMatch` it and its entry; publish the index. Then delete the
+ op row, conditional on `phase == sql-final`.
+5. Compensation from `reserved`, conditional on `op_id`: clear `closed`,
+ delete the op row, roll back the new acquisition.
+
+The app-name sweep settles `reserved` ops older than a minute by
+compensation and `sql-final` ops by step 4. `console migrate-app` runs
+the move path.
+
+**Trash and purge.** A trashed app keeps its apex object; purge releases
+it (§5).
+
+**Namespace rule**, from the name and ownership alone:
| Host under `<sqid>.<apps>` | Allowed |
|---|---|
-| `<l>.<sqid>.<apps>` | whatever the ownership object says: an `apex` of app `<l>` admits only `<l>`'s own instance record at that host (the own-app exception, B-10); absent admits any family |
-| `<x>.<app>.<sqid>.<apps>` | nothing through any door; minted only, under a committed apex of `<app>` |
+| `<l>.<sqid>.<apps>` | what the object says: an `apex` of app `<l>` admits only `<l>`'s own record as its `instance` block; absent admits any family |
+| `<x>.<app>.<sqid>.<apps>` | nothing through any door; minted under a committed apex |
| a manifest `Domain` of `<app>.<sqid>.<apps>` or deeper | refused for any app but `<app>` |
-**Apps with no SQL row.** The box mints an apex for any (account, app)
-with routes, including bucket-only apps. Above the marker a mint needs a
-committed `apex` object, which only the console writes, so bucket-only
-apps stop minting until registered. `carlos ops apps register <sqid>
-<app>` is a **migration-only registration**, admitted during the freeze
-(C-6): it acquires the apex object requiring exact same-owner evidence
-(records or routes for exactly that account and app, no other authority
-at the host) and inserts the row through the apex gate. The startup
-owners backfill (store.go:676) is retired above the floor (C-1).
+**Apps with no SQL row.** Above the marker a mint needs a committed
+`apex`, which only the console writes. `carlos ops apps register <sqid>
+<app>` is a migration-only operation admitted during the freeze (C-6): it
+requires the route inventory (§8.2) and the records to name exactly that
+account and app and no other authority at the apex, then runs the app
+create steps. The startup owners backfill is retired above the floor.
### 4.7 Moves, placement, migrate
-Moves and placement change `op`, `holder.location` and `Region`, never
-the owner. A host under a move is `committed` and its lifecycle claim is
-not serving; release refuses until the move ends.
-
-`accounts migrate`: closes the source app's admission and drains its
-registrations per rev 6.16 §5.3 step 1b (not a lease wait, B-13), lists
-`instances/` and `sidecars/`, refuses with a sentence naming this spec if
-any record exists, otherwise migrates release objects and pointers only,
-without stamping routes, and reopens admission. Ownership transfer across
-accounts (record identity, location, sidecar hosts, aliases) is a
+Moves and placement never change the owner; release refuses while a move
+runs. `accounts migrate` closes the source app's admission and drains per
+rev 6.16 §5.3 step 1b, lists `instances/` and `sidecars/`, refuses with
+any record, otherwise migrates release objects and pointers only, without
+route stamping, and reopens admission. Cross-account transfer is a
follow-up.
-### 4.8 Fleet folds
+### 4.8 Fleet folds and the published index
-Today every box lists `domains/` through `ClaimCache` on the fast-converge
-leg (one `ListMod` per pass; `ListObjectsV2` pages at 1000 keys). After
-rev 4 the prefix holds every hostname: on flagship, aliases plus
-instances, sidecars and apexes, several thousand objects and several
+Today every box lists `domains/` through `ClaimCache` each pass. After
+rev 5 the prefix holds every hostname: several thousand objects, several
pages per pass per box.
-- **Family filter.** Boxes derive only from `alias` and `apex` objects;
- `instance` and `sidecar` objects are skipped on the key list's body
- read, but still listed.
-- **Derived alias index.** The console's ownership sweep writes
- `control/alias-index.json` `{generation, serial, aliases: [committed
- alias objects' routing fields], apexes: [(account, app)]}` on every
- alias or apex commit and release (the same moments that bump the
- serial). Boxes fold the index with a conditional GET (`If-None-Match`
- on its ETag, 304 in the steady state) instead of listing `domains/`.
- The full fold remains the sweep's consistency check and the fallback
- when the index is missing or older than ten minutes.
-- **Switch.** The index is written from day one; boxes prefer it once
- `domains/` exceeds one page, which the sweep records in the index.
-
-Readers of the index are the alias derivation and the two mints. Nothing
-box-side writes `domains/`.
+- **The index** `control/alias-index.json`: `{generation, serial,
+ published_at, complete: true, aliases: [routing fields of committed
+ alias objects and the capability fields of committed first_route
+ objects], apexes: [{account, app, has_instance}]}`.
+- **Publisher** (D-14): the console, after **every routing mutation**
+ (commit, release, repoint, capability or path change, apex commit or
+ release, rename finalisation), rebuilds the index **whole** from the
+ full fold and publishes it by CAS on the index's ETag with
+ `generation = old + 1` and `serial` = the control serial it bumped. A
+ publisher that loses the CAS re-reads and rebuilds. The sweep also
+ rebuilds every minute and whenever the fold differs.
+- **Readers**: a box fetches the index with `If-None-Match`; on 304 it
+ keeps the last body and its `published_at`. It **accepts** an index
+ whose `serial >= ` the control serial the box is converging to and
+ whose `published_at` is younger than ten minutes; otherwise, and on any
+ error, it folds `domains/` directly for this pass. Families other than
+ `alias` and `apex` are skipped on the key list's body read in the
+ fallback.
+- Nothing box-side writes `domains/` or the index.
## 5. Crash recovery and the sweeps
-Two console passes, each every minute, each reading exact objects with
-ETags before acting. `ClaimCache` and listings are discovery only.
+Two console passes, every minute, reading exact objects with ETags before
+acting; `ClaimCache` and listings are discovery only.
-**The ownership sweep**, over `domains/`:
+**The ownership sweep:**
-| State found | Lifecycle says | Action |
+| State | Lifecycle, row, purge say | Action |
|---|---|---|
-| `pending`, older than 60 s | absent or free, or a `creating` op whose `intent.ownership` is a different id | `DeleteIfMatch` the object and its index entry: a request that did not finish. Nothing anyone was told succeeded is removed |
-| `pending` | `creating` naming this `acq.id` | leave it: the lifecycle recovery pass owns it (complete or cancel) |
-| `pending`, family `apex`, older than 60 s | | row exists with `apex_acq == acq.id` and live: commit on its behalf (the row is the family's atomic step, and the id ties them). Else delete |
-| `committed`, family `instance` or `sidecar` | no holder, or holder incarnation differs, and no `releasing` | report "committed ownership without its holder" for the operator; never delete (a record may be mid-create or the lifecycle claim mid-recovery) |
-| `releasing` | serving with holder `I` | restart §4.4 step 4 with `release_op` |
-| `releasing` | `deleting`, `parking` under `release_op` | wait |
-| `releasing` | free, or another holder and no op naming `release_op` | §4.4 step 5 |
-| `committed` whose app row is gone or has another incarnation | | report only; SQL absence is not proof (direct-bucket apps) |
-| index entry without an object | | delete the entry |
-| object without an index entry | | write the entry |
-
-**The lifecycle recovery pass** (rev 6.16 §5.1 step 5) gains the
-ownership check of §4.2 (ask 1) and two gates: no destructive orphan
-deletion before `control/hosts-backfill.json` exists, and none for a
-quarantined host (C-2, B-3).
-
-**Purge** (console door, rev 6.16 §5.3 phases): after `retired`, a new
-phase `released`: list `owned/` (fail closed), for each entry `GET
-domains/<host>`; owned by this app and incarnation: §4.4 steps 3 to 5
-(instance, sidecar), or `releasing` then delete (alias, apex). Then
-`done`. `PurgeApp` deletes everything under the app prefix **except
-`owned/`**, which the `released` phase empties last. Box auto-purge, where
-it still runs, therefore leaves the index behind, and **console
-materialization** (materialize.go:391) runs the `released` phase for a
-trashed row whose prefix is otherwise gone **before** `PurgeAppRow`.
-The name is released only when `owned/` is empty (C-3, B-8). A late
-alias writer for the purged app fails its commit on `app_incarnation`
-(§3.3).
+| `pending` older than 60 s | absent or free lifecycle, or `creating` naming another id | delete object and entry |
+| `pending` | `creating` naming this id | leave: lifecycle recovery owns it |
+| `pending` | a `purging` fence at the host, or the app has a purge reservation | delete object and entry: the create cannot proceed (D-8) |
+| `pending` `apex` older than 60 s | row with `apex_acq == id`, `pending == id` | `CommitOwnership`, clear `pending`. Else delete object and entry |
+| `committed` `instance`/`sidecar` | no holder or another incarnation, no `releasing` | report; never delete |
+| `releasing` `instance`/`sidecar` | serving, holder `I` | restart §4.4 step 4 with `release_op` |
+| `releasing` `instance`/`sidecar` | `deleting`/`parking` under `release_op`, or `purging` | wait |
+| `releasing` `instance`/`sidecar` | free, or another holder and no op naming `release_op` | §4.4 step 5 |
+| `releasing` `alias` or `apex` | (no lifecycle holder) | `DeleteIfMatch` object, entry; publish index (D-9). For an apex, only if no `sql-final` op names it as `new_acq` |
+| any | row gone or other incarnation | report only |
+| entry without object, or `acq_id` mismatch | | delete entry |
+| object without entry | | create entry |
+
+**The lifecycle recovery pass** (rev 6.16 §5.1 step 5) uses
+`CommitOwnership` (§4.2) and two gates: no destructive orphan deletion
+before `control/hosts-backfill.json`, none for a quarantined host.
+
+**Purge** (console door, rev 6.16 §5.3): a phase `released` between
+`retired` and `done`. List `owned/` (fail closed); for each entry with
+`acq_id == object.acq.id` and this app and incarnation: `pending` →
+delete object and entry (the lifecycle fence stays until `done`, D-8);
+committed `instance`/`sidecar` → the host is already `purging`, so its
+record was retired by §5.3 step 4; once the claim leaves `purging`
+(parking, then free), delete object and entry; committed `alias`/`apex`
+(and the apex's block) → `releasing`, delete. Then `done`. `PurgeApp`
+deletes everything under the prefix **except `owned/`**. Box auto-purge
+leaves the index; **console materialization** runs `released` for a
+trashed row whose prefix is otherwise gone before `PurgeAppRow`. The
+name is released only when `owned/` is empty (B-8).
**Writer crash windows:**
| Crash after | Left | Cleared by |
|---|---|---|
-| §4.1 step 2 (pending, nothing else) | pending object | sweep, after 60 s |
-| index write, before lifecycle `BeginCreate` | pending object and entry | sweep |
-| `BeginCreate`, before the record | `creating` claim naming the acquisition; pending object | lifecycle recovery cancels after five minutes (record absent); the sweep then deletes the pending object |
-| the record, before the ownership commit | `creating`, record, pending | lifecycle recovery: ownership names this acquisition and is pending, so it **commits the ownership object first, then completes** (ask 1); or, if the object no longer names it, cancels and deletes the record |
-| the ownership commit, before `CompleteCreate` | `creating`, record, committed | lifecycle recovery completes |
-| §4.4 step 3, before `BeginDelete` | `releasing`, serving | sweep restarts the lifecycle delete |
-| any lifecycle delete step | | lifecycle recovery owners |
-| lifecycle free, before §4.4 step 5 | `releasing`, free | sweep deletes |
-| rename or move, any step | closed source row, pending op, pending new apex | app-name sweep, conditional on `op_id` |
-| purge, any phase | the purge journal's phase | the purge recovery pass, as rev 6.16 §5.3 |
+| acquire | pending object | sweep |
+| index entry, before `BeginCreate` | pending, entry | sweep |
+| `BeginCreate`, before the record | `creating` naming the id; pending | lifecycle recovery cancels (record absent); sweep deletes pending |
+| record, before commit | `creating`, record, pending | recovery runs `CommitOwnership` then completes, or cancels and deletes the record |
+| commit, before `CompleteCreate` | `creating`, record, committed | recovery completes |
+| §4.4 step 3, before `BeginDelete` | `releasing`, serving | sweep restarts |
+| lifecycle free, before step 5 | `releasing`, free | sweep deletes |
+| alias detach after `releasing` | `releasing` alias | sweep deletes; a retry finishes it |
+| app create after the row, before commit | provisional row, pending apex | sweep commits (row evidence) |
+| rename `reserved` | closed source, pending new apex, journal | sweep compensates |
+| rename `sql-final` | renamed row, journal, old apex committed, new apex pending | sweep runs step 4 (D-10) |
+| purge, any phase | journal phase | purge recovery (rev 6.16 §5.3) |
## 6. What this asks of the lifecycle claim spec (rev 6.16)
-Exactly these, for platform-1e:
-
-1. **§5.1 create, binding.** `BeginCreate` records `op.intent.ownership
- = <acquisition id>` (a map entry; the library's `BeginCreate` takes it
- as a parameter or the door adds it to the intent). The step 4 CAS to
- serving, by the door and by the recovery pass, requires
- `domains/<host>` to exist with `acq.id == op.intent.ownership` in
- state `pending` or `committed`; the recovery pass commits a `pending`
- object first (CAS on its ETag), then completes. Otherwise it cancels
- the create and deletes the record with `DeleteIfMatch`.
-2. **§5.1 step 5, gates.** Orphan deletion is disabled until
- `control/hosts-backfill.json` exists, and never acts on a host listed
- in `control/hosts-quarantine.json`.
-3. **§9 adoption.** Before the marker, only the backfill verb adopts
- (§8). Reconcile, the doors, purge and the recovery pass refuse a host
- with no claim and log it. Above the marker no such host exists, so the
- routine is unchanged for them.
-4. **§5.3 purge.** A phase `released` between `retired` and `done`, as
- §5 describes, and `PurgeApp` keeps `owned/`.
-5. **Nothing else.** No new field, no change to C1 to C7, to admission
- (C4 may optionally also check O1), to parking, to the holderless
- sweep, or to the floor. Release never deletes the lifecycle claim.
+For platform-1e, exactly these:
+
+1. **§5.1 binding and commit.** `BeginCreate` records
+ `op.intent.ownership = <acquisition id>` (a map entry; `BeginCreate`
+ takes it as a parameter). The CAS to serving, by the door and by the
+ recovery pass, is preceded by the console's `CommitOwnership`
+ (§4.1), which checks the ownership object, the app row, the index
+ entry and the record's incarnation. On `reclaimed`, `app-gate` or
+ `no-record` the pass cancels the create and deletes the record by the
+ ETag it read. The pass therefore runs on the console with SQL.
+2. **§5.1 step 5 gates.** No orphan deletion before
+ `control/hosts-backfill.json` exists; none for a host in
+ `control/hosts-quarantine.json`.
+3. **§9 adoption.** Before the marker only the backfill verb adopts;
+ other actors refuse a host with no claim and log it.
+4. **§5.3 purge.** A phase `released` between `retired` and `done`
+ (§5), including cancellation of `pending` acquisitions under the
+ purge fence; `PurgeApp` keeps `owned/`.
+5. **§9, operator resolution (D-16).** A transition `AdoptExisting`,
+ from `free` only, run by `carlos ops hosts resolve`: installs a named
+ existing record as holder with `touched.complete: false` and the
+ discovery obligations `Adopt` writes, keeping `retired_incarnations`,
+ `quiet_after` and `lost`. The resolve verb retires each loser whose
+ record is the current holder through §5.2 (from serving) before it
+ runs.
+6. **Nothing else.** No new field, no change to C1 to C7, admission (C4
+ may optionally check O1), parking, the holderless sweep or the floor.
## 7. Privacy
-**Placement.** Every ownership read is §4.1 step 2, after every other
-validation; a request with an invalid later field answers its 400 before
-any ownership read (C's placement note, apiinstances.go:736 moves below
-:1092).
+**Placement.** Every ownership read is §4.1 step 2 or §4.4 step 0, after
+every other validation; an invalid later field answers 400 first
+(apiinstances.go:736 moves below :1092).
-**Response mapping**, in order, first match wins, for every door.
-"Foreign" means another account, no account, or an object that does not
-decode.
+**Response mapping**, in order, first match wins. "Foreign": another
+account, no account, or an object that does not decode.
-| # | Observation | Instance, sidecar and app-name doors | Alias and detach doors |
+| # | Observation | Instance, sidecar, app-name doors | Alias and detach doors |
|---|---|---|---|
-| 1 | a read failed (bucket, registry, SQL) | 502 "could not check the host, try again" | 502, the same |
-| 2 | the object does not decode | 500 "could not check the host"; operator log | the same |
-| 3 | foreign, any family, any state; or a lifecycle purge fence; or an operator route naming another account or none | 409 `hostUnavailableMsg`, byte for byte | 409 `hostUnavailableMsg` (attach); 404 "not attached" (detach) |
-| 4 | own app, same family, committed | 409 "instance already exists" (today) | 200 no-op or repoint |
-| 5 | own app, other family or `first_route` mismatch | 409 naming the conflict and the door that frees it | the same |
-| 6 | own app, `pending` of another request, or `releasing` | 409 "attach in progress, retry" / "being released, retry later" | the same |
-| 7 | own app, committed, but the lifecycle holder does not match (`acq.incarnation`) | 409 "this address needs an operator's attention" | the same |
-| 8 | another app of the caller's account, committed | 409 naming the app, as `hostHeldSentence` does today | 409 `hostUnavailableMsg` (apidomains.go:458's neutrality kept) |
-| 9 | another app of the caller's account, `pending` or `releasing` | 409 naming the app and "is being changed; try again later" | 409 `hostUnavailableMsg` |
-| 10 | an operator route whose account column is the caller's, no object | 409 "operator repair", as today | 409 `hostUnavailableMsg` |
-| 11 | the freeze (§8) | 503 `msgOwnershipFreeze` | 503 |
-
-Nothing a member receives distinguishes a foreign hold of one family
-from another, and read failures are never occupancy answers. Sweeps, the
-backfill and the quarantine file name accounts only in operator output.
+| 1 | a read failed | 502 "could not check the host, try again" | 502 |
+| 2 | the object does not decode | 500; operator log | 500 |
+| 3 | `platform: true`, principal not an operator | 403 `platformOperatorMsg` (D-5) | 403 |
+| 4 | foreign, any family or state; purge fence; operator route naming another account or none | 409 `hostUnavailableMsg` | 409 (attach); 404 "not attached" (detach) |
+| 5 | own app, same family, committed | 409 "instance already exists" | 200 no-op or repoint |
+| 6 | own app, other family or flag mismatch | 409 naming the conflict and the door that frees it | the same |
+| 7 | own app, `pending` of another request, or `releasing` | 409 "in progress, retry" / "being released" | the same; detach finishes a release |
+| 8 | own app, committed, lifecycle holder does not match | 409 "needs an operator's attention" | the same |
+| 9 | another app of the account, committed | 409 naming the app (today's sentence) | 409 `hostUnavailableMsg` |
+| 10 | another app of the account, `pending` or `releasing` | 409 naming the app, "being changed" | 409 `hostUnavailableMsg` |
+| 11 | operator route whose account is the caller's, no object | 409 "operator repair" | 409 `hostUnavailableMsg` |
+| 12 | the freeze | 503 `msgOwnershipFreeze` | 503 |
+
+Row 3 precedes row 4 only because the 403 is answered from the caller's
+own app gate plus a flag on an object the caller's own account holds; a
+foreign object never reaches row 3 (it is foreign first, by account).
+Read failures are never occupancy answers. Sweeps, backfill and
+quarantine name accounts only in operator output.
## 8. Migration
-### 8.1 Order
-
-1. Land the lifecycle library with §6's asks. Ship rev 4's code dormant
- below the floor: every door runs today's path; the verbs refuse.
-2. Roll consoles, then boxes; publish the floor. **The freeze begins**
- with the floor: above it and before the marker every create, attach,
- detach, app-name and delete door answers 503 `msgOwnershipFreeze`.
-3. **Drain** (B-2, C's cutover note). The backfill verb writes
- `control/ownership-freeze.json`. Every new-binary door re-reads that
- object **immediately before** its record, claim or row write and
- refuses if present, so a handler that chose today's path before the
- floor and paused cannot write after the fence. The verb then waits
- twice the longest handler deadline (`instanceStoreTimeout` and the
- browser form's) before listing. Old binaries cannot run above the
- floor (rev 6.16 §10, including boot recovery).
-4. Backfill (§8.2), dry then live, until two clean passes write
- `control/hosts-backfill.json`. The freeze lifts; the doors run §4.
+### 8.1 Order, freeze and drain
+
+1. Land the lifecycle library with §6's asks. Ship rev 5 dormant below
+ the floor.
+2. Roll consoles, then boxes; publish the floor. **The freeze** starts:
+ above the floor and before the marker every create, attach, detach,
+ app-name, delete and mutation door answers 503 `msgOwnershipFreeze`
+ on entry, and the domains pass's account stamping and materialization
+ pause (they read `control/ownership-freeze.json` each loop).
+3. **Observed drain** (D-4). Each console keeps an in-process count of
+ handlers that may mutate a record, claim or app row, incremented at
+ entry and decremented when the handler returns, covering private-store
+ work and SQL. The backfill verb writes the freeze object, then polls
+ every console in the fleet inventory at `/api/system/ownership-drain`
+ until each reports zero; a console that does not answer blocks the
+ verb (fail closed); a console that reports non-zero for ten minutes
+ is restarted by the operator, which empties its count. Only then does
+ the verb list. There is no check-then-write fence: nothing above the
+ floor writes except through counted handlers and paused passes, and
+ the count is observed at zero on every console before inventory.
+4. Backfill (§8.2), dry then live, two clean passes, marker. The freeze
+ object is deleted and the doors switch.
### 8.2 Backfill: `carlos ops hosts backfill [--dry-run]`
-Runs on a console above the floor with the fence in place. Inputs: every
-`apps/*/*/instances/*.json` and `apps/*/*/sidecars/*.json`; every
-`domains/*.json`; every app row (live and trashed) of every account the
-console knows; every route a live box publishes (`edgesfile`); every
-lifecycle claim.
-
-For each hostname that appears in any input, build its **candidates**:
-records (one per app), a legacy alias or first-route object, an apex
-(from an app row whose name is the host's label under its account's
-sqid), and routes. Then:
-
-- **Exactly one authority, consistent:** write the ownership object
- `committed` (create-only, or CAS onto the legacy object preserving its
- alias fields), write the index entry, and for a record with no
- lifecycle claim run `claim.Adopt`. Consistent means: a record's host
- obeys the namespace rule and its lifecycle holder, if any, names its
- incarnation; a legacy alias has no record and no live app row at its
- label other than its owner (B-4); an apex has no other authority at the
- host; a first-route marker has exactly its owner's static record.
-- **Anything else is quarantined** in `control/hosts-quarantine.json`
- with every key involved: two or more records; a record and an alias; a
- record or alias at another app's apex; a lifecycle holder naming a
- different incarnation; a marker without its record; an object that
- does not decode. A quarantined host is held (§7 row 3) and the
- recovery pass never deletes there (§6 ask 2). The operator picks the
- winner with `carlos ops hosts resolve <host> --keep <key>`, which
- deletes the losers' records or objects conditionally and re-runs the
- host.
-- **Apps with no SQL row** that have records or routes block completion
- until `carlos ops apps register` (§4.6) or the operator deletes them.
-- **Legacy aliases whose owner has no app row** are reported and block
- completion until detached by an operator (purge inheritance).
-- Every app row gets `incarnation` and, once its apex is acquired,
- `apex_acq`.
-
-**Completion** needs two consecutive clean passes under the fence: every
-hostname has exactly one committed object and an index entry, every
-record-backed host has a lifecycle claim naming its incarnation, no
-quarantine, no undecodable input, no unregistered app. Then the marker is
-written with counts, the floor version and the time, the freeze object is
-deleted, and the doors switch. Lowering the floor deletes the marker
-first.
+**Inputs**: `apps/*/*/instances/*.json` and `sidecars/*.json`;
+`domains/*.json`; every app row of every known account; every lifecycle
+claim; and the **route inventory** (D-15): each box publishes
+`control/routes/<box>.json` `{box, published_at, routes: [{host, app,
+account, kind, platform, canary, provisioned}]}` from its registry every
+pass. The verb requires an inventory younger than two passes from every
+live box; otherwise it blocks. `edgesfile` is not used for identity.
+
+**Reconciliation**, per hostname in any input, by class:
+
+- **Authoritative hosts** (a record, a legacy object, or an app row's
+ apex): exactly one authority, consistent → write the object committed
+ (create-only, or CAS onto the legacy object preserving alias fields),
+ the entry, and for a record with no lifecycle claim run `claim.Adopt`.
+ Apex plus the app's own record → apex object with a committed
+ `instance` block. Consistent means: the namespace rule holds; a
+ lifecycle holder, if any, names the record's incarnation; a legacy
+ alias has no record and no app row at its label other than its owner
+ (B-4); a first-route marker has exactly its owner's static record.
+- **Derived canaries** (`<x>.<app>.<sqid>.<apps>` routes): must belong,
+ by the inventory's account and app, to a committed apex; otherwise
+ quarantined. They get no object.
+- **Operator routes** (inventory routes with no authority): listed for
+ the operator; they block completion only when they collide with an
+ authority.
+- **Free lifecycle claims** with no authority: history, listed, never an
+ owner, never blocking.
+- **Quarantine** everything else in `control/hosts-quarantine.json`
+ with every key: two or more records; record and alias; record or alias
+ at another app's apex; a holder naming another incarnation; a marker
+ without its record; an undecodable object. Quarantined hosts are held
+ (§7 row 4) and exempt from orphan deletion.
+- Apps with no row that have records or routes block completion until
+ registered (§4.6) or deleted; legacy aliases whose owner has no row
+ block until detached.
+
+**Resolution** (D-16): `carlos ops hosts resolve <host> --keep <key>`
+writes `control/hosts-resolve/<host>.json {keep, losers, phase}` and
+drives: for each loser whose record is the lifecycle holder, rev 6.16
+§5.2 from serving (parking, free); `DeleteIfMatch` each loser's record or
+object; then, if the lifecycle claim is free, `AdoptExisting(keep)`
+(§6 ask 5) or, if absent, `Adopt`; then the ownership object; then
+remove the host from quarantine. Phases are replayable by the sweep.
+
+**Completion**: two consecutive clean passes under the drained freeze:
+every authoritative host has one committed object and entry; every
+record-backed host a lifecycle claim naming its incarnation; every canary
+under a committed apex; no quarantine, undecodable input, unregistered
+app or stale inventory. Then the marker. Lowering the floor deletes the
+marker first.
### 8.3 Mixed versions and cross-account
-Below the floor old and new consoles run today's paths and the race is
-open; above it no old binary runs. Backfill writes ownership for records
-under accounts the console does not know (from the bucket) and never
-rewrites, re-homes or deletes a record; collisions involving them are
-quarantined like any other. No door's refusal names another account.
+Below the floor the race is open; above it no old binary runs (rev 6.16
+§10). Backfill writes ownership for records under unknown accounts and
+never rewrites, re-homes or deletes a record. No refusal names another
+account.
## 9. Cost
-Bucket requests per logical operation (C-7). "Fold" means
-`edgesfile.DeploymentRoutes`: box status reads plus one GET per live
-box's edges file.
+**Minimum ownership writes** per operation, then **total bucket
+requests** including the fold (`DeploymentRoutes`: a box-status listing,
+an edges listing, one GET per live box file), index publication (one
+fold rebuild and one CAS), lifecycle registration (one CAS to add, one
+to remove) and pagination (D-17).
-| Path | Today | Rev 4 |
-|---|---|---|
-| Instance create, N apps in the account | registry, N GETs, 1 GET, 1 PUT | 1 GET + 1 PUT (acquire), 1 PUT (index), lifecycle step 0 (1 GET, lease PUT), 1 CAS, 1 record PUT, 1 CAS (commit), 1 CAS (complete). The fold only when the namespace rule admits an unowned platform host. Independent of N |
-| Instance delete | 1 DELETE | 1 GET, 1 lifecycle GET, 1 CAS, lifecycle delete's own writes, 1 DELETE, 1 DELETE |
-| Alias attach, no-op | 1 GET | 1 GET |
-| Alias repoint | 1 GET, 1 PUT | 1 GET, 1 PUT |
-| Alias fresh attach | 1 GET, 1 PUT, the fold | 1 GET, 1 PUT, 1 PUT (index), 1 CAS, the fold |
-| Alias detach | 1 GET, 1 DELETE | 1 GET, 1 CAS, 2 DELETE |
-| First-route attach | 1 GET, 1 PUT, 1 PUT | the instance create path with `first_route` |
-| App create | N GETs | 1 GET, 1 PUT, 1 PUT, 1 CAS, SQL |
-| Rename, move | N GETs | the above, plus 1 CAS and 1 DELETE for the old apex |
-| Ownership sweep | | one `ListMod` per page per minute; a GET per changed or pending object; the index PUT on change |
-| Boxes | one `ListMod` per page per pass | one conditional GET of the alias index per pass (304); the full fold only as fallback |
-| Purge | app prefix listing | plus one listing of `owned/` and a GET and release per host |
-| Backfill | | one-off: the listings, one PUT and one index PUT per host, one `Adopt` per record |
-
-Removing the per-app scan does not make create constant in bucket
-requests; it makes it independent of account size and of fleet size
-except where the fold is consulted.
+| Path | Today (requests) | Rev 5 ownership writes | Rev 5 total requests |
+|---|---|---|---|
+| Instance create, N apps, F live boxes | N + 3 | 2 (acquire, commit) + 1 entry | about 9 + registration 2; + fold (2 + F) only when the namespace rule admits an unowned platform host |
+| Instance delete | 1 | 1 CAS + 2 deletes | about 6 + lifecycle delete's writes + index publish (fold + 1) |
+| Alias no-op re-attach | 1 + fold | 0 | 1 (no write follows, so no fold) |
+| Alias repoint | 1 + fold + 1 | 1 | 2 + fold + publish |
+| Alias fresh attach | 1 + fold + 1 | 2 + 1 entry | 4 + fold + publish |
+| Alias detach | 2 | 1 CAS + 2 deletes | 4 + publish |
+| App create, rename | N | 2 to 4 | 4 to 8 + publish, plus SQL |
+| Ownership sweep, per minute | | | `ListMod` pages (P) + a GET per changed or pending object + publish when changed |
+| Boxes, per pass | P pages | | 1 conditional GET (304); fallback P pages |
+| Route inventory, per box per pass | | | 1 PUT |
+| Purge | prefix listing | per host as above | + `owned/` listing + a GET per host |
+| Backfill, one-off | | one object and entry per host | the listings + one `Adopt` per record |
+
+Create is independent of account size and, except for the fold case, of
+fleet size. Boxes trade P listing pages per pass for one conditional GET
+and one inventory PUT.
## 10. Tests
-Dirstore with CAS and conditional delete, foreground, in
-`internal/console`. Every interruption test runs lifecycle recovery and
-both sweeps afterwards and asserts the residual bucket, index and SQL
-state.
-
-1. **Concurrent create**, two apps, one host, 16 goroutines each, seeds
- 1..200: one 201, one object, one record, holder incarnation equals
- `acq.incarnation`.
-2. **Alias versus instance versus app create**, every pairing, both
- orders and concurrent: at most one commits; no cross-authority check
- is needed to prove it, the test asserts the single object.
-3. **Delayed creator** (§4.2): pause after acquire past 60 s; the sweep
- reclaims; a successor acquires; the original resumes through
- `BeginCreate` and the record and fails its commit, cancels, deletes
- its record; the successor completes on retry. Variant: pause between
- record and commit past five minutes; recovery cancels because the
- object names another acquisition.
-4. **Recovery completes with commit**: crash between record and commit;
- recovery commits the object then completes; O1 holds.
-5. **Same-owner retry and takeover**: resume by id; takeover by CAS; the
- displaced writer's commit, rollback and index write all fail.
-6. **Release**: crash after each of §4.4's steps; the sweep finishes;
- the object is gone, the lifecycle claim is free and retains its
- history; a new create succeeds afterwards and is refused "being
- released" before.
-7. **Detach dispatch**: each row of §4.5; an instance without
- `first_route` is never deleted by detach; `route == ""` has no effect.
-8. **App incarnation fence**: attach paused across purge and recreate of
- the name; commit refused; nothing attached to the new app.
-9. **Purge enumeration**: a read failure during `owned/` enumeration
- aborts the `released` phase; nothing is released; the name stays
- reserved. Materialization releases the row only when `owned/` is
- empty.
-10. **Rename with a concurrent first-route attach** (C-4): the attach's
- commit fails on `closed`; the source stays empty; finalize succeeds.
- Crash after each step; the app-name sweep settles by `op_id`.
-11. **Namespace rule and canary mint**: each row of §4.6's table on
- every door; the mint skips an unrelated route and skips when the
- apex is not committed.
-12. **Fence and freeze** (B-2): a handler paused before its write with
- the freeze object present refuses; every door answers 503 during the
- freeze.
-13. **Backfill**: a fixture with every quarantine case, a legacy alias at
- a live app's label (B-4), a rowless app, an undecodable object;
- nothing is adopted or stamped for a quarantined host; the recovery
- pass deletes nothing before the marker (C-2); `resolve --keep`
- clears each; two clean passes write the marker.
-14. **Response mapping**: every row of §7 on every door; foreign bodies
- byte-identical and sqid-free; read failures answer 502 for held and
- free alike; invalid later fields answer 400 before any ownership
- read.
-15. **Alias index**: boxes derive the same `route_aliases` from the index
- as from the full fold; a stale index falls back.
-16. **Migrate**: refused with a record; admission closed and drained per
- §5.3 step 1b during the check.
+Dirstore with CAS and conditional delete, foreground, `internal/console`.
+Every interruption test runs lifecycle recovery and both sweeps and
+asserts residual bucket, index and SQL state.
+
+1. Concurrent create, two apps, 16 goroutines each, seeds 1..200: one
+ object, one record, O1.
+2. Every pairing of alias, instance, app create and first-route at one
+ host, both orders and concurrent: at most one commits.
+3. Delayed creator (§4.2): reclaimed before `BeginCreate`; paused
+ between record and commit past five minutes (recovery cancels);
+ recovery completes while the door is paused and the door's conflict
+ re-run returns success and never deletes the record (D-1).
+4. Recovery's commit refuses on a trashed, closed or re-incarnated app
+ row and cancels with the record deleted by its ETag (D-2).
+5. Apex with own record (D-3): create at own apex, release it, the apex
+ stays, the mint resumes; backfill of the shape.
+6. Takeover: displaced writer's commit, rollback and index create or
+ replace all fail; a stale entry is replaced; purge ignores a
+ mismatching entry (D-7).
+7. Release: crash after each §4.4 step; sweep finishes; history kept;
+ alias and apex `releasing` recovered (D-9); detach retry finishes.
+8. Platform flag (D-5): instance delete, PATCH, move and detach on a
+ flagged object by a member answer 403; by an operator proceed.
+9. Purge: pending acquisition under the fence is cancelled (D-8); read
+ failure during `owned/` enumeration aborts; materialization releases
+ the row only when `owned/` is empty; late alias writer fails on
+ incarnation.
+10. Rename: concurrent first-route attach fails on `closed`; crash after
+ each phase including `sql-final` (D-10); `appDeployEmpty` passes for
+ a never-deployed app with its apex entry (D-11); provisional rows
+ admit no work (D-12).
+11. Capabilities and paths on a committed `first_route` object derive as
+ today (D-13).
+12. Index: published on every routing mutation; a losing publisher
+ rebuilds; readers reject a stale `serial` and fall back; 304 keeps
+ age (D-14).
+13. Account stamping (D-6): a route whose host has a committed object is
+ never stamped with another account; paused under the freeze.
+14. Drain (D-4): a handler paused inside its mutation keeps the count
+ non-zero and the verb waits; after the handler returns the verb
+ proceeds; a restarted console reports zero.
+15. Backfill with every quarantine case, a legacy alias at a live app's
+ label, a derived canary under no apex, an operator route, a free
+ lifecycle claim, a stale inventory, a rowless app; resolution via
+ `resolve --keep` through `AdoptExisting` (D-16); two clean passes
+ write the marker; recovery deletes nothing before it (C-2).
+16. Response mapping, every row on every door; foreign bodies identical
+ and sqid-free; read failures 502 for held and free alike.
+17. Migrate: refused with a record; admission drained per §5.3 step 1b.
## 11. Rollout
-§8.1's order. After the marker, the release that follows removes
-`hostHolder`, `apexHolder`'s scan, `ReportApexSquats`' route walk, the
-startup owners backfill and the legacy-object branches.
+§8.1. The release after the marker removes `hostHolder`, `apexHolder`'s
+scan, `ReportApexSquats`' walk, the startup owners backfill and the
+legacy-object branches.
## 12. Alternatives considered
-- **Two authorities with write-then-verify** (revs 1 to 3). Rejected by
- ruling: the interleaving proof, the reciprocal checks and the
- cross-family migration cases were most of the design's risk.
-- **An `alias` family in the lifecycle claim.** Rejected: it would make
- every alias a lifecycle object with parking and retirement, and it
- changes a settled spec; ownership is a smaller object.
-- **A per-canary ownership object acquired by boxes.** Rejected: boxes
- would need `domains/` write grants and cross-region races; the apex
- covers its subtree.
-- **Boxes keep listing `domains/`.** Kept as the fallback; the derived
- index is the steady state once the prefix exceeds a page.
-- **Release deletes ownership as soon as the record is gone.** Rejected:
- a successor would acquire and then be refused by a `parking` lifecycle
- claim; releasing at lifecycle free gives one refusal with one sentence.
-- **A sweep that commits stale pending objects.** Delete-only, except the
- `apex` family where the SQL row carrying `apex_acq` is exact evidence.
+- Two authorities with write-then-verify (revs 1 to 3): retired by
+ ruling.
+- An `alias` family in the lifecycle claim: every alias would carry
+ parking and retirement.
+- Per-canary objects acquired by boxes: box write grants and cross-region
+ races; the apex covers its subtree.
+- A separate object for the apex's own record: two keys for one host
+ reintroduces the two-authority problem; the block keeps one key (D-3).
+- A check-then-write cutover fence: a paused handler defeats it (D-4);
+ the observed drain is the only thing that proves termination.
+- Release deleting ownership as soon as the record is gone: a successor
+ would be refused by a `parking` claim with a different sentence.
+- A sweep that commits stale pending objects: delete-only, except `apex`
+ where the row carrying `apex_acq` and `pending` is exact evidence.
+- `edgesfile` as route identity for backfill: it carries hosts only
+ (D-15).
## 13. Open questions for Paul
-1. The freeze: every door 503 for the minutes the backfill takes on
- flagship, once. Acceptable?
-2. Quarantine resolution by `carlos ops hosts resolve --keep <key>` with
- the operator choosing: confirm, or prefer a default (the record the
- provisioning pass keeps today, first in listing order)?
-3. The derived alias index as a console-written object the boxes read:
- acceptable as a second derived artefact beside the edges files?
-4. The 60 s acquisition timeout and the one-minute sweeps are guesses.
-5. §6 asks 1 to 4 of the lifecycle spec need platform-1e's agreement
- before planning.
+1. The freeze with an observed drain: minutes of 503 on flagship, once,
+ plus a console restart if a handler will not finish. Acceptable?
+2. The route inventory `control/routes/<box>.json`, one PUT per box per
+ pass, and the published alias index: two new derived artefacts.
+3. Quarantine resolution by operator choice with `resolve --keep`, driven
+ through lifecycle retirement and the new `AdoptExisting` transition
+ (§6 ask 5): confirm the ask is acceptable to platform-1e.
+4. The 60 s acquisition timeout, one-minute sweeps and the ten-minute
+ index age are guesses.
+5. Rev 5 is unreviewed; round 4 found five P1s on rev 4, all answered
+ here. A fifth round before planning?