design: self-serve offline moves, rev 9 (Astra round 8)
Holding entries list explicit aliases only; wildcard-derived aliases keep today's answer during a move, because nothing can order a stale holding entry against a later instance deriving the same name. The binding revision lives in a console table that survives the domains record, so detach stays a delete and claim listings keep their meaning; attach and repoint write the new value into the record they create.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+6
−5
docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md+6 −5
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 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 (1 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (141 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.882s
ok github.com/carlosframework/platform/cmd/moveprobe 11.932s
ok github.com/carlosframework/platform/cmd/zddprobe 0.750s
ok github.com/carlosframework/platform/internal/activator 60.383s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.035s
ok github.com/carlosframework/platform/internal/appconfig 3.032s
ok github.com/carlosframework/platform/internal/apperr 0.226s
ok github.com/carlosframework/platform/internal/applog 0.221s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.008s
ok github.com/carlosframework/platform/internal/boxid 0.645s
? 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.009s
ok github.com/carlosframework/platform/internal/caps 0.018s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.803s
ok github.com/carlosframework/platform/internal/changelog 0.558s
ok github.com/carlosframework/platform/internal/claimactivation 1.248s
ok github.com/carlosframework/platform/internal/clamd 0.060s
? 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 6.073s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.010s
ok github.com/carlosframework/platform/internal/domains 2.899s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 45.549s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.009s
ok github.com/carlosframework/platform/internal/fleetapi 0.277s
ok github.com/carlosframework/platform/internal/gate 2.003s
ok github.com/carlosframework/platform/internal/genselect 0.025s
ok github.com/carlosframework/platform/internal/geo 0.024s
ok github.com/carlosframework/platform/internal/geoip 0.034s
ok github.com/carlosframework/platform/internal/historyreadback 0.285s
ok github.com/carlosframework/platform/internal/host 138.546s
ok github.com/carlosframework/platform/internal/hostactors 5.851s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 3.221s
ok github.com/carlosframework/platform/internal/hostclaim 0.933s
ok github.com/carlosframework/platform/internal/hostenv 0.006s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 5.649s
ok github.com/carlosframework/platform/internal/hostfence 0.055s
ok github.com/carlosframework/platform/internal/hostfiles 10.343s
ok github.com/carlosframework/platform/internal/hostfloor 0.019s
ok github.com/carlosframework/platform/internal/hostguard 1.048s
ok github.com/carlosframework/platform/internal/hostops 2.227s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.005s
ok github.com/carlosframework/platform/internal/hostquarantine 0.006s
ok github.com/carlosframework/platform/internal/hostrelocate 0.705s
ok github.com/carlosframework/platform/internal/hostresolve 0.237s
ok github.com/carlosframework/platform/internal/hostwrite 0.003s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 5.254s
ok github.com/carlosframework/platform/internal/instanceexec 2.825s
ok github.com/carlosframework/platform/internal/instancefs 0.007s
ok github.com/carlosframework/platform/internal/instancekey 0.005s
ok github.com/carlosframework/platform/internal/instanceupdates 0.010s
ok github.com/carlosframework/platform/internal/layout 1.875s
ok github.com/carlosframework/platform/internal/lbhealth 0.005s
ok github.com/carlosframework/platform/internal/lease 10.190s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.595s
ok github.com/carlosframework/platform/internal/ledger 0.029s
ok github.com/carlosframework/platform/internal/mail 0.250s
ok github.com/carlosframework/platform/internal/maintenance 0.089s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.561s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.119s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.389s
ok github.com/carlosframework/platform/internal/moves/archive 1.077s
ok github.com/carlosframework/platform/internal/objstore 0.312s
ok github.com/carlosframework/platform/internal/ops 0.185s
ok github.com/carlosframework/platform/internal/park 0.267s
ok github.com/carlosframework/platform/internal/plans 0.005s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 13.753s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.669s
ok github.com/carlosframework/platform/internal/relay 7.242s
ok github.com/carlosframework/platform/internal/release 24.643s
ok github.com/carlosframework/platform/internal/rollout 3.804s
ok github.com/carlosframework/platform/internal/rolloutwords 0.005s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.061s
ok github.com/carlosframework/platform/internal/rootstate 0.029s
ok github.com/carlosframework/platform/internal/runs 0.524s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.728s
ok github.com/carlosframework/platform/internal/seeds 0.012s
ok github.com/carlosframework/platform/internal/sim/hostlife 11.425s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.818s
ok github.com/carlosframework/platform/internal/status 0.072s
ok github.com/carlosframework/platform/internal/steering 0.014s
ok github.com/carlosframework/platform/internal/stopevidence 0.214s
ok github.com/carlosframework/platform/internal/svcrole 0.010s
ok github.com/carlosframework/platform/internal/telemetry 0.009s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.008s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.006s
ok github.com/carlosframework/platform/internal/usage 0.007s
ok github.com/carlosframework/platform/internal/version 0.007s
ok github.com/carlosframework/platform/internal/vet 1.688s
=== internal_console@0 (81 s)
ok github.com/carlosframework/platform/internal/console 79.095s
=== internal_console@1 (38 s)
ok github.com/carlosframework/platform/internal/console 35.949s
=== internal_console@2 (43 s)
ok github.com/carlosframework/platform/internal/console 40.946s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 35.144s
=== internal_console@4 (37 s)
ok github.com/carlosframework/platform/internal/console 35.252s
=== internal_console@5 (35 s)
ok github.com/carlosframework/platform/internal/console 33.325s
=== internal_console@6 (37 s)
ok github.com/carlosframework/platform/internal/console 34.784s
=== internal_console@7 (43 s)
ok github.com/carlosframework/platform/internal/console 41.494s
=== root@0 (33 s)
ok github.com/carlosframework/platform 30.735s
=== root@1 (45 s)
ok github.com/carlosframework/platform 42.592s
=== root@2 (41 s)
ok github.com/carlosframework/platform 39.354s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/21 22:58:24 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0AD946105A3B6, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
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 0888ba0f6..d22e9f36a 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 8, 2026-10-21. 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 9, 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). |
| 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. |
| @@ -21,7 +21,7 @@ A Tito customer opens Account settings, Data residency, in Tito's dashboard, see |
| **Goals.** |
| 1. One request from Tito's router starts a move that runs to completion with no operator step: stage while serving, stop, seal, flip, open, health check, finish, and the cleanup that follows. |
| -2. The holding page while offline, with the account's name and a minutes estimate, on every edge that answers for the host, on every domain the host has. |
| +2. The holding page while offline, with the account's name and a minutes estimate, on every edge that answers for the host, on its primary hostname and every explicitly attached custom domain (derived wildcard aliases keep today's answer, §6.4). |
| 3. Automatic, lossless return to the old region on any failure before `open`: the final copy, the archive, the config render and the landing's verification all happen there (§7.5). After `open` the account is never left silently offline: the state says what happened, operators are paged, and the operator doors run the return. The failures that stay after `open` are named (§7.5). |
| 4. An estimate before the customer confirms, and a better one once the data is staged. |
| 5. Tito's router keeps its calls (prepare, status, cut, abort) and state words. It needs one new credential and reads two new fields of the status view (§6.1). |
| @@ -115,13 +115,14 @@ The legacy move doors keep their paths and roles. With `CARLOS_SELF_SERVE_MOVES= |
| From `start` until the target's row is ready, the host is held on every source box and has no ready row anywhere. Today every edge answers a platform-domain host with the "not here yet" 503 retry page (`internal/edge/pending.go`) and a customer domain with 404. This design adds a maintenance answer, carried on the existing box to edges-file to edge channel: |
| -- **Publication.** Every box that holds the host (every source-region box; and the target box from `flip` until its row is ready) lists it in a new edges-file field, `offline_moves: [{host, aliases: [{alias, binding_rev}], move_id, incarnation, serving_seq, offline_at, estimate_seconds, display_name}]`, and every route in `routes` gains `incarnation`, `serving_seq` and, per alias, `binding_rev`; separate from `routes` so no decoder counts it as served (the seal door's "still listed" check reads `routes`; the builder audits every edges-file reader). `aliases` are exactly the alias rows the edges writer would publish for the host as a served route, under the same eligibility rules (DNS pointing, ownership, wildcard handling), so every custom domain that would reach the account gets the page and no other. A box stops publishing the entry when the record no longer carries that move id or when its own row for the host is ready. |
| +- **Publication.** Every box that holds the host (every source-region box; and the target box from `flip` until its row is ready) lists it in a new edges-file field, `offline_moves: [{host, aliases: [{alias, binding_rev}], move_id, incarnation, serving_seq, offline_at, estimate_seconds, display_name}]`, and every route in `routes` gains `incarnation`, `serving_seq` and, per alias, `binding_rev`; separate from `routes` so no decoder counts it as served (the seal door's "still listed" check reads `routes`; the builder audits every edges-file reader). `aliases` are exactly the explicit alias rows the edges writer would publish for the host as a served route, under the same eligibility rules (DNS pointing, ownership), so every explicitly attached custom domain that would reach the account gets the page; derived aliases are left out (below). A box stops publishing the entry when the record no longer carries that move id or when its own row for the host is ready. |
| - **Rendering.** An edge answers a request for a host or alias whose highest-sequence publication is a holding entry (the ordering rule below) with 503, `Retry-After: 30`, `noindex`, and the holding page: "`<display_name>` is temporarily offline. Please check back in approximately N minutes." with N from `until` and the estimate, counting down; without a display name, "This account is temporarily offline. ...". The highest-sequence rule below decides between a ready route and a holding entry; the window between `open` and the first edges-file refresh of the target's publication is the pending page's known 503-then-retry window and nothing worse. The source box's own edge answers its held rows the same way from its registry (the home map excludes a box's own publication). The page reuses the pending page's safe reload mechanics (location.replace, sessionStorage) with its give-up replaced by `until`. |
| - **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.** The console keeps a table `host_incarnations (host, incarnation)` that is never deleted with a record: a hostname's first record gets incarnation 1, and every later record for the same hostname (deleted and recreated) gets the previous value plus one, allocated in the create door's transaction. Existing hosts get incarnation 1 the first time the console needs it (their first offline move, or the first publication after this ships); the allocation is idempotent per record. The record carries `incarnation`; the console sets `move_seq` beside it at the first move. Boxes persist `(incarnation, serving_seq)` on the row in the same write as the status (reconciliation writes the record's pair when it mints a row, as above) and publish both with every route and holding entry. **Edges order a host's publications by the pair `(incarnation, serving_seq)`, highest first, and a publication without the pair is `(0, 0)`.** So: the first move's hold `(1, 1)` beats the source's lineage-less ready publication an edge retained through a read failure `(0, 0)`; the target's ready route at `open` `(1, 2)` beats the hold; a recreated host's first ready row `(2, 0)` beats every publication of the previous lifetime, holding entries included; a hold in the current lifetime `(2, s)` beats a previous lifetime's retained ready route `(1, x)`; two ready routes from different lifetimes resolve to the higher incarnation, and two equal pairs (the same transition seen from two publishers) fall to the existing home-map rule. Nothing is inferred from identities or freshness. `move_lineage` is dropped. |
| -- **Aliases: the binding revision.** An alias's publications order by the alias's binding, not by its target's counters, because a customer domain can be repointed from one instance to another whose pair is unrelated. The domains record for an alias (`domains/<alias>.json`, the one ownership record per hostname) gains `binding_rev`, a counter the console increments in the same write as every attach, repoint and detach of the alias, never reset (a detached alias keeps its last value; a re-attach increments it). Every route and holding entry publishes, for each alias, `{alias, target_host, binding_rev}` beside the target's `(incarnation, serving_seq)`. **An edge orders an alias's publications by `binding_rev` first, highest wins; within the newest binding it compares the target's pair as above.** A publication without a binding revision is 0. So a retained holding entry for `customer.example.com` at binding 7 (target A, pair `(1, 9)`) loses to B's ready publication at binding 8, however small B's pair; and an old alias-ready publication at binding 7 with a large pair loses to the new target's hold at binding 8. The primary hostname's own ordering is unchanged. Existing precedence for real routes and wildcard parents is kept; this rule only decides between publications that name the same alias. |
| +- **Aliases: explicit ones get the page, derived ones do not.** A holding entry lists only the host's **explicit** aliases: those with their own domains record (`domains/<alias>.json`) naming the host. Aliases **derived** from a wildcard catch-all (`*.customer.example` deriving `team.customer.example` from an instance label) are not listed: they have no binding record of their own, so no revision could order a stale holding entry against a later instance that derives the same name, and a wrong order would leave a healthy instance behind a holding page. During a move a derived alias answers as it does today for a held row (the pending page on a platform domain, the plain 404 elsewhere). The design accepts that, and says so in the customer-facing copy where Tito shows derived domains (Tito's own `go.tito.io` subdomains are the primary hostnames, not derived aliases). |
| +- **Explicit aliases: the binding revision.** An explicit alias's publications order by the alias's binding, not by its target's counters, because a customer domain can be repointed from one instance to another whose pair is unrelated. The console keeps `alias_bindings (alias, binding_rev)`, a table never deleted with the domains record: every attach, repoint and detach of an explicit alias increments the alias's `binding_rev` there in the same console transaction and, for attach and repoint, writes the new value into the domains record it creates or rewrites. Detach deletes the domains record exactly as today, so claim listings, catch-all exclusion and certificate eligibility keep their meaning; the table alone remembers the number, and a later re-attach gets a higher one. Every route and holding entry publishes, for each explicit alias, `{alias, target_host, binding_rev}` from the domains record beside the target's `(incarnation, serving_seq)`. **An edge orders an explicit alias's publications by `binding_rev` first, highest wins; within the newest binding it compares the target's pair as above.** A publication without a binding revision is 0. So a retained holding entry for `customer.example.com` at binding 7 (target A, pair `(1, 9)`) loses to B's ready publication at binding 8, however small B's pair; an old alias-ready publication at binding 7 with a large pair loses to the new target's hold at binding 8; a detached-then-re-attached alias is at a higher revision than every retained publication. The primary hostname's own ordering is unchanged. Existing precedence for real routes and wildcard parents is kept; this rule only decides between publications that name the same explicit alias. |
| - **Freshness.** The home map's existing behaviour stands: publications are refreshed on the edges' own cadence, unchanged publications are not rewritten, and a last-good publication is kept through a read failure. A holding entry therefore lives exactly as long as its publisher lists it, which is while the record carries the move and the publisher's row is not ready; there is no age rule and no fixed expiry. The displayed estimate is separate from validity: past `offline_at + estimate` the page says "...is taking longer than expected. Please check back in a few minutes." and keeps retrying. |
| - **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. |
| @@ -258,7 +259,7 @@ 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. |
| -- `incarnation` (allocated from `host_incarnations`, never reused) and `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 allocation for existing hosts; `binding_rev` on the domains record, written with every attach, repoint and detach, published per alias, and the edges' newest-binding-first rule. the probe mode (`X-Carlos-Probe`, `X-Carlos-Served-By`); the "taking longer" sentence's trigger. |
| +- `incarnation` (allocated from `host_incarnations`, never reused) and `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 allocation for existing hosts; `binding_rev` in the console's `alias_bindings` table, incremented with every attach, repoint and detach and written into the domains record on attach and repoint, published per explicit alias, and the edges' newest-binding-first rule; derived aliases excluded from holding entries. 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; an alias repointed from a held instance to another, the new target's ready publication winning over the retained holding entry, and the reverse (an old alias-ready publication losing to the new target's hold); an edge restarted into mixed alias 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. |