design: self-serve offline moves, rev 13 (Astra round 12)
Recovery replays a pending create at its allocated incarnation from the persisted request and never advances a slot to abandon one, so a create that materialised keeps serving; deletes are conditional on the record ETag read and retire only the captured slot revision, with persisted intent, so a delayed delete cannot erase a successor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+4
−4
docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md+4 −4
CI failed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.014s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (2 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (140 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.682s
ok github.com/carlosframework/platform/cmd/moveprobe 11.887s
ok github.com/carlosframework/platform/cmd/zddprobe 0.748s
ok github.com/carlosframework/platform/internal/activator 59.835s
ok github.com/carlosframework/platform/internal/agentsock 0.032s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.040s
ok github.com/carlosframework/platform/internal/appconfig 2.858s
ok github.com/carlosframework/platform/internal/apperr 0.189s
ok github.com/carlosframework/platform/internal/applog 0.363s
ok github.com/carlosframework/platform/internal/awsinv 0.006s
ok github.com/carlosframework/platform/internal/boxchoice 0.008s
ok github.com/carlosframework/platform/internal/boxid 0.846s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.016s
ok github.com/carlosframework/platform/internal/caps 0.016s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.734s
ok github.com/carlosframework/platform/internal/changelog 0.504s
ok github.com/carlosframework/platform/internal/claimactivation 1.155s
ok github.com/carlosframework/platform/internal/clamd 0.066s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.734s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.011s
ok github.com/carlosframework/platform/internal/domains 2.848s
ok github.com/carlosframework/platform/internal/doorkey 0.017s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 45.190s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.014s
ok github.com/carlosframework/platform/internal/fleetapi 0.190s
ok github.com/carlosframework/platform/internal/gate 1.782s
ok github.com/carlosframework/platform/internal/genselect 0.024s
ok github.com/carlosframework/platform/internal/geo 0.023s
ok github.com/carlosframework/platform/internal/geoip 0.071s
ok github.com/carlosframework/platform/internal/historyreadback 0.196s
ok github.com/carlosframework/platform/internal/host 137.134s
ok github.com/carlosframework/platform/internal/hostactors 6.213s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.008s
ok github.com/carlosframework/platform/internal/hostbox 3.364s
ok github.com/carlosframework/platform/internal/hostclaim 0.805s
ok github.com/carlosframework/platform/internal/hostenv 0.006s
ok github.com/carlosframework/platform/internal/hostevidence 0.012s
ok github.com/carlosframework/platform/internal/hostexec 5.414s
ok github.com/carlosframework/platform/internal/hostfence 0.033s
ok github.com/carlosframework/platform/internal/hostfiles 10.155s
ok github.com/carlosframework/platform/internal/hostfloor 0.025s
ok github.com/carlosframework/platform/internal/hostguard 1.089s
ok github.com/carlosframework/platform/internal/hostops 2.224s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s
ok github.com/carlosframework/platform/internal/hostquarantine 0.005s
ok github.com/carlosframework/platform/internal/hostrelocate 0.578s
ok github.com/carlosframework/platform/internal/hostresolve 0.245s
ok github.com/carlosframework/platform/internal/hostwrite 0.003s
ok github.com/carlosframework/platform/internal/infraview 0.006s
ok github.com/carlosframework/platform/internal/instancectl 5.229s
ok github.com/carlosframework/platform/internal/instanceexec 2.829s
ok github.com/carlosframework/platform/internal/instancefs 0.012s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.006s
ok github.com/carlosframework/platform/internal/layout 1.968s
ok github.com/carlosframework/platform/internal/lbhealth 0.004s
ok github.com/carlosframework/platform/internal/lease 10.170s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.557s
ok github.com/carlosframework/platform/internal/ledger 0.018s
ok github.com/carlosframework/platform/internal/mail 0.226s
ok github.com/carlosframework/platform/internal/maintenance 0.116s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.587s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.109s
ok github.com/carlosframework/platform/internal/metrics 0.008s
ok github.com/carlosframework/platform/internal/moves 21.355s
ok github.com/carlosframework/platform/internal/moves/archive 1.128s
ok github.com/carlosframework/platform/internal/objstore 0.318s
ok github.com/carlosframework/platform/internal/ops 0.175s
ok github.com/carlosframework/platform/internal/park 0.254s
ok github.com/carlosframework/platform/internal/plans 0.008s
ok github.com/carlosframework/platform/internal/privatefile 0.007s
ok github.com/carlosframework/platform/internal/provision 13.304s
ok github.com/carlosframework/platform/internal/publicrelease 0.008s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.516s
ok github.com/carlosframework/platform/internal/relay 7.241s
ok github.com/carlosframework/platform/internal/release 25.132s
--- FAIL: TestAMoveOntoTheInstalledGoodBuildWaitsForASignedRollback (5.02s)
--- FAIL: TestAMoveOntoTheInstalledGoodBuildWaitsForASignedRollback/socket_instance (5.01s)
holdran_test.go:796: timed out waiting for the move onto v1
FAIL
FAIL github.com/carlosframework/platform/internal/rollout 8.668s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.062s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.575s
ok github.com/carlosframework/platform/internal/s3compat 0.009s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.756s
ok github.com/carlosframework/platform/internal/seeds 0.013s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.747s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.604s
ok github.com/carlosframework/platform/internal/status 0.068s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.188s
ok github.com/carlosframework/platform/internal/svcrole 0.005s
ok github.com/carlosframework/platform/internal/telemetry 0.010s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.003s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.008s
ok github.com/carlosframework/platform/internal/usage 0.008s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.650s
FAIL
=== internal_console@0 (76 s)
ok github.com/carlosframework/platform/internal/console 73.932s
=== internal_console@1 (38 s)
ok github.com/carlosframework/platform/internal/console 35.545s
=== internal_console@2 (42 s)
ok github.com/carlosframework/platform/internal/console 39.868s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 34.964s
=== internal_console@4 (37 s)
ok github.com/carlosframework/platform/internal/console 35.055s
=== internal_console@5 (35 s)
ok github.com/carlosframework/platform/internal/console 32.887s
=== internal_console@6 (37 s)
ok github.com/carlosframework/platform/internal/console 34.505s
=== internal_console@7 (43 s)
ok github.com/carlosframework/platform/internal/console 41.227s
=== root@0 (32 s)
ok github.com/carlosframework/platform 30.290s
=== root@1 (44 s)
ok github.com/carlosframework/platform 42.102s
=== root@2 (41 s)
ok github.com/carlosframework/platform 39.241s
FAILED: rest
make: *** [Makefile:19: test] Error 1
amadan: step gate failed: exit status 2
amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md b/docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md| index 947dab323..5a7f6fb5c 100644 |
| --- a/docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md |
| +++ b/docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md |
| @@ -1,6 +1,6 @@ |
| # Self-serve offline moves: a tenant's customer moves their own instance |
| -Rev 12, 2026-10-22. Status: design for review (Paul). Branch `design-self-serve-offline-moves`. Rev 1 (062387a1) was reviewed by Astra (`after236/selfserve-spec-review.md`, NOT READY, 14 findings) and by a cold builder check (7 questions); rev 2 (0e58e51d) answered them and was reviewed again (`selfserve-spec-review2.md`, NOT READY, 12 findings). Rev 2's automatic return after `open` was the source of most of round 2: whether the new region ever accepted a write cannot be proven from the bucket (litestream releases the lease even when its final sync failed; file-only changes leave no LTX; an absent lock does not tell "never taken" from "released"), and a safe post-open revert would need a new admission barrier and stop protocol for the target, which is Phase 1's work. **Rev 3 narrows the promise to what is provable**: every failure before `open` returns the account to the old region automatically and losslessly, everything checkable is checked before `open`, and a failure after `open` is paged with the holding page kept until the app answers, the return being an operator's run of the same offline move the other way. This changes one of Paul's rulings and is §10 Q3. Rev 3 (b77c01a9) was reviewed again (`selfserve-spec-review3.md`, NOT READY, 8 findings, the abort boundary accepted); rev 4 answers them: which failures stay after `open` (§7.5), the job's durable admission order (§7), cleanup's own reservation and the root handoff (§7.6), the holding entries' causal ordering (§6.4), the probe mode (§7.4), deadlines as initiate-and-retry with blocked states (§7.2), and the stale contradictions. Rev 4 (20fdcf18) was reviewed again (`selfserve-spec-review4.md`, NOT READY, 2 P1 and 2 P2: holding entries ordered by move identity cannot order consecutive moves or show an abort completed; a timed-out root cleanup request could run after its reservation was released; the queued handshake's producers; stale text). Rev 5 answers them with a per-host `move_seq` published with every route and holding entry (§6.4), a root request that ends only with root's `done` or acknowledged `cancelled` (§7.6), queued defined as no copying with the participants' reports allowed (§7), and the stale passages aligned. Rev 5 (89e0f37a) was reviewed again (`selfserve-spec-review5.md`, NOT READY, 1 P1 and 2 P2: a serving row rebuilt by ordinary reconciliation after a finished move would carry sequence 0 and lose to a stale holding entry, and a recreated host's publications need separating from a previous life's; the queued table order; two stale sentences). Rev 6 answers them: reconciliation persists the record's sequence with a row it mints after a move (§6.4), a per-record `move_lineage` separates lives (§6.4), the table order and the two sentences are fixed. Rev 6 (bc5e2d97) was reviewed again (`selfserve-spec-review6.md`, NOT READY, 1 P1: a random lineage identifies but does not order lifetimes, and a lineage-less publication retained by an edge through a read failure can coexist with the first move's holding entry). Rev 7 replaces the lineage with a durable per-hostname incarnation number the console allocates and never reuses, so every publication orders as (incarnation, sequence) and a publication without one loses (§6.4). Rev 7 (f2feed6c) was reviewed again (`selfserve-spec-review7.md`, NOT READY, 1 P1: a custom domain repointed from one instance to another compares two unrelated counters). Rev 8 gives alias bindings their own durable revision from the domains record, published with the target hostname; edges pick the newest binding first and compare the target's pair within it (§6.4). Rev 8 (05f4c353) was reviewed again (`selfserve-spec-review8.md`, NOT READY, 1 P1: aliases derived from a wildcard catch-all have no binding record to carry a revision; 1 P2: retaining a detached domains record changes what a claim listing means). Rev 9 narrows: holding entries list explicit aliases only and derived aliases keep today's answer during a move; the binding revision lives in a console table that survives the domains record, so detach stays a delete (§6.4). Rev 9 (3bcea70e) was reviewed again (`selfserve-spec-review9.md`, NOT READY: a detached explicit alias can fall back to a derived one beneath a retained explicit holding entry; the revision table and the bucket write need a crash-safe protocol). Every remaining round-8 and round-9 finding was about a holding entry an edge keeps as last-good through a read failure and later orders against a new binding. **Rev 10 stops tolerating that case instead of ordering it:** a holding entry is never last-good, it is honoured only from a publication fetched within the last five minutes, and the alias revision machinery is dropped; the incarnation number is allocated durably before it is exposed (§6.4). Rev 10 (82023a80) was reviewed again (`selfserve-spec-review10.md`, NOT READY, 2 P1 and 1 P2: two concurrent creates of one hostname can commit incarnations out of order; a fresh hold must not displace a retained ready route for an alias that now names another instance; alias publications need the target hostname). Rev 11 answers them: creates and deletes of a hostname are serialised through a pending allocation that only a completed record write commits (§6.4); for an alias, any eligible ready route naming a different target wins over a holding entry, pairs are compared only within one target, and the five-minute bound is stated as a bound on reader disconnection, not publisher death (§6.4); every alias publication carries its target hostname (§6.4). Rev 11 (9ae3902f) was reviewed again (`selfserve-spec-review11.md`, NOT READY, 1 P1: recovery after a console crash could abandon an outstanding create whose record write later lands, because the record's "absent" precondition recurs after a deletion). Rev 12 makes the incarnation an authoritative bucket slot that never returns to absent and that every consumer checks the record against, so a late write materialises nothing (§6.4). |
| +Rev 13, 2026-10-22. Status: design for review (Paul). Branch `design-self-serve-offline-moves`. Rev 1 (062387a1) was reviewed by Astra (`after236/selfserve-spec-review.md`, NOT READY, 14 findings) and by a cold builder check (7 questions); rev 2 (0e58e51d) answered them and was reviewed again (`selfserve-spec-review2.md`, NOT READY, 12 findings). Rev 2's automatic return after `open` was the source of most of round 2: whether the new region ever accepted a write cannot be proven from the bucket (litestream releases the lease even when its final sync failed; file-only changes leave no LTX; an absent lock does not tell "never taken" from "released"), and a safe post-open revert would need a new admission barrier and stop protocol for the target, which is Phase 1's work. **Rev 3 narrows the promise to what is provable**: every failure before `open` returns the account to the old region automatically and losslessly, everything checkable is checked before `open`, and a failure after `open` is paged with the holding page kept until the app answers, the return being an operator's run of the same offline move the other way. This changes one of Paul's rulings and is §10 Q3. Rev 3 (b77c01a9) was reviewed again (`selfserve-spec-review3.md`, NOT READY, 8 findings, the abort boundary accepted); rev 4 answers them: which failures stay after `open` (§7.5), the job's durable admission order (§7), cleanup's own reservation and the root handoff (§7.6), the holding entries' causal ordering (§6.4), the probe mode (§7.4), deadlines as initiate-and-retry with blocked states (§7.2), and the stale contradictions. Rev 4 (20fdcf18) was reviewed again (`selfserve-spec-review4.md`, NOT READY, 2 P1 and 2 P2: holding entries ordered by move identity cannot order consecutive moves or show an abort completed; a timed-out root cleanup request could run after its reservation was released; the queued handshake's producers; stale text). Rev 5 answers them with a per-host `move_seq` published with every route and holding entry (§6.4), a root request that ends only with root's `done` or acknowledged `cancelled` (§7.6), queued defined as no copying with the participants' reports allowed (§7), and the stale passages aligned. Rev 5 (89e0f37a) was reviewed again (`selfserve-spec-review5.md`, NOT READY, 1 P1 and 2 P2: a serving row rebuilt by ordinary reconciliation after a finished move would carry sequence 0 and lose to a stale holding entry, and a recreated host's publications need separating from a previous life's; the queued table order; two stale sentences). Rev 6 answers them: reconciliation persists the record's sequence with a row it mints after a move (§6.4), a per-record `move_lineage` separates lives (§6.4), the table order and the two sentences are fixed. Rev 6 (bc5e2d97) was reviewed again (`selfserve-spec-review6.md`, NOT READY, 1 P1: a random lineage identifies but does not order lifetimes, and a lineage-less publication retained by an edge through a read failure can coexist with the first move's holding entry). Rev 7 replaces the lineage with a durable per-hostname incarnation number the console allocates and never reuses, so every publication orders as (incarnation, sequence) and a publication without one loses (§6.4). Rev 7 (f2feed6c) was reviewed again (`selfserve-spec-review7.md`, NOT READY, 1 P1: a custom domain repointed from one instance to another compares two unrelated counters). Rev 8 gives alias bindings their own durable revision from the domains record, published with the target hostname; edges pick the newest binding first and compare the target's pair within it (§6.4). Rev 8 (05f4c353) was reviewed again (`selfserve-spec-review8.md`, NOT READY, 1 P1: aliases derived from a wildcard catch-all have no binding record to carry a revision; 1 P2: retaining a detached domains record changes what a claim listing means). Rev 9 narrows: holding entries list explicit aliases only and derived aliases keep today's answer during a move; the binding revision lives in a console table that survives the domains record, so detach stays a delete (§6.4). Rev 9 (3bcea70e) was reviewed again (`selfserve-spec-review9.md`, NOT READY: a detached explicit alias can fall back to a derived one beneath a retained explicit holding entry; the revision table and the bucket write need a crash-safe protocol). Every remaining round-8 and round-9 finding was about a holding entry an edge keeps as last-good through a read failure and later orders against a new binding. **Rev 10 stops tolerating that case instead of ordering it:** a holding entry is never last-good, it is honoured only from a publication fetched within the last five minutes, and the alias revision machinery is dropped; the incarnation number is allocated durably before it is exposed (§6.4). Rev 10 (82023a80) was reviewed again (`selfserve-spec-review10.md`, NOT READY, 2 P1 and 1 P2: two concurrent creates of one hostname can commit incarnations out of order; a fresh hold must not displace a retained ready route for an alias that now names another instance; alias publications need the target hostname). Rev 11 answers them: creates and deletes of a hostname are serialised through a pending allocation that only a completed record write commits (§6.4); for an alias, any eligible ready route naming a different target wins over a holding entry, pairs are compared only within one target, and the five-minute bound is stated as a bound on reader disconnection, not publisher death (§6.4); every alias publication carries its target hostname (§6.4). Rev 11 (9ae3902f) was reviewed again (`selfserve-spec-review11.md`, NOT READY, 1 P1: recovery after a console crash could abandon an outstanding create whose record write later lands, because the record's "absent" precondition recurs after a deletion). Rev 12 makes the incarnation an authoritative bucket slot that never returns to absent and that every consumer checks the record against, so a late write materialises nothing (§6.4). Rev 12 (35bd391d) was reviewed again (`selfserve-spec-review12.md`, NOT READY, 2 P1: recovery that advances the slot would invalidate a create whose record did land; the ordinary delete is unconditional and a delayed one could erase a successor). Rev 13 answers them: recovery replays the create's own intent to completion at its allocated number and never advances a slot to abandon; deletes are conditional on the observed record ETag and retire only the captured slot revision, with persisted intent (§6.4). |
| Builds on the offline operator move (`docs/superpowers/plans/2026-10-20-offline-operator-move.md`, shipped in v0.28.5 to v0.28.7) and its runbook (`docs/superpowers/plans/2026-10-21-offline-move-runbook.md`). Supersedes nothing: Phase 1 (`docs/superpowers/specs/2026-10-02-move-any-app-design.md`) stays the live-move path for claims-on deployments, and the M1 to M18 rulings stand. M18 ("Tito waits; move mutations are operator-only") is answered here for one door, not lifted: §5 says exactly what the customer's request may do. |
| @@ -120,7 +120,7 @@ From `start` until the target's row is ready, the host is held on every source b |
| - **TLS.** A host or alias in a holding entry is allowed for TLS exactly as it would be as a served route (the same `Allowed` decision, wildcard and mirrored certificates included), so the page is reachable on HTTPS; the entry never widens what the host could already present. |
| - **The display name.** Tito supplies it per request; the console validates it (at most 80 characters, printable, no control characters, no newlines) and stores it in the move; the edge renders it through contextual template escaping. It is the one piece of request-supplied text the page shows, by design (the customer named their own account), and it is shown to unauthenticated visitors, as the account's own pages are. Nothing else from the request, no header, path, diagnostic or `requested_by`, reaches the page. |
| - **Ordering by a per-host sequence.** The record gains `move_seq`, a per-host counter the console initialises to 1 on a host's first offline move and increments on every offline transition that changes who serves the host: `start` (the hold), `open` (the target serves), the abort's reopening of the source, and `finish`. Every box persists on its registry row the `move_seq` of the transition that produced the row's current state (`serving_seq`: the hold's for a held row, `open`'s for a landed ready row, the abort's for a reopened source row), written in the same row write as the status, and publishes it with the route and with any holding entry. **Rows minted outside a move:** when ordinary reconciliation mints or re-mints a ready row for a host from a record that carries no move (a rebuilt registry, a box returning from an outage, a row removed and restored), it writes `serving_seq` = the record's current `move_seq` (the last finished or aborted move's final value) in the same write; a record with no `move_seq` (a host with no move history) gives 0. Zero therefore means "no move history", never "a new row". Observing a newer record does not by itself move a row's sequence; only applying a transition, or minting the row from the record, does. |
| -- **Lifetimes: the host incarnation slot.** Each hostname has one authoritative object, `control/host-incarnations/<host>.json`, the **slot**: `{incarnation, state: live|retired, intent}`. It is created once and never deleted; every change is a conditional write (`If-None-Match: *` the first time, `If-Match: <etag>` after), so it never returns to absent. The create door: takes the console's per-hostname mutex; records a `pending` allocation in SQLite; advances the slot to `{incarnation: N, live, intent: <create id>}` (N = the slot's previous value plus one, or 1 for a new slot); writes the record carrying `incarnation: N`; marks the allocation `committed`. The delete door: deletes the record, then writes the slot `{N, retired}` conditionally; the slot stays. **Every consumer of a record that carries an incarnation checks it against the slot** before acting on it: reconcile mints and keeps a row only for a record whose incarnation equals the slot's and whose slot is `live`; the doors refuse a record that fails that check as absent; a box publishes nothing for it. So a record write that lands late, after a crash and a recovery, materialises nothing: recovery resolves a `pending` allocation by **advancing the slot** (`{N+1, live, intent: recovery}` under `If-Match`), never by reading the record's absence; the late record with N is stale to every consumer, and the next create for the hostname, finding a stale record under the key, deletes it conditionally on its ETag and retries. Existing hosts' lazy migration writes the slot first (`{1, live}`) and then the record's incarnation by CAS; a record without the field whose slot exists is a migration in flight and is valid; a record without the field and no slot is legacy and valid. Boxes read the slot alongside the record (one small object per host per pass, cached by ETag). `move_seq` stays on the record; `serving_seq` is persisted and published as before, as `(incarnation, serving_seq)`; publications without the pair are `(0, 0)`. The order among publications is as before: the first move's hold `(1, 1)` beats a retained pair-less ready `(0, 0)`; `open` `(1, 2)` beats the hold; a recreated host's first row `(2, 0)` beats every publication of lifetime 1; a current hold beats a previous lifetime's ready route; two ready routes from different lifetimes resolve to the higher incarnation; equal pairs fall to the existing home-map rule. |
| +- **Lifetimes: the host incarnation slot.** Each hostname has one authoritative object, `control/host-incarnations/<host>.json`, the **slot**: `{incarnation, state: live|retired, intent}`. It is created once and never deleted; every change is a conditional write (`If-None-Match: *` the first time, `If-Match: <etag>` after), so it never returns to absent. The create door: takes the console's per-hostname mutex; records a `pending` allocation in SQLite; advances the slot to `{incarnation: N, live, intent: <create id>}` (N = the slot's previous value plus one, or 1 for a new slot); writes the record carrying `incarnation: N`; marks the allocation `committed`. The delete door: records a `pending` retirement in SQLite `{host, incarnation N, record ETag as read, slot ETag as read}`; deletes the record **conditionally on that ETag** (`release.DeleteIfMatch`, bound to the account, app and incarnation it authorised); writes the slot `{N, retired}` **conditionally on the captured slot ETag**; marks the retirement `committed`. A conditional failure at either step means a successor exists (a different record under the key, or a slot past N): the retirement is `void` and touches nothing. A retirement found `pending` on restart is resolved the same way, from the captured ETags: record gone and slot still at the captured ETag: retire the slot; record present with another ETag or incarnation, or slot moved: `void`. A delayed delete sent before a crash can therefore never remove a successor's record: it carries the old ETag and fails `If-Match`. **Every consumer of a record that carries an incarnation checks it against the slot** before acting on it: reconcile mints and keeps a row only for a record whose incarnation equals the slot's and whose slot is `live`; the doors refuse a record that fails that check as absent; a box publishes nothing for it. **Recovery completes the create, it never abandons it.** The `pending` allocation in SQLite carries the create's whole request (its id, account, app, host, region and body), so a restart can replay it. For each `pending` allocation N the restarted console reads the slot and the record: the record present with incarnation N, matching: `committed`; the record absent and the slot still `{N, live, intent: this create}`: the create is **replayed** idempotently at N (the same conditional record write; a late original write that lands first is the same content, and whichever lands second fails `If-None-Match` harmlessly), then `committed`; the slot already past N (a successor's create or a retirement happened, which the per-hostname mutex only allows after this allocation was resolved, so this means an operator moved the slot by hand): `superseded`, and any record with incarnation N under the key is stale to every consumer and replaced by the next create. The slot is never advanced in order to abandon a create; so a create that materialised keeps serving through any number of console crashes. Existing hosts' lazy migration writes the slot first (`{1, live}`) and then the record's incarnation by CAS; a record without the field whose slot exists is a migration in flight and is valid; a record without the field and no slot is legacy and valid. Boxes read the slot alongside the record (one small object per host per pass, cached by ETag). `move_seq` stays on the record; `serving_seq` is persisted and published as before, as `(incarnation, serving_seq)`; publications without the pair are `(0, 0)`. The order among publications is as before: the first move's hold `(1, 1)` beats a retained pair-less ready `(0, 0)`; `open` `(1, 2)` beats the hold; a recreated host's first row `(2, 0)` beats every publication of lifetime 1; a current hold beats a previous lifetime's ready route; two ready routes from different lifetimes resolve to the higher incarnation; equal pairs fall to the existing home-map rule. |
| - **Aliases.** A holding entry lists the host's explicit aliases (those with their own domains record naming the host) as the edges writer would publish them for a served route, under the same eligibility rules; derived aliases from a wildcard catch-all are not listed, and answer during a move as they do today for a held row (the pending page on a platform domain, the plain 404 elsewhere). **Every alias publication, ready routes included and derived aliases included, carries `target_host`**, the primary hostname it resolves to; holding entries identify their target through `host`. The edge's rule for an alias: **any ready route for the alias that is eligible under ordinary routing (fresh or last-good, remote or local) and names a different `target_host` than a holding entry wins over that holding entry**, whatever the two targets' pairs, because a binding change is a console decision and the pairs of different instances are unrelated; a holding entry and a ready route for the **same** `target_host` are compared by the target's `(incarnation, serving_seq)` as for the primary hostname (the stale ready route of the instance being moved loses to its own hold). A legacy publication without `target_host` is treated as a different target (the ready route wins), which is today's behaviour. No alias revision is kept. The five-minute bound on holding entries bounds a reader's disconnection from a publisher, not a publisher's death: a box that stops updating but whose last object stays readable keeps its holding entry eligible, which is exactly the case the different-target rule decides. Expiry is enforced from the entry's last successful fetch time, independent of any later refresh succeeding; a refresh that fails as a whole leaves the old snapshot's routes installed as today and its holding entries expiring on their own clock. "Within one refresh" describes healthy convergence after a repoint, not a deadline. |
| - **Reopening.** The public reopens when the target's row is ready and its publication, at `open`'s sequence, reaches the edges: visitors' first requests wake the app as any cold wake does. The console's health check (§7.4) is detection, not a gate on reopening. A failure after that is visible to visitors as the app's own answers, as any instance failure is. |
| - **The health probe** (§7.4) goes to the target box directly, by its address from its edges file with the host as SNI, so it reaches the app whatever the edges show. |
| @@ -257,10 +257,10 @@ Infra: both staging lanes for every region pair the console arms (today IE to UK |
| - The estimate's storage and the rolling median; the phase timings' recording. |
| - The `moves` column migration, the mint flag and the instance-bound refusal. |
| - The return's leftover acceptance in the operator start door's target-ready contract, keyed by the forward move id. |
| -- the incarnation slot `control/host-incarnations/<host>.json` (never deleted, conditional writes, advanced on create and by recovery, retired on delete), the record's `incarnation` checked against it by every consumer, `move_seq` on the record, `(incarnation, serving_seq)` on rows (written with the status, by moves and by reconciliation), both published with routes and holding entries; the edges' highest-pair rule with `(0, 0)` for publications without the pair; the lazy migration for existing hosts; the five-minute validity of holding entries at the edge (never last-good, enforced from the fetch time), with the fallback answer; `target_host` on alias publications and the different-target rule; derived aliases excluded from holding entries; the per-hostname pending allocation, its restart resolution by advancing the slot, and the stale-record cleanup in the create door. the probe mode (`X-Carlos-Probe`, `X-Carlos-Served-By`); the "taking longer" sentence's trigger. |
| +- the incarnation slot `control/host-incarnations/<host>.json` (never deleted, conditional writes, advanced on create and by recovery, retired on delete), the record's `incarnation` checked against it by every consumer, `move_seq` on the record, `(incarnation, serving_seq)` on rows (written with the status, by moves and by reconciliation), both published with routes and holding entries; the edges' highest-pair rule with `(0, 0)` for publications without the pair; the lazy migration for existing hosts; the five-minute validity of holding entries at the edge (never last-good, enforced from the fetch time), with the fallback answer; `target_host` on alias publications and the different-target rule; derived aliases excluded from holding entries; the per-hostname pending allocation with the create's request, its replay on restart, the pending retirement with captured ETags and its resolution, and the stale-record cleanup in the create door. the probe mode (`X-Carlos-Probe`, `X-Carlos-Served-By`); the "taking longer" sentence's trigger. |
| - The root cleanup request, done and cancel files and root's check before each deletion. |
| - The `offline-queued` state and the `self_serve_moves` row's phases; the cleanup reservation kind in `active.json` and the create door's check of it. |
| -- Tests: the two-box end-to-end witness of the offline plan (Task 8) extended to the job: forward; cancel in ready; an abort after landing followed by the source row's reconstruction by reconciliation while the target's holding publication is retained, with the reconstructed route winning; the first move with the source's pair-less ready publication retained by an edge through the hold and the open; a host recreated after a move with the old lifetime's holding and ready publications retained, the new lifetime's first row winning; a current hold beating a previous lifetime's ready route; an edge restarted into those mixtures; a holding entry from a publication last fetched more than five minutes ago ignored, with the fallback answer, including after a whole refresh fails; an alias repointed from a held instance to another, the new target's ready route winning as a fresh and as a last-good publication; a detached explicit alias falling back to a derived one whose ready route names another target; the stale ready alias of the instance being moved losing to its own hold; an outstanding create whose record write lands after a console restart, a recovery that advanced the slot, a newer create and publication and a deletion: the late record is stale to reconcile, the doors and the boxes, and the next create replaces it; an edge restarted into mixed publications; a cut naming a stale id; each pre-check refusal (a stale live link, a non-empty extra directory, an ineligible archive, a failed lane probe); each deadline before open ending in a lossless abort; a post-open failure to answer ending in `done` with a warning and a page; the holding page on a third edge and a custom domain; a ready route winning over a stale holding entry and a holding entry winning over a stale route; two moves at once on disjoint boxes and two queued on one; a reservation reclaimed only on evidence; cleanup refusing when the record moved again, when a current version differs from the inventory, and when the reservation is held by a move. |
| +- Tests: the two-box end-to-end witness of the offline plan (Task 8) extended to the job: forward; cancel in ready; an abort after landing followed by the source row's reconstruction by reconciliation while the target's holding publication is retained, with the reconstructed route winning; the first move with the source's pair-less ready publication retained by an edge through the hold and the open; a host recreated after a move with the old lifetime's holding and ready publications retained, the new lifetime's first row winning; a current hold beating a previous lifetime's ready route; an edge restarted into those mixtures; a holding entry from a publication last fetched more than five minutes ago ignored, with the fallback answer, including after a whole refresh fails; an alias repointed from a held instance to another, the new target's ready route winning as a fresh and as a last-good publication; a detached explicit alias falling back to a derived one whose ready route names another target; the stale ready alias of the instance being moved losing to its own hold; a create whose record landed and is served, a crash before the allocation commits, and recovery marking it committed with the account still serving, including a second crash during recovery; an outstanding create replayed by recovery with the late original write failing harmlessly; an outstanding delete, a restart, a completed retry, a recreation and the late original delete failing on its ETag with the successor intact; a late stale record (incarnation behind the slot) ignored by reconcile, the doors and the boxes, and replaced by the next create; an edge restarted into mixed publications; a cut naming a stale id; each pre-check refusal (a stale live link, a non-empty extra directory, an ineligible archive, a failed lane probe); each deadline before open ending in a lossless abort; a post-open failure to answer ending in `done` with a warning and a page; the holding page on a third edge and a custom domain; a ready route winning over a stale holding entry and a holding entry winning over a stale route; two moves at once on disjoint boxes and two queued on one; a reservation reclaimed only on evidence; cleanup refusing when the record moved again, when a current version differs from the inventory, and when the reservation is held by a move. |
| ## 12. Risks |