design: host ownership rev 3, answers Astra round 2 (B-1 to B-15)
Alias acquisitions carry an immutable id and kind, takeover is a CAS, the sweep is delete-only; creates freeze between the floor and the backfill marker; adoption evidence and a quarantine guard become prerequisites; the backfill validates every host across all three authorities; alias finalizers verify the app row; first-route markers are bound to the record incarnation; materialization detaches claims before releasing a purged name; app-name ops get an op id; migrate quiesces via admission; ordered response mapping; readers and costs listed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+546
−431
docs/superpowers/specs/2026-10-06-host-ownership-design.md+546 −431
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 03f25789f..f51f22cbd 100644 |
| --- a/docs/superpowers/specs/2026-10-06-host-ownership-design.md |
| +++ b/docs/superpowers/specs/2026-10-06-host-ownership-design.md |
| @@ -1,19 +1,25 @@ |
| # Host-scoped atomic ownership |
| -Design, 2026-10-06, **rev 2**. Closes amadan #232. Design only; nothing |
| +Design, 2026-10-06, **rev 3**. Closes amadan #232. Design only; nothing |
| here is built. |
| -Rev 1 (b70d4d30) went to Astra for a read-only review against the code |
| -(`~/.claude/tmp/host-ownership-review.md` on DOCS-T1, findings cited |
| -`A-1` to `A-15` below). Rev 2 answers all of them: aliases are acquired |
| -in two phases (pending, then committed) with conditional cleanup (A-3, |
| -A-4, A-5, A-8); the create door verifies before it grants serving |
| -authority and follows the lifecycle create protocol in full (A-6, A-9); |
| -lazy adoption is dropped and the backfill marker follows the version |
| -floor (A-1, A-2); minted namespaces get one explicit rule applied to every |
| -door (A-7); app-name writes get a pending state and a sweep (A-10); the |
| -inventory, migrate, privacy, purge and cost sections are corrected |
| -(A-11 to A-15). |
| +Review history. Rev 1 (b70d4d30) and rev 2 (519d25e9) each went to Astra |
| +for a read-only review against the code; the findings are cited `A-n` |
| +(round 1) and `B-n` (round 2) below. Rev 2 answered A-1 to A-15. Rev 3 |
| +answers B-1 to B-15 and the parts of round 1 that round 2 judged partial: |
| +the alias acquisition carries an immutable id and kind for the life of |
| +the claim, takeover is a CAS before verification, the sweep is delete-only |
| +(B-1, B-7, B-11, B-12); creates are frozen between the floor and the |
| +marker so no unclaimed record can be written after the floor (B-2); the |
| +adoption rule and a quarantine guard are rollout prerequisites and the |
| +backfill validates every host across all three authorities (B-3, B-4); |
| +alias finalizers verify the app row (B-5); the first-route marker is |
| +bound to the record's incarnation (B-6); console materialization detaches |
| +claims before it releases a purged name (B-8); app-name operations get an |
| +op id and reserve both names (B-9); the own-app apex exception is explicit |
| +and direct-bucket apps block completion (B-10); migrate quiesces the app |
| +through admission (B-13); the response mapping is ordered and complete |
| +(B-14); readers and costs are listed (B-15). Rev 3 has not been reviewed. |
| Related specs: `2026-10-02-host-lifecycle-claim-design.md` rev 6.14 on |
| `design-replica-ownership` (the lifecycle claim), with its library at |
| @@ -49,11 +55,11 @@ The design: |
| hosts, and this spec adds no field to that claim. |
| 2. **Aliases** (hosts with a claim and no record) are owned by the |
| **global hostname claim** at `domains/<host>.json`. The claim gains |
| - one additive field, `pending`, so an alias is acquired in two phases: |
| - written pending, verified, then committed. Only committed claims |
| - materialise or count as held by their owner. |
| + one additive block, `acq`, so an acquisition has an immutable identity |
| + and kind and is written pending, verified, then committed. Only |
| + committed claims materialise or count as held by their owner. |
| 3. The two families meet at one rule: **write your own authority first, |
| - re-read the other family's, back off if it is held, and only then |
| + re-read every other authority, back off if any is held, and only then |
| commit.** With S3's strong read-after-write consistency at least one |
| of two racing writers sees the other. Every back-off deletes only the |
| exact object version it wrote. |
| @@ -63,9 +69,10 @@ The design: |
| name alone. The console's unique (account, app) row is the atomic gate |
| for the name, and it too is written then verified. |
| 5. Legacy hosts get a claim from a backfill verb that runs after the |
| - version floor, so no old writer can add an unclaimed record behind it. |
| - The door treats an absent claim as free only once the backfill has |
| - written its completion marker. There is no lazy adoption. |
| + version floor, during a short freeze of every create door, so no |
| + writer can add an unclaimed record behind it. The door treats an |
| + absent claim as free only once the backfill has written its completion |
| + marker. There is no lazy adoption. |
| ## 1. The problem, in the code |
| @@ -107,27 +114,28 @@ holder unchanged). |
| | Instance create (`handleAPIInstanceCreate`) | `<label>.<m.Domain>` | record, create-only | decides | check then write; scan cost; local registry; ordering oracle | |
| | Instance delete (:1675) | same | `DeleteRecord` | decides | nothing host-scoped to release yet | |
| | Replica create (same door, `CreateRecordReplica`) | one label + the app's domain | record | decides | as create | |
| -| Sidecar create and delete (apisidecars.go:285, :347; `CreateSidecarRecord`, `DeleteSidecarRecord`, sidecar.go:322, :398) | `<name>.<app>.<sqid>.sidecar.internal` | sidecar record at `sidecars/<name>.json`; its own primitives, not `CreateRecord` | decides (lifecycle family `sidecar`) | none for ownership: the host embeds account and app; never routable | |
| +| Sidecar create and delete (apisidecars.go:285, :347; `CreateSidecarRecord`, `DeleteSidecarRecord`, sidecar.go:322, :398) | `<name>.<app>.<sqid>.sidecar.internal` | sidecar record at `sidecars/<name>.json`; its own primitives | decides (lifecycle family `sidecar`) | none for ownership: the host embeds account and app; never routable | |
| | Canary (`carlos canary`, canary.go:231) | `<canary>-<app>.<sqid>.<apps>` | the create door | decides, via the door | flat label makes collisions easy (#207) | |
| -| Platform apex mint (domains/reconcile.go:266) | `<app>.<sqid>.<apps>` | registry route via `Upsert`; candidates come from routes (`candidateApps`, :757), not SQL | derives from the app name | defers to any route already at the host | |
| -| Static canary mint (domains/reconcile.go:328-337) | `<name>.<app>.<sqid>.<apps>` | registry `Upsert`, **no `byHost` check**; `Upsert` rewrites `app` and `account` on conflict (registry.go:990) | derives from the app name | can overwrite an unrelated route at that host | |
| +| Platform apex mint (domains/reconcile.go:266) | `<app>.<sqid>.<apps>` | registry route via `Upsert`; candidates come from routes (`candidateApps`, :757), not SQL | derives from the app name | defers to any route already at the host; mints for apps with no SQL row | |
| +| Static canary mint (domains/reconcile.go:328-337) | `<name>.<app>.<sqid>.<apps>` | registry `Upsert`, no `byHost` check; `Upsert` rewrites `app` and `account` on conflict (registry.go:990) | derives from the app name | can overwrite an unrelated route | |
| | Trash and retired-canary route removal (domains/reconcile.go:207, :356) | the app's routes | `reg.Delete` | derives | none | |
| | Instance and sidecar route mint and removal (provision/reconcile.go:965, :1178; sidecar_reconcile.go:403, :450) | record hosts | registry rows | derives from records | first-listed record wins a duplicate host | |
| -| App create, rename, move (server.go:3667, :3707; appsettings.go:802, :827, :1305, :1350) | the apex | SQL app row | decides the name | check then write against records; no crash recovery after the row | |
| -| Startup app-row backfill (store.go:676) | the apex | `INSERT OR IGNORE` ops-account rows for owners with no row | decides the name, by hand | an operator-era path; names it takes are checked by nothing | |
| -| Domain attach, alias branch (apidomains.go:444; domainattach.go:153) | any hostname | `domains/<host>.json`, create-only; same-owner `Route` converge in place (claims.go:150) | decides | does not read records or the lifecycle claim; converge can turn a first-route claim into an alias | |
| +| App create, rename, move (server.go:3667, :3707; appsettings.go:802, :827, :1305, :1350; store.go:1801-1905) | the apex | SQL app row and related rows (credentials, grants) | decides the name | check then write against records; no crash recovery after the row; rename and move release the old name at once | |
| +| Startup owners backfill (store.go:676) | the apex | `INSERT OR IGNORE` ops-account rows for legacy `owners` entries with no row | decides the name, by hand | legacy-only; does not register bucket-derived apps | |
| +| Console materialization (materialize.go:391) | the apex | removes a trashed app's SQL row when its bucket prefix is gone (after box auto-purge) | releases the name | releases it while the app's `domains/` claims still exist, so a new app of the name inherits them | |
| +| Domain attach, alias branch (apidomains.go:444; domainattach.go:153) | any hostname | `domains/<host>.json`, create-only; same-owner `Route` converge in place (claims.go:150) | decides | does not read records, the lifecycle claim or app rows; converge can turn a first-route claim into an alias | |
| | Domain attach, first-route branch (domainattach.go:183-192) | any hostname | claim (`Route ""`) then static record | decides, both families | the record half has the create door's race | |
| -| Attach rollback (`rollbackAttach`, domainattach.go:483-499) | | `DeleteRecord`, `DetachClaim` | cleanup | unconditional deletes: a paused rollback can remove a successor's claim | |
| +| Attach rollback (`rollbackAttach`, domainattach.go:483-499) | | `DeleteRecord`, `DetachClaim` | cleanup | unconditional deletes | |
| | Platform attach (`--platform`, `attachDomainAs`) | names in the deployment's zones | the same two branches | decides | the same | |
| -| Domain detach (apidomains.go:853; domainattach.go:304) | | `DeleteRecord` when `Route ""`, then `DetachClaim` (read, then unconditional delete, claims.go:182) | decides | unconditional delete | |
| -| Claim capabilities and paths (`SetClaimCapabilities`, `SetClaimPaths`, claims.go:302, :398) | | fields of an existing claim, owner-gated CAS | no change to ownership | none | |
| -| Domains pass (`ReconcileAliases`, aliases.go:95-133) | | `route_aliases`, `wildcard_domains`, `route_paths` | derives from claims | never reads the lifecycle claim; routes win only once a route is published | |
| -| Operator routes (`carlos route add`, `carlos remove`; cli.go:255, :278) | any | registry `Upsert`, `Delete` on one box | operator | by hand; covered by the fleet fold, not by any object | |
| +| Domain detach (apidomains.go:853; domainattach.go:288-314) | | `DeleteRecord` when `Route ""`, then `DetachClaim` (read, then unconditional delete, claims.go:182) | decides | unconditional delete; the record to delete is chosen from a claim read that nothing fences | |
| +| Claim capabilities and paths (`SetClaimCapabilities`, `SetClaimPaths`, claims.go:302, :398) | | fields of an existing claim, owner-gated CAS | no change to ownership | must refuse a pending claim | |
| +| Domains pass (`ReconcileAliases`, aliases.go:89-133): alias, wildcard, catch-all and path derivation | | `route_aliases`, `wildcard_domains`, `route_paths` | derives from claims | never reads the lifecycle claim; must skip pending claims | |
| +| Operator routes (`carlos route add`, `carlos remove`; cli.go:255, :278) | any | registry `Upsert`, `Delete` on one box | operator | covered by the fleet fold, not by any object | |
| | Moves (apiinstancemove.go, `SetRecordMove`) | unchanged | record fields | no change | | |
| | Placement (`SetRecordRegion`) and pools | unchanged | record Region | no change | | |
| -| Account migrate (accounts.go:312) | unchanged | copies `apps/<src>/<app>/` to `apps/<dst>/<app>/` create-only; only channel pointers are rebound (:369), records are copied unchanged (:416); stamps routes' account; deletes nothing | operator | the copied record names the source account, which `GetRecord` refuses as disagreeing with its path (provision.go:1145); two records per host; `domains/` claims still name the source | |
| -| App purge (`release.PurgeApp`, trash.go:182-278; console door ownership.go:442; box auto-purge domains/reconcile.go:232) | all of an app's hosts | deletes every key under the app prefix and the marker | decides (release) | leaves the app's `domains/` claims; a new app of the same name inherits them | |
| -| Mail domains (`email/domain-claims/<domain>.json`) | email sending domains | its own create-only claim | separate namespace | out of scope: a domain can serve a site and send mail | |
| +| Account migrate (accounts.go:312-470) | unchanged | copies `apps/<src>/<app>/` to `apps/<dst>/<app>/` create-only; only channel pointers are rebound (:369), records are copied unchanged (:416); stamps routes' account (:456); deletes nothing | operator | the copied record names the source account, which `GetRecord` refuses (provision.go:1145); two records per host; `domains/` claims still name the source; the plan is built from a listing nothing fences | |
| +| App purge (`release.PurgeApp`, trash.go:182-278; console door ownership.go:442; box auto-purge domains/reconcile.go:232) | all of an app's hosts | deletes every key under the app prefix and the marker | decides (release) | leaves the app's `domains/` claims | |
| +| Mail domains (`email/domain-claims/<domain>.json`) | email sending domains | its own create-only claim | separate namespace | out of scope | |
| Every record-backed writer funnels through `CreateRecord`, |
| `CreateSidecarRecord`, `DeleteRecord`, `DeleteSidecarRecord` or |
| @@ -147,22 +155,39 @@ converges `Route` in place. `DetachClaim` is owner-gated and quiet on |
| mismatch. Every box folds `domains/` on its fast-converge leg through |
| `ClaimCache`, and `carlos domains list` and app Settings list it too. |
| -This spec adds one field, additive and `omitempty`: |
| +This spec adds one block, additive and `omitempty`: |
| ``` |
| -"pending": "<acquisition id>" |
| +"acq": { |
| + "id": "<random, 128-bit>", |
| + "kind": "alias" | "first-route", |
| + "state": "pending" | "committed", |
| + "incarnation": "<hex>", // first-route only, set at commit |
| + "started_at": "<RFC3339>" |
| +} |
| ``` |
| -A pending claim is an acquisition that has not been verified. It |
| -reserves the key (nobody else can create there) but it is **not held**: |
| -the alias pass does not materialise it, Settings and `domains list` show |
| -it as "attaching", and `SetClaimCapabilities` and `SetClaimPaths` refuse |
| -it. The acquisition id is random, chosen by the writer, and is how a |
| -retry tells its own acquisition from another's. A claim with no `pending` |
| -field is committed, which is every claim that exists today. |
| - |
| -The field must be declared in `Claim` in the same change that first |
| -writes it (the converge rule in claims.go's own comment). |
| +Rules: |
| + |
| +- `id` and `kind` are **immutable for the life of the claim**. A repoint |
| + preserves them; a takeover (§4.4) replaces the whole claim including |
| + `id`, which is how it fences the writer it displaces. Two acquisitions |
| + of one host therefore never produce identical bytes, so their ETags |
| + differ on every store (B-7). |
| +- `state: pending` reserves the key against everyone but is **not |
| + held** by its owner: the alias pass derives nothing from it (alias, |
| + wildcard, catch-all or path), Settings and `domains list` show it as |
| + "attaching", and `SetClaimCapabilities`, `SetClaimPaths` and the |
| + repoint path refuse it. |
| +- `kind` fixes the family the claim is for. An `alias` acquisition can |
| + never become a first-route marker or the reverse (B-1). |
| +- A claim with no `acq` block is a legacy committed alias, or a legacy |
| + first-route marker when `Route == ""`. The backfill (§8.1) stamps |
| + `acq` onto every legacy claim it validates. |
| + |
| +The block must be declared in `Claim` in the same change that first |
| +writes it (the converge rule in claims.go's own comment), and the floor |
| +guarantees no reader below this release folds a pending claim. |
| It remains the right owner for aliases and the wrong owner for instance |
| hosts: the fleet folds this prefix every few seconds on every box and |
| @@ -183,30 +208,36 @@ refuses unless `c.Free()`. `CompleteCreate` moves `op` to `serving` and |
| drops the create's op id, after which `CancelCreate` is illegal |
| (create.go:93, :104). `IsOrphan` treats a record whose incarnation an |
| existing claim does not name as an orphan (orphan.go:10). `Adopt` writes |
| -a claim only for a host that has never had one (adopt.go). |
| +a claim only for a host that has never had one (adopt.go). A purge fence |
| +is a holderless `purging` op (claim.go, CC21). |
| Spec §5.1 is the create protocol: step 0 reads the claim and keeps its |
| ETag or absence, then registers the create in the app's `admission.json` |
| -as a lease; step 1 assigns `CreatedAt`; step 2 CASes the claim to |
| -`{holder: new, op: creating}` conditional on exactly what step 0 read, |
| -and a 412 restarts from step 0 with a new registration and a new |
| -`CreatedAt`; step 3 is `CreateRecord`; step 4 CASes to `serving`; step 5 |
| -is the recovery pass. §5.2 is delete; §5.3 purge; §9 legacy adoption; |
| -§10 the version floor. |
| +as a lease, which succeeds only while the app's admission is `open`; |
| +step 1 assigns `CreatedAt`; step 2 CASes the claim to `{holder: new, op: |
| +creating}` conditional on exactly what step 0 read, and a 412 restarts |
| +from step 0 with a new registration and a new `CreatedAt`; step 3 is |
| +`CreateRecord`; step 4 CASes to `serving`; step 5 is the recovery pass. |
| +§5.2 is delete; §5.3 purge; §9 legacy adoption; §10 the version floor. |
| **The lifecycle claim's holder is the ownership object for every |
| record-backed host.** This spec adds nothing to it and changes none of |
| its invariants. It specifies where the console's doors read the other |
| -family's authority inside that protocol, and asks the lifecycle spec for |
| -two things (§6). |
| +authorities inside that protocol, and asks the lifecycle spec for three |
| +things (§6), two of which are rollout prerequisites. |
| + |
| +### 3.3 Readers that change |
| -### 3.3 The record and the registry |
| +The record stays the provisioning authority and the registry stays |
| +derived. The readers this spec changes (B-15): |
| -The record stays the provisioning authority: boxes mint routes from |
| -records, and under the lifecycle spec's C4 admit them only when the |
| -claim's holder names the record's incarnation. The registry's routes and |
| -`route_aliases` stay derived. The one box-side change this spec asks for |
| -is in the static canary mint (§4.6). |
| +- **Box:** `ReconcileAliases` skips any claim with `acq.state == |
| + pending` before every derivation (alias, wildcard, catch-all, paths). |
| + The static canary mint checks `byHost` (§4.6). Nothing else. |
| +- **Console:** Settings and `domains list` render pending as |
| + "attaching"; `SetClaimCapabilities`, `SetClaimPaths` and the repoint |
| + path refuse pending; `apexHolder`, `hostHolder` and |
| + `ReportApexSquats` read the lifecycle claim instead of scanning. |
| ## 4. The design |
| @@ -218,15 +249,15 @@ Each hostname has exactly one **ownership authority** by family: |
| |---|---|---|---| |
| | record-backed (instance, replica, static record, sidecar) | lifecycle claim | `holder != nil` or `op.kind != ""` | `Update(BeginCreate)` from absent or free | |
| | alias | `domains/<host>.json` | a claim exists (pending or committed; a pending one is held against everyone but its own acquisition) | `AttachClaim`'s create-only `Put` | |
| -| minted (apex, static canary host) | the app named in the host | the (account, app) row exists, live or trashed | the console's unique row insert | |
| +| minted (apex, static canary host) | the app named in the host | the (account, app) row exists, live, trashed or pending | the console's unique row insert | |
| A host can be in only one family at a time. The cross-family rule, for |
| every writer: |
| > **Write your own authority first, unverified. Then re-read every other |
| -> family's authority for the host. If any is held, delete exactly the |
| -> object version you wrote and answer 409. Otherwise commit: the record, |
| -> the committed alias, the verified app row.** |
| +> authority for the host. If any is held, delete exactly the object |
| +> version you wrote and answer 409. Otherwise commit: the record, the |
| +> committed alias, the verified app row.** |
| Why it is enough: S3 gives strong read-after-write consistency for |
| `PutObject` and `GetObject` on a key. Take two writers W1 (family X) and |
| @@ -238,7 +269,7 @@ considered and rejected: the side that must yield cannot tell whether |
| the other side also saw it, so any rule that lets one side keep going is |
| unsound in some interleaving. |
| -Three corollaries the rev 1 text lacked: |
| +Corollaries: |
| - **Nothing is committed before verification.** An unverified authority |
| (a `creating` lifecycle op, a pending alias claim, a pending app row) |
| @@ -246,14 +277,18 @@ Three corollaries the rev 1 text lacked: |
| materialised, and is not what a same-owner retry may report as done. |
| A retry that meets its owner's unverified authority finishes the |
| verification itself or refuses; it never short-circuits (A-3, A-4). |
| +- **Verification covers every other authority**, not only the other |
| + family: an alias verifies the lifecycle claim **and** the app row of |
| + the host's label; a create verifies `domains/` **and** the app row; |
| + an app row verifies the lifecycle claim **and** `domains/` (B-5). |
| - **Cleanup is conditional.** Every back-off and rollback deletes with |
| `release.DeleteIfMatch` on the ETag the write returned, and refuses |
| - when the backend cannot enforce it. A paused rollback can never remove |
| - a successor's object, including another account's (A-5). |
| -- **Family does not change in place.** A first-route claim does not |
| - become an alias by converging `Route`, and a record-backed host does |
| - not become an alias while its record stands. Conversion is detach then |
| - attach, each through its own door (A-4). |
| + when the backend cannot enforce it (A-5). |
| +- **Displacing a writer is a CAS.** Taking over a stale acquisition |
| + rewrites the object under `If-Match`, so the displaced writer's own |
| + commit or cleanup fails its condition (B-1). |
| +- **Family does not change in place.** Conversion is detach then attach, |
| + each through its own door (A-4). |
| The pre-write check stays as a cheap filter for the sequential case, but |
| the design never relies on it. |
| @@ -263,63 +298,53 @@ the design never relies on it. |
| Replaces `hostHolder` and `hostClaimHolder` in `handleAPIInstanceCreate`. |
| Replicas, static records and canary instances go through the same door. |
| The protocol is the lifecycle spec's §5.1 **in full**, with the |
| -cross-family reads inserted at two points. Nothing below replaces or |
| -shortens a lifecycle step (A-9). |
| +cross-family reads inserted at two points (A-9). |
| **Placement.** Every ownership read and write sits after every other |
| -validation the door performs (shape, replica rails, region, pool, |
| -environment, the store walk's preconditions), immediately before the |
| -record write, where `leftoverReplicaRefusal` sits today ("last of the |
| -checks"). An invalid request therefore answers its 400 whether or not |
| -the host is held (A-13). |
| - |
| -1. Shape and name-derived checks, unchanged and early: `ValidHost`, the |
| - namespace rule of §4.6 (which subsumes today's sibling-label check). |
| +validation the door performs, immediately before the record write, where |
| +`leftoverReplicaRefusal` sits today. An invalid request answers its 400 |
| +whether or not the host is held (A-13). |
| + |
| +**Gate.** Above the floor and before the marker (§8.2) the door answers |
| +503 `msgOwnershipFreeze` and does nothing. Below the floor it runs |
| +today's path. |
| + |
| +1. Shape and the namespace rule of §4.6, early. The rule's own-app |
| + exception applies: an app may create at its own apex label. |
| 2. **Pre-check, cheap, after all validation.** `GET domains/<host>`: any |
| - claim, pending or committed, is a refusal (§7 for the sentence). One |
| - `registry.Get` on this box for an operator route with no record. |
| - `edgesfile.DeploymentRoutes` for a route a live box publishes that no |
| - record explains. The fold is best-effort and permissive on read |
| - failure, as the attach door uses it. |
| -3. **Lifecycle step 0 and 1.** `hostclaim.Read`, keep the ETag or |
| - absence; register the create in `admission.json` as the lifecycle spec |
| - says, holding its lease; assign `CreatedAt`. |
| + claim is a refusal (§7). One `registry.Get` on this box for an |
| + operator route with no record. `edgesfile.DeploymentRoutes` for a |
| + route a live box publishes that no record explains; best-effort and |
| + permissive on read failure, as the attach door uses it. |
| +3. **Lifecycle steps 0 and 1.** `hostclaim.Read`, keep the ETag or |
| + absence; register the create in `admission.json`, holding its lease; |
| + assign `CreatedAt`. |
| 4. **Lifecycle step 2.** `Update(BeginCreate)` conditional on step 3's |
| - read. On `ErrConflict`: re-read once to classify for the member (held |
| - by another, or a purge fence, or free again), answer 409 in the first |
| - two cases, and in the third **restart from step 3** with a new |
| - registration and a new `CreatedAt`, at most once per request. Never |
| - retry the CAS alone, and never wait inside the handler. |
| + read. On `ErrConflict`: re-read once to classify (§7), answer 409 for |
| + a hold or a fence, and for free-again **restart from step 3** with a |
| + new registration and a new `CreatedAt`, at most once per request. |
| + Never retry the CAS alone, never wait inside the handler. |
| 5. **Verify.** `GET domains/<host>` again, and `AppNameState` for the |
| - host's label when the host is in the account's platform namespace. Any |
| - `domains/` object, pending or committed, alias or first-route, or a |
| - live or trashed app at the label: `Update(CancelCreate)` (legal: the |
| - op is still `creating`), release the registration, 409. The rev 1 |
| - exception for `Route == ""` claims is withdrawn: a first-route writer |
| - may converge that claim while this create proceeds, so its presence is |
| - not safe to ignore (A-4). |
| + host's label when the host is `<l>.<sqid>.<apps>`. Any `domains/` |
| + object, or a live, trashed or pending app at the label **other than |
| + the creating app itself** (B-10): `Update(CancelCreate)` (legal: the |
| + op is still `creating`), release the registration, 409. |
| 6. **Lifecycle step 3.** `CreateRecord`, create-only. |
| 7. **Lifecycle step 4.** `Update(CompleteCreate)`. From here the host is |
| serving and the only way back is delete and park (§4.3). Nothing after |
| this step may call `CancelCreate` (A-6). |
| -The sibling-label re-check that today runs after the record write |
| -(apiinstances.go:1180) moves into step 5, before the record exists. The |
| -recovery pass can therefore complete any `creating` claim whose record is |
| -present without repeating door predicates: a record exists only after |
| -every verification passed, and the one write that could still race (an |
| -app row appearing after step 5) is caught on the other side, because |
| -`claimApp` writes its row and then reads this claim (§4.6). |
| - |
| -The scan of other apps' records is gone. The only thing that can say a |
| -record-backed host is held is the host's own lifecycle claim. |
| +The recovery pass completes any `creating` claim whose record is present |
| +without repeating door predicates: a record exists only after step 5 |
| +verified, and the one write that could still race (an app row or alias |
| +appearing after step 5) is caught on the other side, because those |
| +writers write and then read this claim (§4.4, §4.6). |
| ### 4.3 Instance delete |
| Lifecycle §5.2 unchanged: CAS `deleting`, `DeleteIfMatch` the record, |
| park, free. Ownership is released when the claim reaches free. A host |
| -that is `deleting` or `parking` is held for the create door (step 4 sees |
| -a non-free claim). The member-facing sentence is §7's. |
| +that is `deleting` or `parking` is held for the create door. |
| ### 4.4 Alias attach and detach |
| @@ -327,83 +352,117 @@ In `handleAPIDomainAttach`'s alias branch and `attachDomainAs`'s alias |
| branch, around the existing `AttachClaim`. Policy and |
| `deploymentRouteTaken` stay first and in their existing order; the Task 5 |
| ordering rules stand, and the new reads sit below them, immediately |
| -around the write. |
| - |
| -1. `GET domains/<host>` (the read `AttachClaim` already does; it returns |
| - the claim and ETag to the door). |
| - - **Committed, own, same route:** done, no write, 200. This is the |
| - titogo reload steady state and costs this one GET. |
| - - **Committed, own, different route, `Route != ""`:** converge as |
| - today (CAS on the ETag), audit, serial. Ownership was decided when |
| - the claim was committed. |
| - - **Committed, own, `Route == ""`:** a first-route claim. Refuse: |
| - "this hostname is this app's own route; detach it before attaching |
| - it as an alias". No in-place conversion (A-4). |
| - - **Pending, own acquisition id:** a retry of this request. Go to |
| - step 4. |
| - - **Pending, own owner, another acquisition id, older than the |
| - acquisition timeout (60 s):** a crashed attempt. Take it over: go to |
| - step 4 with this ETag. |
| - - **Any other pending or committed claim:** 409, `hostUnavailableMsg`. |
| +around the write. Every refusal below is §7's mapping; the sentences |
| +named here are its rows. |
| + |
| +1. `GET domains/<host>` with its ETag `E0`. |
| + - **Committed, own, same route:** done, 200. The titogo reload steady |
| + state: one GET. |
| + - **Committed, own, `kind alias`, different route:** repoint by CAS on |
| + `E0`, preserving `acq`. Ownership was decided at commit. |
| + - **Committed, own, `kind first-route`:** refuse (§7: own-app |
| + conflict). No in-place conversion. |
| + - **Pending, `kind alias`, own acquisition id:** a retry of this |
| + request. Resume at step 4 with `E0`. |
| + - **Pending, `kind alias`, own owner, another id, `started_at` older |
| + than the acquisition timeout (60 s):** **takeover.** CAS the whole |
| + claim on `E0` with this request's id and `started_at`, keep `E1` = |
| + the result, then step 4. The displaced writer's commit or cleanup |
| + now fails its `If-Match` (B-1). |
| + - **Pending, own owner, another id, younger than the timeout:** refuse |
| + (§7: attach in progress). |
| + - **Pending, `kind first-route`, own owner:** refuse (§7: own-app |
| + conflict, "a first-route attach of this host is in progress"). An |
| + alias request never touches a first-route acquisition (B-1). |
| + - **Any claim of another owner:** refuse (§7: foreign). |
| - **Absent:** continue. |
| -2. **Pre-check, cheap.** `hostclaim.Read`. A holder or any op: 409, |
| - `hostUnavailableMsg`. Absent or free: continue. |
| -3. **Write pending.** `Put(domains/<host>, claim{pending: id}, "")`, |
| - create-only. Keep the returned ETag `E1`. A CAS conflict re-reads and |
| - re-enters step 1 once. |
| -4. **Verify.** `hostclaim.Read` again. A holder or any op: |
| - `DeleteIfMatch(domains/<host>, E1)`, 409 with the same bytes. A failed |
| - or refused conditional delete leaves a pending claim, which the sweep |
| - clears (§5) and which never materialises. |
| -5. **Commit.** `Put(domains/<host>, claim without pending, E1)`. Keep |
| - `E2`. A conflict here means someone else's cleanup or takeover moved |
| - the object: re-read and re-enter step 1 once. |
| +2. **Pre-check, cheap.** `hostclaim.Read`: a holder or any op is a |
| + refusal. `AppNameState` of the label when the host is |
| + `<l>.<sqid>.<apps>`: any row other than the attaching app's own is a |
| + refusal. |
| +3. **Write pending.** `Put(domains/<host>, claim{acq: {id, alias, |
| + pending, started_at}}, "")`, create-only. Keep `E1`. A CAS conflict |
| + re-reads and re-enters step 1 once. |
| +4. **Verify.** `hostclaim.Read` again, and `AppNameState` of the label |
| + again (B-5), and the attaching app's own row is still live. Any hold, |
| + any other app's row (live, trashed or pending), or own app not live: |
| + `DeleteIfMatch(domains/<host>, E1)`, refuse. A failed conditional |
| + delete leaves a pending claim, which never materialises and which the |
| + sweep clears (§5). |
| +5. **Commit.** `Put(domains/<host>, claim{acq.state: committed}, E1)`. |
| + Keep `E2`. A conflict means a takeover or cleanup moved the object: |
| + re-read and re-enter step 1 once. |
| 6. Audit and serial, only on a commit. |
| -Two same-owner requests A1 and A2 racing an instance create B (A-3's |
| -interleaving): A2 now meets A1's pending claim and either waits for A1's |
| -outcome by re-reading (a retry with A1's id) or, as a different request, |
| -answers 409 "attach in progress, retry". A2 can only report success after |
| -a commit it observed or performed, and A1's cleanup is conditional on |
| -`E1`, so a claim A2 committed cannot be removed by A1. |
| - |
| -Detach: `DeleteIfMatch` on the ETag `GetClaim` returned, owner-matched as |
| -today, quiet on mismatch and on a lost condition (A-5). A pending claim |
| -of the caller's own may be detached the same way. |
| - |
| -### 4.5 First-route attach |
| - |
| -The first-route branch writes a claim with `Route == ""` and then a |
| -static record. The record makes the host record-backed; the lifecycle |
| -holder is its authority and the `domains/` claim is the attach surface's |
| -marker. The claim is pending until the record is serving. |
| - |
| -1. Policy, `attachChannel`, `deploymentRouteTaken` as today. |
| -2. **Write pending** `domains/<host>` with `Route ""` and an acquisition |
| - id, create-only, keeping `E1`. Any existing claim: as §4.4 step 1. |
| -3. The create protocol of §4.2 steps 3 to 7 for the static record. Its |
| - verify (step 5) reads `domains/<host>` and finds this call's own |
| - pending claim, recognised by acquisition id; that is not a conflict. |
| - Any other `domains/` state is. |
| -4. If the lifecycle CAS loses or the verify fails: |
| - `DeleteIfMatch(domains/<host>, E1)`, 409. |
| -5. After `CompleteCreate`: commit the claim, `Put(..., E1)`. |
| -6. Audit and serial. |
| - |
| -`attachWrote` and its shape restriction stay (A-4): the idempotent branch |
| -adopts a record only when it is kind static, no slug, no region, no pool, |
| -**and** the lifecycle holder is this app's record. The holder alone is |
| -not enough, because an instance record of the same app can sit at the |
| +Detach of an alias: `GET` the claim with ETag, owner-matched, then |
| +`DeleteIfMatch` on that ETag, quiet on mismatch and on a lost condition |
| +(A-5). A pending claim of the caller's own may be detached the same way. |
| + |
| +### 4.5 First-route attach and detach |
| + |
| +The first-route branch writes a claim with `Route == ""` and `kind: |
| +first-route`, then a static record. The record makes the host |
| +record-backed; the lifecycle holder is its authority and the `domains/` |
| +claim is the attach surface's marker. The marker is committed only when |
| +the record is serving, and the commit stamps `acq.incarnation` with the |
| +record's `hostclaim.Incarnation(account, app, host, CreatedAt)` (B-6). |
| + |
| +**Attach**, after policy, `attachChannel` and `deploymentRouteTaken`: |
| + |
| +1. `GET domains/<host>` with `E0`. The branch table (B-11): |
| + - **Absent:** write pending `{id, first-route, pending}` create-only, |
| + keep `E1`. Continue at step 2. |
| + - **Pending first-route, own id:** retry; `E1 = E0`, continue at step |
| + 2. |
| + - **Pending first-route, own owner, another id, older than the |
| + timeout:** takeover by CAS as in §4.4, then step 2. |
| + - **Committed first-route, own:** read the lifecycle claim. Holder is |
| + this app's record with `attachWrote` shape and |
| + `holder.incarnation == acq.incarnation`: idempotent success, no |
| + write. Anything else: refuse (§7: own-app conflict, operator |
| + repair). |
| + - **Any alias claim, own or not, or a pending first-route younger than |
| + the timeout by another id:** refuse per §7. |
| +2. `hostclaim.Read`. By state: |
| + - **absent or free:** run §4.2 steps 3 to 7 for the static record. Its |
| + verify (step 5) finds this call's own pending marker, recognised by |
| + id, which is not a conflict; any other `domains/` state or app row |
| + is. If the CAS loses or the verify fails: `DeleteIfMatch(domains/, |
| + E1)`, refuse. |
| + - **`creating` with holder this app's record (a crashed earlier |
| + attempt):** do not write; answer 409 "attach in progress, retry in |
| + a minute". The recovery pass settles the record half within five |
| + minutes; the next retry meets `serving` or `free`. |
| + - **`serving` with holder this app's static record of `attachWrote` |
| + shape:** a lost commit. Go to step 3. |
| + - **held by another, or any other op:** `DeleteIfMatch(domains/, E1)`, |
| + refuse per §7. |
| +3. **Commit** the marker: `Put(..., claim{committed, incarnation: |
| + holder.incarnation}, E1)`. |
| +4. Audit and serial. |
| + |
| +`attachWrote` and its shape restriction stay (A-4): a record is this |
| +door's only when it is kind static, no slug, no region, no pool, **and** |
| +the lifecycle holder is this app's record **and** the committed marker's |
| +incarnation matches. An instance record of the same app can sit at the |
| same key under a custom manifest domain, and detach must never gain |
| authority over it. |
| -Detach's first-route branch becomes lifecycle delete (§4.3) and then |
| -`DeleteIfMatch` on the claim. |
| +**Detach** of a first-route marker (B-6): |
| -A crash between step 2 and step 5 leaves a pending first-route claim with |
| -or without a record. The lifecycle recovery pass settles the record half |
| -(§5); the sweep then commits the claim when the holder is this app's |
| -static record, or deletes it when the host is free (§5). |
| +1. `GET domains/<host>` with `E0`; owner-matched, `kind first-route`, |
| + committed, with incarnation `I`. Anything else: the alias detach path |
| + or §7's refusal. |
| +2. `hostclaim.Read` with its ETag. `holder.incarnation != I`, or any op |
| + other than serving: refuse (§7: operator repair). The marker names a |
| + record that is not the one serving. |
| +3. Lifecycle delete (§4.3), with `BeginDelete` conditional on the claim |
| + ETag read in step 2, so a successor holder fails the CAS. |
| +4. `DeleteIfMatch(domains/<host>, E0)`. |
| + |
| +A detach that paused between steps 1 and 3 while another detach and a |
| +new attach ran meets a different incarnation in step 2 or a failed CAS |
| +in step 3, and retires nothing. |
| ### 4.6 Minted hosts and the namespace rule |
| @@ -417,48 +476,54 @@ rows alone, so it is never an oracle (A-7): |
| | Host shape under `<sqid>.<apps>` | Belongs to | Allowed | |
| |---|---|---| |
| -| `<l>.<sqid>.<apps>` where `<l>` is a live or trashed app of the account | app `<l>` | an instance of `<l>` (the apex's own record), nothing else: no alias, no first-route, no instance of another app | |
| -| `<l>.<sqid>.<apps>` where `<l>` is no app | nobody yet | an instance of any app of the account (today's canary and slug hosts); an alias only on deployments with reservation off, as today | |
| -| `<x>.<app>.<sqid>.<apps>` | app `<app>` | nothing through any door. Minted only. An alias, a first-route or an instance here is refused whatever the attaching app | |
| -| a manifest `Domain` that is `<app>.<sqid>.<apps>` or deeper | app `<app>` | refused for any other app at the manifest door (`PutManifestIfMatch` callers) and at `ValidHost`'s caller in the create door | |
| - |
| -Applied by: the instance create door (step 1 of §4.2), both attach |
| -branches (with `domainHostPolicy`, before any write), and the manifest |
| -door. On deployments with `ReservePlatformDomains` the whole apps domain |
| -is `hostReserved` for members except the delegated alias accounts; the |
| -rule matters for those accounts and for deployments with reservation off. |
| - |
| -**The apex gate.** The console's app row is the apex's `If-None-Match`, |
| -and it follows the write-then-verify rule: |
| - |
| -1. `claimApp` (and rename, and move, for the new name) inserts the row |
| - with `pending = 1` in its transaction. The unique (account, app) |
| - constraint, trash included, is the atomic step. A pending row reserves |
| - the name against siblings exactly like a live row. |
| -2. Read the lifecycle claim of the apex host and `GET domains/<apex>`. |
| +| `<l>.<sqid>.<apps>` where `<l>` is a live, trashed or pending app of the account | app `<l>` | an instance of `<l>` itself (the own-app exception, B-10), nothing else: no alias, no first-route, no instance of another app | |
| +| `<l>.<sqid>.<apps>` where `<l>` is no app | nobody yet | an instance of any app of the account (today's canary and slug hosts); an alias only on deployments with reservation off, as today, and only with the app-row verification of §4.4 | |
| +| `<x>.<app>.<sqid>.<apps>` | app `<app>` | nothing through any door. Minted only | |
| +| a manifest `Domain` that is `<app>.<sqid>.<apps>` or deeper | app `<app>` | refused for any other app at the manifest door and at the create door | |
| + |
| +Applied by: the instance create door (§4.2 step 1 and step 5), both |
| +attach branches (before any write, and again at verify), and the |
| +manifest door. |
| + |
| +**The apex gate** follows the write-then-verify rule with an operation |
| +id (A-10, B-9): |
| + |
| +1. `claimApp`, rename and move each start by inserting a row in |
| + `pending_app_ops` `{op id, kind, app id, from, to, journal}` and, in |
| + the same transaction, the name reservation: for create, the app row |
| + with `pending = op id`; for rename and move, a **second** row for `to` |
| + with `pending = op id` while the `from` row stays live. Both names are |
| + reserved until the operation finishes (B-9). The unique (account, app) |
| + constraint is the atomic step. |
| +2. Read the lifecycle claim of the new apex and `GET domains/<apex>`. |
| Either held (a holder, an op, or any claim, pending or committed): |
| - delete the row (or, for rename and move, revert the pending change), |
| - refuse. Rename and move record `{app id, kind, from, to}` in a |
| - `pending_app_ops` table in the same transaction as their change, so |
| - the revert is one row away. |
| -3. Clear `pending` and the `pending_app_ops` row. |
| - |
| -The instance create door does the other half (§4.2 step 5). Rows and |
| -ops left pending by a crash are settled by the app-name sweep (§5): |
| -re-run step 2; held, revert; free, clear. A pending row is not an app a |
| -member can deploy to: the doors treat it as "being created" (A-10). |
| - |
| -**Apex candidates from routes.** The box mints an apex for any (account, |
| -app) that has routes (`candidateApps`), including an app with no SQL row. |
| -Such an app's name is reserved by nothing. The backfill verb (§8.1) |
| -reports every app prefix in the bucket that has no row, and the startup |
| -backfill at store.go:676 is the existing operator path that gives it one; |
| -this spec makes that path go through the same pending-and-verify steps. |
| + compensate, in one transaction conditional on `pending_app_ops.op id` |
| + still present: delete the pending row, delete the op row, refuse. |
| +3. Finalise, in one transaction conditional on the op id: for create, |
| + clear `pending`; for rename and move, apply the related-row changes |
| + the journal lists (credentials, grants, store.go:1801-1905), drop the |
| + `from` row (rename) or re-home it (move), clear `pending` on `to`, |
| + delete the op row. A handler that paused and finds its op row gone |
| + does nothing: recovery already settled it. |
| + |
| +The instance and alias doors do the other half (§4.2 step 5, §4.4 step |
| +4). Rows and ops left pending by a crash are settled by the app-name |
| +sweep (§5) with the same conditional transactions. A pending row is not |
| +an app a member can deploy to: the doors treat it as "being created". |
| + |
| +**Apps with no SQL row.** The box mints an apex for any (account, app) |
| +that has routes (`candidateApps`), including an app that exists only in |
| +the bucket. Such a name is reserved by nothing (B-10). The backfill |
| +(§8.1) lists every app prefix with records or routes and no row, and |
| +**blocks completion** until each is registered (`carlos ops apps |
| +register <sqid> <app>`, which inserts the row through the apex gate) or |
| +excluded from minting by an operator deleting its records. The startup |
| +owners backfill at store.go:676 is legacy-only and is not that path. |
| **The static canary mint** must not overwrite a route. The box-side |
| change: before `reg.Upsert(nr)` at domains/reconcile.go:337, skip and |
| log when `byHost` has the host and the existing route's app or account |
| -differ from the mint's. The apex mint already checks `byHost`. |
| +differ from the mint's. |
| ### 4.7 Replicas, sidecars, fleets, pools, moves |
| @@ -477,23 +542,28 @@ differ from the mint's. The apex mint already checks `byHost`. |
| `carlos accounts migrate --app A --to <dst>` copies the app's objects to |
| the destination prefix create-only and deletes nothing. Records are |
| copied with their body unchanged, so the destination copy names the |
| -source account and `GetRecord` refuses it as disagreeing with its own |
| -path (provision.go:1145). The verb was written for release objects and |
| -channel pointers; for instance records it has never produced a valid |
| -destination record (A-12). |
| +source account and `GetRecord` refuses it (provision.go:1145). The verb |
| +has never produced a valid destination record (A-12). |
| Under this design a host's lifecycle holder names the source record's |
| exact key, the incarnation hashes the source account, `holder.location` |
| is incarnation-scoped, and a sidecar's host embeds the source sqid. A |
| -transfer therefore needs a protocol for record identity, storage |
| -location, execution authority, sidecars and `domains/` claims. That is |
| -not a one-field CAS and it is not designed here. |
| +transfer needs a protocol for record identity, storage location, |
| +execution authority, sidecars and `domains/` claims, and it is not |
| +designed here. |
| + |
| +**Rule.** `accounts migrate`: |
| + |
| +1. Closes the source app's admission (`admission.json` to `closed`, the |
| + lifecycle spec's own quiesce: create step 0 then refuses), and waits |
| + out every outstanding registration lease (B-13). |
| +2. Lists the app's `instances/` and `sidecars/` prefixes. Any record: |
| + reopen admission, refuse with a sentence naming this spec. |
| +3. Otherwise migrates release objects and pointers as today, **without** |
| + stamping routes (an app with no records has no provisioned routes to |
| + stamp; an operator route is the operator's), then reopens admission. |
| -**Rule:** `accounts migrate` refuses an app that has any instance or |
| -sidecar record, with a sentence naming this spec. Release objects and |
| -pointers migrate as today. The check reads the app's record prefixes |
| -under the floor, where every record has a claim, so no adoption can race |
| -it. The follow-up thread owns the transfer and the `domains/` rebind. |
| +The follow-up thread owns the transfer and the `domains/` rebind. |
| ## 5. Crash recovery |
| @@ -503,26 +573,26 @@ frees a host that has a record with the holder's incarnation. |
| | Crash after | Left behind | Cleared by | |
| |---|---|---| |
| -| §4.2 step 4 (claim `creating`, no record) | an orphaned claim | the lifecycle recovery pass (§5.1 step 5): record absent, `CancelCreate`, incarnation retired. Held for at most five minutes plus the pass interval. Never steals: a record present with this incarnation completes instead | |
| +| §4.2 step 4 (claim `creating`, no record) | an orphaned claim | the lifecycle recovery pass (§5.1 step 5): record absent, `CancelCreate`, incarnation retired. Never steals: a record present with this incarnation completes instead | |
| | §4.2 step 6, before `CompleteCreate` | claim `creating`, record present | the same pass completes it. Safe without re-running door predicates: the record exists only after step 5 verified | |
| -| §4.2 step 5 back-off, before `CancelCreate` | claim `creating`, a `domains/` claim or an app row present | the pass cancels (record absent). The other writer was earlier and stands | |
| -| §4.4 step 3 (pending alias, verify not run) | a pending claim | never materialised. The **claim sweep** (console, every few minutes, over `ClaimCache`): a pending claim older than the acquisition timeout whose host has a lifecycle holder or op is deleted with `DeleteIfMatch`; one whose host is free is committed on behalf of its owner only if `Route` names a ready route of that owner (the fold says so), otherwise deleted. Deleting a pending claim removes nothing anyone was told succeeded | |
| -| §4.4 step 4 back-off, before `DeleteIfMatch` | the same pending claim | the same | |
| -| §4.4 step 5 (verified, commit PUT lost) | a verified but still pending claim | the sweep, as above; the owner's retry recognises its id and commits | |
| -| §4.5 step 2 to 5 | a pending first-route claim, with or without a record and a lifecycle state | the recovery pass settles the record half; then the sweep commits the claim when the holder is this app's static record (`attachWrote` shape), or deletes it when the host is free | |
| -| §4.6 step 1 (pending app row, unverified) | a name reserved by a row nobody verified | the **app-name sweep** (console, startup and every minute): re-run step 2 for every pending row and `pending_app_ops` row older than a minute; held, revert and log; free, clear | |
| -| delete (§4.3) at any step | the lifecycle spec's recovery owners | unchanged | |
| -| app purge | records gone; the app's `domains/` claims left | **the console purge door**, which holds the durable evidence (`PurgeReady`, the tombstone), detaches the app's claims with `DeleteIfMatch` **before** `PurgeApp`, listing them through `ClaimCache`. Box auto-purge has no bucket write grant on `domains/` and leaves them; the sweep reports those as "owner purged" and does not delete, because SQL absence is not proof (direct-bucket apps exist) (A-14) | |
| - |
| -Old-console deletes during the roll (a record gone with a `serving` |
| -claim) are the lifecycle spec's §10 case: the host is held until |
| -`carlos ops host resolve`. For the create door that is a refusal, §7's |
| -sentence, visible and safe. |
| - |
| -**Name reuse after purge.** A `domains/` claim names account and app by |
| -name, so a new app of the same name would inherit a purged app's aliases. |
| -The purge door's detach closes that going forward. Existing inherited |
| -claims are listed by the backfill verb for the operator. |
| +| §4.2 step 5 back-off, before `CancelCreate` | claim `creating`, another authority present | the pass cancels (record absent). The other writer was earlier and stands | |
| +| §4.4 step 3 or 4 (pending alias, not committed) | a pending claim | never materialised. The **claim sweep** (console, every few minutes): `ClaimCache` for discovery only; for each pending claim older than the timeout, `GET` the exact object with its ETag, read the lifecycle claim and the label's app row, then **delete only**: `DeleteIfMatch` when the host has any other authority, **or** when none and the claim is still pending at that ETag. The sweep never commits on an owner's behalf (B-12, answers rev 2's Q4): a pending claim is a request that did not finish, and its owner's retry is the only thing that may finish it. Deleting it removes nothing anyone was told succeeded | |
| +| §4.4 step 5 (verified, commit PUT lost) | a pending claim | the owner's retry (own id, step 4 then 5) within the timeout; after it, the sweep deletes it and the owner attaches again | |
| +| §4.5 pending marker, lifecycle absent or free, no record | a pending first-route claim | the sweep deletes it as above | |
| +| §4.5 pending marker, lifecycle `creating` | | the recovery pass settles the record half; the sweep leaves a pending first-route marker whose host is `creating` or `serving` **for this app's static record** alone and logs "pending first-route, re-attach to finish"; the owner's retry commits it (§4.5 step 2, lost commit) | |
| +| §4.5 pending marker, lifecycle held by another | | the sweep deletes the marker | |
| +| §4.6 step 1 (pending row or op, unverified) | names reserved by an op nobody finished | the **app-name sweep** (console, startup and every minute): for every `pending_app_ops` row older than a minute, re-run §4.6 step 2 and then step 3 or its compensation, each conditional on the op id | |
| +| delete (§4.3, §4.5 detach) at any step | the lifecycle spec's recovery owners | unchanged | |
| +| console purge door | records gone; claims | the door detaches the app's `domains/` claims with `DeleteIfMatch` **before** `PurgeApp`, discovered through `ClaimCache`, each read exactly first. An alias acquisition in flight for the app fails its §4.4 step 4 own-app-live check, because the app is trashed before purge | |
| +| box auto-purge | records and marker gone; the trashed SQL row; the app's claims | **console materialization** (materialize.go:391) detaches the app's claims, as the purge door does, **before** it removes the trashed row. The name is released only once no claim names the app (B-8, A-14). Until then the row stays trashed and the name reserved | |
| + |
| +Old-console deletes cannot happen above the floor (§8.3). A `serving` |
| +claim whose record is gone can arise from a crashed new-console delete, |
| +and the lifecycle spec's recovery owners cover it. |
| + |
| +**Quarantine guard (B-3).** The recovery pass never deletes an orphan |
| +whose host is listed in `control/hosts-quarantine.json` (§8.1). The |
| +guard is a rollout prerequisite, not a follow-up. |
| ## 6. Composition with the lifecycle claim |
| @@ -531,26 +601,29 @@ Ownership and lifecycle are **one object for record-backed hosts** and |
| - This spec uses `internal/hostclaim` as specified: `Read`, `Update`, |
| `BeginCreate`, `CancelCreate` (only while `creating`), `CompleteCreate`, |
| - `Free`, and the recovery pass. "Who holds H" is `Read(H).Claim.Holder` |
| - and the op, nothing else. The create door runs the lifecycle protocol |
| - in full, admission lease included. |
| -- It asks the lifecycle spec for **two changes**, both to §9 adoption |
| - (A-1): |
| - 1. `Adopt` requires deployment-wide evidence: the adopting actor has |
| - listed every `apps/*/*/instances/<host>.json` and |
| + `BeginDelete` conditional on the ETag read, `Free`, and the recovery |
| + pass. "Who holds H" is `Read(H).Claim.Holder` and the op, nothing else. |
| + The create door runs the lifecycle protocol in full, admission lease |
| + included, and migrate uses admission to quiesce. |
| +- It asks the lifecycle spec for **three changes**: |
| + 1. **(prerequisite)** `Adopt` requires deployment-wide evidence: the |
| + adopting actor has listed every `apps/*/*/instances/<host>.json` and |
| `apps/*/*/sidecars/*.json` for the host and found exactly one |
| - record. A host with two or more records is quarantined: not adopted, |
| - reported, refused to every door. Reconcile, the doors, purge and the |
| - recovery pass, which §9 names as adopting actors, take the same rule |
| - or defer adoption to the backfill verb. |
| - 2. Account migrate is refused for hosts with records until a transfer |
| + record, and that record's host is consistent with `domains/` and |
| + the namespace rule (§8.1 step 3). In this design the **backfill verb |
| + is the only adopting actor**; reconcile, the doors, purge and the |
| + recovery pass, which §9 names as adopting actors, refuse a host with |
| + no claim and log it (A-1, B-3). Above the marker no such host |
| + exists. |
| + 2. **(prerequisite)** The recovery pass honours the quarantine guard |
| + (§5) before `DeleteIfMatch` on an orphan. |
| + 3. Account migrate is refused for hosts with records until a transfer |
| transition exists (§4.8). A ruling, not a mechanism. |
| -- It adds console-side protocol around the lifecycle CAS: the two reads |
| - of `domains/`, the alias doors' reads of `control/hosts/`, and the two |
| - sweeps. The lifecycle claim does not know aliases exist. |
| +- It adds console-side protocol around the lifecycle CAS: the reads of |
| + `domains/` and app rows, the alias doors' reads of `control/hosts/`, |
| + and the two sweeps. The lifecycle claim does not know aliases exist. |
| - The lifecycle floor gates this design. Below the floor the doors keep |
| - today's checks; the pending alias field is written only above it, so |
| - no old box folds a pending claim as committed. |
| + today's checks; the `acq` block is written only above it. |
| The alternative, an `alias` holder family in the lifecycle claim so that |
| one object owns every routable name, was considered (§12). |
| @@ -558,31 +631,33 @@ one object owns every routable name, was considered (§12). |
| ## 7. Privacy |
| **Placement.** Every ownership read sits after every other validation |
| -and immediately before the write it protects, on every door (A-13). The |
| -instance door's reads move from :772 to the position of |
| -`leftoverReplicaRefusal`. The alias doors' reads sit between |
| -`deploymentRouteTaken` and the `AttachClaim` write, where the Task 5 |
| -ordering rule puts the write's own refusal. |
| +and immediately before the write it protects, on every door (A-13). |
| -**Responses.** One table, for every door: |
| +**The response mapping**, evaluated in this order, first match wins, for |
| +every door (B-14). "Foreign" means the authority names an account other |
| +than the caller's, or names no account (a holderless purge fence, a |
| +malformed object). |
| -| Cause | Instance and app-name doors | Alias doors | |
| -|---|---|---| |
| -| held by another **account**, any family, any op | 409 `hostUnavailableMsg`, byte for byte | 409 `hostUnavailableMsg` | |
| -| held by another **app of the caller's account**, serving | 409 naming the app, as `hostHeldSentence` does today | 409 `hostUnavailableMsg` (the alias door's existing stronger neutrality, apidomains.go:458, is kept) | |
| -| held by another app of the caller's account, non-serving op (`creating`, `deleting`, `moving`...) | 409 naming the app and "is being changed; try again later" | 409 `hostUnavailableMsg` | |
| -| held by the caller's **own** app in the other family | 409 naming the conflict and the detach or delete that frees it | the same | |
| -| operator route, no record, no claim, caller's own account on the route | 409 "operator repair", as today | 409 `hostUnavailableMsg` | |
| -| operator route naming another account | 409 `hostUnavailableMsg` (today's "routed for account X" sentence is retired) | 409 `hostUnavailableMsg` | |
| -| a pending acquisition by another request | 409 "attach in progress, retry" only when the pending owner is the caller's own app; otherwise `hostUnavailableMsg` | the same | |
| - |
| -"Being released" and "operator repair" are therefore never said about a |
| -foreign hold. Nothing a member receives distinguishes a foreign hold from |
| -a foreign alias claim. |
| +| # | Observation | Instance and app-name doors | Alias doors | |
| +|---|---|---|---| |
| +| 1 | a read failed (bucket, registry, SQL) | 502 "could not check the host, try again". Never an occupancy answer | 502, the same | |
| +| 2 | a claim or lifecycle object does not decode, or `Validate` fails | 500 "could not check the host"; logged for the operator | the same | |
| +| 3 | held by another **account**, any family, any op; or a holderless op; or a claim naming no account | 409 `hostUnavailableMsg`, byte for byte | 409 `hostUnavailableMsg` | |
| +| 4 | the caller's **own app**, same family, already there (its own record, its own committed alias) | 409 "instance already exists" (today's sentence) | 200 no-op, or the repoint | |
| +| 5 | the caller's own app, other family or an unfinished acquisition | 409 naming the conflict and the step that frees it ("detach the alias first", "a first-route attach is in progress", "attach in progress, retry") | the same | |
| +| 6 | another app of the caller's account, serving or committed | 409 naming the app, as `hostHeldSentence` does today | 409 `hostUnavailableMsg` (apidomains.go:458's stronger neutrality kept) | |
| +| 7 | another app of the caller's account, non-serving op or pending | 409 naming the app and "is being changed; try again later" | 409 `hostUnavailableMsg` | |
| +| 8 | an operator route whose account column is the caller's, with no record and no claim | 409 "operator repair", as today | 409 `hostUnavailableMsg` | |
| +| 9 | an operator route naming another account or none | 409 `hostUnavailableMsg` (today's "routed for account X" is retired) | 409 `hostUnavailableMsg` | |
| +| 10 | the ownership freeze (§8.2) | 503 `msgOwnershipFreeze` | 503, the same | |
| + |
| +Rows 1 and 2 come first so that unavailable evidence is never reported |
| +as a conflict or as free. Nothing a member receives distinguishes a |
| +foreign hold from a foreign alias claim, and §4.4 and §4.5 name only |
| +rows of this table. |
| **Logs and sweeps.** The two sweeps and the backfill verb name hosts, |
| -accounts and keys in the operator log and the verb's stdout only. No |
| -tenant activity log receives a line about another account. |
| +accounts and keys in the operator log and the verb's stdout only. |
| **Buckets.** `control/hosts/` and `domains/` are read by consoles and |
| boxes. No door returns a claim body to a member. |
| @@ -592,55 +667,73 @@ boxes. No door returns a claim body to a member. |
| ### 8.1 Backfill |
| A console-side verb, `carlos ops hosts backfill [--dry-run]`, runnable |
| -only above the version floor: |
| +only above the version floor and only while the freeze (§8.2) is in |
| +force: |
| -1. List the bucket, not SQL: every `apps/*/*/instances/*.json` and |
| - `apps/*/*/sidecars/*.json`. This covers direct-bucket records of apps |
| +1. List the bucket, not SQL: every `apps/*/*/instances/*.json`, |
| + `apps/*/*/sidecars/*.json`, `domains/*.json`, and every route a live |
| + box publishes (`edgesfile`). This covers direct-bucket records of apps |
| SQL does not list. |
| -2. For each host with exactly one record and no lifecycle claim, |
| - `claim.Adopt` with that record as holder, as the lifecycle spec's §9 |
| - defines (discovery obligations included). Create-only; a claim that |
| - appeared since is left alone. |
| -3. A host with **two or more records** is quarantined: not adopted, |
| - reported with every key, and refused to the create door (an absent |
| - claim with a quarantine entry is held). The operator picks the winner |
| - by deleting the loser's record, then re-runs. The quarantine list is |
| - `control/hosts-quarantine.json`, rewritten whole by the verb. |
| -4. A record that does not decode, or whose body disagrees with its key, |
| - is reported and blocks completion. Nothing is adopted for it. |
| -5. Report, without acting: app prefixes with no SQL row (§4.6); |
| - `domains/` claims whose owner has no app row; records under accounts |
| - the console does not know. |
| -6. **Completion** needs two consecutive clean passes with the floor in |
| - force: no unclaimed record, no quarantine, no undecodable record, and |
| - the second listing showing no record the first did not. Then the verb |
| - writes `control/hosts-backfill.json` with the count, the floor version |
| - and the time. Re-running is idempotent. |
| - |
| -Aliases need no backfill: `domains/` already is their authority, and |
| -every existing claim is committed by definition. |
| - |
| -### 8.2 The door before and after the marker |
| - |
| -There is no lazy adoption (A-1). Before the marker the create door runs |
| -**today's** path: the scan, `hostClaimHolder`, plain `CreateRecord`, no |
| -claim. An absent claim is not evidence of anything until the backfill |
| -says every record has one. After the marker, an absent claim is free, |
| -and the door runs §4.2. |
| - |
| -The marker cannot go stale (A-2): it is written only above the floor, |
| -where no console or box below this release runs, so nothing can write a |
| -record without a claim after it. If the floor is ever lowered, the |
| -marker is deleted first and the doors fall back. |
| +2. For each host with exactly one record, no lifecycle claim, no |
| + `domains/` claim or only a legacy first-route marker owned by the |
| + record's app with `attachWrote` shape, and a host consistent with the |
| + namespace rule: `claim.Adopt` with that record as holder (the lifecycle |
| + spec's §9, discovery included), then stamp the legacy marker |
| + `{acq: committed, first-route, incarnation}`. |
| +3. **Quarantine** (B-3, B-4): any of |
| + - two or more records for a host, claimed or not; |
| + - a record and an alias claim (`Route != ""`) at one host; |
| + - a record whose lifecycle holder names a different incarnation; |
| + - a record at `<l>.<sqid>.<apps>` where `<l>` is an app other than |
| + the record's, or at `<x>.<app>.<sqid>.<apps>`; |
| + - a legacy first-route marker whose owner has no matching record. |
| + |
| + Quarantined hosts are listed in `control/hosts-quarantine.json`, |
| + rewritten whole by the verb, with every key involved. The create door |
| + refuses them (an absent or present claim with a quarantine entry is |
| + held, row 3 of §7), the recovery pass never deletes an orphan there |
| + (§5), and the operator repairs by deleting the loser or detaching the |
| + alias, then re-runs. |
| +4. A record or claim that does not decode, or whose body disagrees with |
| + its key, is reported and blocks completion. |
| +5. **Legacy alias claims** with no record at the host are stamped |
| + `{acq: committed, alias}` by CAS on their ETag. A legacy claim whose |
| + owner has no app row is reported (purge inheritance) and blocks |
| + completion until detached. |
| +6. **Apps with no SQL row** that have records or routes block completion |
| + (§4.6). |
| +7. **Completion** needs two consecutive clean passes under the freeze: |
| + no unclaimed record, no quarantine, no undecodable object, no |
| + unregistered app, and the second listing showing nothing the first |
| + did not. The verb then writes `control/hosts-backfill.json` with the |
| + counts, the floor version and the time, and the freeze lifts. |
| + |
| +### 8.2 The freeze, and the door before and after the marker |
| + |
| +There is no lazy adoption (A-1). Below the floor the create door, both |
| +attach branches and the apex gate run **today's** paths. Above the floor |
| +and before the marker they answer 503 `msgOwnershipFreeze` ("the |
| +platform is finishing a maintenance step; try again in a few minutes") |
| +and write nothing (B-2). After the marker they run §4. |
| + |
| +So no record, claim or app row can be written between the floor and the |
| +marker by any binary: old binaries are refused by the floor, new ones by |
| +the freeze. The two clean passes are defence in depth, not the mechanism. |
| +The freeze is minutes on flagship: one listing of a few thousand keys and |
| +one `Adopt` per host. |
| + |
| +The marker cannot go stale: nothing above the floor writes an unclaimed |
| +record. If the floor is ever lowered, the marker is deleted first and the |
| +doors fall back to today's paths. |
| ### 8.3 Mixed-version consoles |
| Below the floor, old and new consoles both run today's path, so the race |
| remains open until the floor is published; that is the lifecycle spec's |
| -rollout order and the roll is consoles first. An old console's delete |
| -after the floor cannot happen, because the floor refuses old binaries |
| -(lifecycle §10). A `serving` claim whose record is gone can still arise |
| -from a crashed new-console delete, and §5 covers it. |
| +rollout order and the roll is consoles first. Above the floor no old |
| +binary runs (lifecycle §10 refuses them, including boot recovery). A |
| +`serving` claim whose record is gone can still arise from a crashed |
| +new-console delete, and the lifecycle recovery owners cover it. |
| ### 8.4 Cross-account |
| @@ -648,117 +741,131 @@ from a crashed new-console delete, and §5 covers it. |
| accounts the console does not know. It reports those and never |
| rewrites, re-homes or deletes a record. |
| - Nothing in this design deletes a legacy record: the recovery pass |
| - deletes only orphans established under §5.1 step 5, and under the |
| - marker every legacy record has the claim the backfill gave it, so none |
| - is an orphan. |
| -- Refusals follow §7's table. |
| + deletes only orphans established under §5.1 step 5 outside quarantine, |
| + and under the marker every legacy record has the claim the backfill |
| + gave it, so none is an orphan. |
| +- Refusals follow §7's mapping. |
| ## 9. Cost |
| -Per path, bucket calls only. "Today" is the current main. |
| +Per path, bucket calls only (SQL reads are local). "Today" is the |
| +current main. |
| | Path | Today | After | |
| |---|---|---| |
| -| Instance create, account with N apps | registry read, 2 SQL reads, N sequential GETs, 1 claim GET, 1 record PUT | 1 `domains/` GET, 1 fold read (the attach door's existing fold), lifecycle step 0 (1 GET + the admission lease's PUT), 1 claim PUT, 1 `domains/` GET + 1 SQL read, 1 record PUT, 1 claim PUT. Independent of N. The lifecycle pair and the lease are the lifecycle spec's cost; this spec adds the two `domains/` GETs and removes N | |
| -| Alias re-attach, no-op (titogo's reload loop) | 1 GET | 1 GET, unchanged | |
| -| Alias repoint (same owner, new route) | 1 GET + 1 PUT | 1 GET + 1 PUT, unchanged | |
| -| Alias fresh attach | 1 GET + 1 PUT | 1 GET, 1 lifecycle GET, 1 PUT (pending), 1 lifecycle GET, 1 PUT (commit): two GETs and one PUT more | |
| -| First-route attach | 1 GET + 1 PUT + record PUT | pending PUT, the create path above, commit PUT | |
| -| Detach | 1 GET + 1 DELETE | the same, conditional | |
| -| App create, rename, move | the N-GET scan | 1 lifecycle GET + 1 `domains/` GET | |
| -| Claim sweep | none | one `ListMod` of `domains/` every few minutes (the `ClaimCache` steady state), GETs for changed objects only, writes only for stale pending claims | |
| -| App-name sweep | none | SQL only, plus the two GETs per pending row, which is normally zero rows | |
| -| Boxes | | nothing new; the canary mint check is a map lookup | |
| - |
| -Migrate's refusal check lists the app's two record prefixes once. |
| +| Instance create, account with N apps | registry read, 2 SQL reads, N sequential GETs, 1 claim GET, 1 record PUT | 1 `domains/` GET, 1 fold read, lifecycle step 0 (1 GET, the lease's PUT), 1 claim PUT, 1 `domains/` GET, 1 record PUT, 1 claim PUT. Independent of N. The lifecycle pair and the lease are the lifecycle spec's cost; this spec adds two `domains/` GETs and removes N. A restarted CAS (step 4) repeats the lease and the pair once | |
| +| Alias re-attach, no-op | 1 GET | 1 GET, unchanged | |
| +| Alias repoint | 1 GET + 1 PUT | 1 GET + 1 PUT, unchanged | |
| +| Alias fresh attach | 1 GET + 1 PUT | 1 GET, 1 lifecycle GET, 1 PUT (pending), 1 lifecycle GET, 1 PUT (commit): two GETs and one PUT more. A takeover adds one PUT | |
| +| First-route attach | 1 GET + 1 PUT + record PUT | pending PUT, the create path, commit PUT | |
| +| Detach | 1 GET + 1 DELETE | alias: the same, conditional. First-route: plus 1 lifecycle GET and the lifecycle delete's own writes | |
| +| App create, rename, move | the N-GET scan | 1 lifecycle GET + 1 `domains/` GET, plus SQL | |
| +| Claim sweep | none | one `ListMod` of `domains/` every few minutes (the `ClaimCache` steady state); per **pending** claim older than the timeout: 1 GET, 1 lifecycle GET, 1 conditional DELETE. Normally zero pending claims | |
| +| App-name sweep | none | SQL, plus two GETs per pending op, normally zero | |
| +| Backfill | none | one-off: one listing of each prefix, one `Adopt` PUT per host, one CAS per legacy claim | |
| +| Migrate | listing | plus the admission close and reopen (2 CAS) and two prefix listings | |
| +| Boxes | | the pending skip is a field test; the canary check is a map lookup | |
| ## 10. Tests |
| Against a dirstore with real CAS and conditional delete, in |
| `internal/console`, foreground. Every interruption test runs the |
| recovery pass and both sweeps afterwards and asserts the residual bucket |
| -state, not only the responses (A-15's test note). |
| +and SQL state, not only the responses. |
| 1. **Concurrent create.** Two apps, one host, 2 and then 16 goroutines |
| per app. Exactly one 201; every other response is 409 with the |
| within-account sentence; exactly one record; the claim's holder names |
| it; `op.kind == serving`. Seeds 1..200. |
| -2. **Alias versus instance, deterministic schedules.** Attach then |
| - create, create then attach, and every interleaving of §4.2 steps 2 to 7 |
| - with §4.4 steps 1 to 5 under an injected scheduler. Invariant after |
| - both return and after recovery: never both 2xx; the bucket holds |
| - either one committed alias claim and a free or absent lifecycle claim, |
| - or a record with a matching holder and no `domains/` object, or |
| - neither. No pending claim survives the sweep. |
| -3. **Same-owner alias retries** (A-3's schedule): A1, A2 for the same |
| - owner racing B. Whoever answered 2xx, the object it reported exists |
| - afterwards. A2 never reports success on a claim A1 later removed. |
| -4. **Family conversion** (A-4): a first-route claim, a concurrent alias |
| - attach by the same owner that would converge `Route`, and an instance |
| - create. The converge is refused; never both the record and an alias. |
| -5. **Stale cleanup** (A-5): a rollback paused across a detach and a |
| - different account's attach. The conditional delete fails; the |
| - successor's claim stands. |
| -6. **Crash between claim and record.** Inject a failure after |
| +2. **Alias versus instance, deterministic schedules.** Every interleaving |
| + of §4.2 steps 2 to 7 with §4.4 steps 1 to 5 under an injected |
| + scheduler. Invariant after both return and after recovery: never both |
| + 2xx; the bucket holds either one committed alias claim and a free or |
| + absent lifecycle claim, or a record with a matching holder and no |
| + `domains/` object, or neither. No pending claim survives the sweep. |
| +3. **Same-owner alias retries and takeover** (A-3, B-1): A1 and A2 for |
| + the same owner racing B, with A1 paused past the timeout so A2 takes |
| + over. A1's commit and cleanup fail their conditions; whoever answered |
| + 2xx, the object it reported exists afterwards. |
| +4. **Family conversion** (A-4, B-1): a pending or committed first-route |
| + claim and an alias request by the same owner. Refused; never both the |
| + record and an alias. An alias request never CASes a first-route |
| + acquisition. |
| +5. **Stale cleanup** (A-5, B-7): a rollback paused across a detach and a |
| + re-attach by the same principal with the same route within one |
| + second. The conditional delete fails; the new claim stands. |
| +6. **First-route detach versus successor** (B-6): a detach paused after |
| + reading marker A while A is detached and B attached; the paused detach |
| + refuses at step 2 or fails its CAS at step 3; B's record stands. |
| +7. **Crash between claim and record.** Inject a failure after |
| `BeginCreate`; advance the clock past five minutes; recovery frees the |
| claim, retires the incarnation, lists the key; a create by another app |
| succeeds. The negative: record present, recovery completes, never |
| frees. |
| -7. **Crash after commit point.** Inject a failure after `CompleteCreate` |
| - with the sibling re-check forced to fail: the door does not call |
| - `CancelCreate`; the host stays serving; the test documents that this |
| - path cannot occur because the check moved before the record. |
| 8. **Pending alias never serves** (A-8): a pending claim and a lifecycle |
| - holder whose route is not yet published; `ReconcileAliases` writes no |
| - `route_aliases` row; the sweep deletes the pending claim. |
| -9. **Backfill.** Records with no claims across three apps and two |
| - accounts, one host with two records, one undecodable record. The verb |
| - adopts the singletons, quarantines the pair, reports the bad record, |
| - writes no marker. After repair, two clean passes write it. The door |
| - refuses creates at the quarantined host throughout and treats absent |
| - as not free until the marker. |
| -10. **Legacy writer after the floor** (A-2): with the floor published, |
| - an attempt to run an old-path create is refused by the floor; with |
| - the floor absent, the marker verb refuses to run. |
| -11. **Privacy table.** For every row of §7's table and every door, the |
| - status and body match the table, cross-account bodies are |
| - byte-identical to a foreign `domains/` claim's refusal and contain no |
| - sqid, and an invalid request answers 400 whether or not the host is |
| - held. |
| -12. **Apex.** `claimApp` racing an instance create at the label under |
| - every interleaving: at most one winner; the loser's row or record is |
| - gone after the sweeps; no pending row survives. |
| -13. **Namespace rule.** Each row of §4.6's table against each door, with |
| - reservation on and off, including a manifest `Domain` of |
| - `<app>.<sqid>.<apps>` for another app. |
| -14. **Canary mint does not overwrite.** A route at `<x>.<app>.<sqid>.<apps>` |
| - owned by another app; the mint skips and logs. |
| -15. **Migrate refusal** for an app with a record; success for an app |
| - with releases only. |
| -16. **Purge detaches claims.** The console purge door removes the app's |
| - claims before `PurgeApp`; box auto-purge leaves them and the sweep |
| - reports without deleting. |
| + holder whose route is not yet published; `ReconcileAliases` derives |
| + nothing for it (alias, wildcard, catch-all, paths); the sweep deletes |
| + it. |
| +9. **Alias versus app name** (B-5): with reservation off, an alias at |
| + `<l>.<sqid>.<apps>` racing `claimApp` for `<l>` under every |
| + interleaving; at most one commits; the loser's row or claim is gone |
| + after the sweeps. |
| +10. **Backfill.** Records across three apps and two accounts; one host |
| + with two records; one record with an alias claim; one legacy |
| + first-route marker with and without its record; one undecodable |
| + record; one app prefix with no row. The verb adopts the clean |
| + singletons, stamps legacy claims, quarantines each bad combination |
| + with every key named, blocks on the bad record and the rowless app, |
| + writes no marker; after repair, two clean passes write it. The create |
| + door answers 503 throughout the freeze and refuses quarantined hosts |
| + after it. |
| +11. **Freeze** (B-2): above the floor and before the marker, every door |
| + answers 503 and writes nothing; the marker verb refuses below the |
| + floor. |
| +12. **Quarantine guard** (B-3): an orphan at a quarantined host survives |
| + the recovery pass. |
| +13. **Response mapping.** Every row of §7 on every door; cross-account |
| + bodies byte-identical and sqid-free; read failures answer 502 for a |
| + held and a free host alike; an invalid request answers 400 whether or |
| + not the host is held. |
| +14. **Apex gate** (B-9): create, rename and move each crashed after step |
| + 1 and after step 2; both names stay reserved; the sweep settles with |
| + the op id; a paused handler finding its op gone does nothing. |
| +15. **Namespace rule**, each row of §4.6 against each door, reservation |
| + on and off, including the own-app exception and a manifest `Domain` |
| + of `<app>.<sqid>.<apps>` for another app. |
| +16. **Canary mint does not overwrite** a route owned by another app. |
| +17. **Migrate** (B-13): refused for an app with a record, with admission |
| + closed during the check and reopened after; a create during the |
| + check is refused by admission; success for a releases-only app with |
| + no route stamping. |
| +18. **Purge and materialization** (B-8): the purge door detaches claims |
| + before `PurgeApp`; after box auto-purge, materialization detaches |
| + claims before releasing the row; a new app of the name inherits |
| + nothing. |
| The joint simulation (`internal/sim/hostlife`) should gain the alias |
| -actor, the pending state and the two-family invariant, if the moves |
| +actor, the acquisition state and the two-family invariant, if the moves |
| session agrees; this spec does not depend on it. |
| ## 11. Rollout |
| -1. Land `internal/hostclaim` on main (`feat/hostclaim-library`). |
| -2. Ship the doors, the two sweeps, the backfill verb, the `pending` |
| - field, the namespace rule and the canary-mint check, all **dormant |
| - below the floor**: below it every door runs today's path and the verb |
| - refuses to run. |
| +1. Prerequisites, in the lifecycle spec and library: the adoption |
| + evidence rule and the quarantine guard (§6). Land `internal/hostclaim` |
| + on main with them. |
| +2. Ship the doors, the two sweeps, the backfill verb, the `acq` block, |
| + the namespace rule, the apex gate's op table, the purge and |
| + materialization detaches, the canary-mint check and the pending skip |
| + in `ReconcileAliases`, all **dormant below the floor**: below it every |
| + door runs today's path and the verb refuses to run. |
| 3. Roll consoles, then boxes, in the lifecycle spec's order. Publish the |
| - floor. |
| + floor. The freeze begins. |
| 4. Run the backfill dry; read the quarantine and the reports; repair; |
| - run live until two clean passes write the marker. |
| -5. The doors read the marker and switch to §4.2 on the next request. |
| - `hostHolder`'s record loop, `apexHolder`'s scan and `ReportApexSquats`' |
| + run live until two clean passes write the marker. The freeze lifts. |
| +5. `hostHolder`'s record loop, `apexHolder`'s scan and `ReportApexSquats`' |
| route walk are removed in the release after. |
| 6. Follow-ups filed, not built here: migrate's transfer and `domains/` |
| - rebind (§4.8); the lifecycle spec's adoption evidence rule (§6). |
| + rebind (§4.8). |
| ## 12. Alternatives considered |
| @@ -770,15 +877,21 @@ session agrees; this spec does not depend on it. |
| its own crash recovery. |
| - **An `alias` holder family in the lifecycle claim.** One object for |
| every routable name, no two-family rule, and the alias pass would read |
| - one authority (A-8's "unify" option). Rejected for now: it changes a |
| - settled spec and `holderKeyMatches`, gives every custom domain a |
| - lifecycle object the recovery pass lists, and still needs the pending |
| - state for same-owner retries. It remains the cleanest end state if the |
| - two-family rule proves hard to keep as doors are added; §13 asks. |
| -- **Box-side alias pass reading the lifecycle claim** (A-8's other |
| - option). Rejected: one GET per alias per box per pass, or a second |
| - `ClaimCache` over `control/hosts/`. The pending state gives the pass a |
| - committed-only rule with no new read. |
| + one authority. Rejected for now: it changes a settled spec and |
| + `holderKeyMatches`, gives every custom domain a lifecycle object the |
| + recovery pass lists, and still needs an acquisition state for |
| + same-owner retries. It remains the cleanest end state if the two-family |
| + rule proves hard to keep as doors are added; §13 asks. |
| +- **Box-side alias pass reading the lifecycle claim.** Rejected: one GET |
| + per alias per box per pass, or a second cache over `control/hosts/`. |
| + The pending state gives the pass a committed-only rule with no new |
| + read. |
| +- **A sweep that commits stale pending aliases.** Rejected (B-12): the |
| + sweep lacks the observations to authorise a commit, and a commit nobody |
| + requested is an attach nobody asked for. Delete-only. |
| +- **Plain `CreateRecord` between the floor and the marker.** Rejected |
| + (B-2): any such write after the final listing turns a legitimate record |
| + into a future orphan. A short freeze is cheaper than a wrong delete. |
| - **Deterministic tie-break instead of both backing off.** Rejected as |
| unsound (§4.1). |
| - **Lazy adoption.** Rejected (A-1): the door can only see the caller's |
| @@ -790,22 +903,24 @@ session agrees; this spec does not depend on it. |
| ## 13. Open questions for Paul |
| -1. Two families with pending alias claims (this spec), or one object with |
| - an `alias` family in the lifecycle claim (§12)? The second reopens the |
| - lifecycle spec. |
| -2. The lifecycle spec's §9 adoption rule: accept the deployment-wide |
| - single-record evidence requirement, and defer adoption to the backfill |
| - verb for all actors (§6)? |
| -3. Account migrate: refuse apps with records indefinitely (this spec), or |
| +1. Two families with acquisition-stamped alias claims (this spec), or one |
| + object with an `alias` family in the lifecycle claim (§12)? The second |
| + reopens the lifecycle spec. |
| +2. The lifecycle spec's §9 adoption rule and the quarantine guard are now |
| + rollout prerequisites (§6). Accept them into that spec, with the |
| + backfill verb as the only adopting actor? |
| +3. The freeze (§8.2): a few minutes of 503 on every create and attach |
| + door on flagship, once. Acceptable, or should the backfill run against |
| + a snapshot with a different fence? |
| +4. Account migrate: refuse apps with records indefinitely (this spec), or |
| file the transfer protocol now? |
| -4. The claim sweep commits a stale pending alias on its owner's behalf |
| - when the route is ready. Prefer delete-only, so nothing is ever |
| - attached without a request completing? |
| 5. The namespace rule refuses `<x>.<app>.<sqid>.<apps>` through every |
| door, and a manifest `Domain` under another app's apex. Acceptable on |
| deployments with reservation off? |
| 6. Backfill quarantine: operator picks the winner by hand (this spec), or |
| the verb keeps the record the provisioning pass currently keeps (first |
| in listing order)? |
| -7. The acquisition timeout of 60 s and the recovery age of five minutes |
| - are guesses. Shorter? |
| +7. The acquisition timeout of 60 s, the sweep cadence, and the recovery |
| + age of five minutes are guesses. Shorter? |
| +8. Rev 3 has not been reviewed. A third Astra round before planning, or |
| + straight to plans with the review at plan level? |