carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 5c4c31f16d675384afd1a4d6a95188943f82baed parent 519d25e
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?