carlos / platform Public

design: self-serve offline moves, rev 11 (Astra round 10)

Incarnation allocation is serialised per hostname through a pending row that
only a completed record write commits, resolved on restart from the bucket;
alias publications carry target_host, any eligible ready route for a
different target wins over a holding entry and pairs compare only within one
target; the five-minute bound is a bound on reader disconnection, enforced
from the fetch time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9ae3902fae67b6e90fed16b0e23a8a398a238344 parent 82023a8
1 file changed, +6 −6
  • docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md +6 −6

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	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 (139 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.588s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.926s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.747s
ok  	github.com/carlosframework/platform/internal/activator	58.635s
ok  	github.com/carlosframework/platform/internal/agentsock	0.017s
ok  	github.com/carlosframework/platform/internal/alerting	0.005s
ok  	github.com/carlosframework/platform/internal/analytics	0.026s
ok  	github.com/carlosframework/platform/internal/appconfig	3.065s
ok  	github.com/carlosframework/platform/internal/apperr	0.175s
ok  	github.com/carlosframework/platform/internal/applog	0.372s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.868s
?   	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.015s
ok  	github.com/carlosframework/platform/internal/caps	0.013s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.788s
ok  	github.com/carlosframework/platform/internal/changelog	0.543s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.255s
ok  	github.com/carlosframework/platform/internal/clamd	0.057s
?   	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.187s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.017s
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.008s
ok  	github.com/carlosframework/platform/internal/domains	3.047s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.011s
ok  	github.com/carlosframework/platform/internal/edge	45.552s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.020s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.284s
ok  	github.com/carlosframework/platform/internal/gate	2.049s
ok  	github.com/carlosframework/platform/internal/genselect	0.018s
ok  	github.com/carlosframework/platform/internal/geo	0.012s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.269s
ok  	github.com/carlosframework/platform/internal/host	136.741s
ok  	github.com/carlosframework/platform/internal/hostactors	5.695s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.007s
ok  	github.com/carlosframework/platform/internal/hostbox	3.256s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.868s
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.486s
ok  	github.com/carlosframework/platform/internal/hostfence	0.051s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.196s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.030s
ok  	github.com/carlosframework/platform/internal/hostguard	1.079s
ok  	github.com/carlosframework/platform/internal/hostops	2.275s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.004s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.580s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.214s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.005s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	5.249s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.822s
ok  	github.com/carlosframework/platform/internal/instancefs	0.007s
ok  	github.com/carlosframework/platform/internal/instancekey	0.004s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.009s
ok  	github.com/carlosframework/platform/internal/layout	2.032s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.004s
ok  	github.com/carlosframework/platform/internal/lease	10.180s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.595s
ok  	github.com/carlosframework/platform/internal/ledger	0.020s
ok  	github.com/carlosframework/platform/internal/mail	0.280s
ok  	github.com/carlosframework/platform/internal/maintenance	0.117s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.586s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.085s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	21.344s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.226s
ok  	github.com/carlosframework/platform/internal/objstore	0.250s
ok  	github.com/carlosframework/platform/internal/ops	0.235s
ok  	github.com/carlosframework/platform/internal/park	0.271s
ok  	github.com/carlosframework/platform/internal/plans	0.006s
ok  	github.com/carlosframework/platform/internal/privatefile	0.005s
ok  	github.com/carlosframework/platform/internal/provision	13.387s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.018s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.606s
ok  	github.com/carlosframework/platform/internal/relay	7.231s
ok  	github.com/carlosframework/platform/internal/release	24.699s
ok  	github.com/carlosframework/platform/internal/rollout	3.827s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.060s
ok  	github.com/carlosframework/platform/internal/rootstate	0.028s
ok  	github.com/carlosframework/platform/internal/runs	0.536s
ok  	github.com/carlosframework/platform/internal/s3compat	0.008s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.710s
ok  	github.com/carlosframework/platform/internal/seeds	0.017s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.760s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.140s
ok  	github.com/carlosframework/platform/internal/status	0.073s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.167s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.006s
ok  	github.com/carlosframework/platform/internal/termfence	0.005s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.006s
ok  	github.com/carlosframework/platform/internal/unixdial	0.008s
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.004s
ok  	github.com/carlosframework/platform/internal/vet	1.701s
=== internal_console@0 (77 s)
ok  	github.com/carlosframework/platform/internal/console	74.839s
=== internal_console@1 (37 s)
ok  	github.com/carlosframework/platform/internal/console	35.162s
=== internal_console@2 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.200s
=== internal_console@3 (37 s)
ok  	github.com/carlosframework/platform/internal/console	35.224s
=== internal_console@4 (37 s)
ok  	github.com/carlosframework/platform/internal/console	35.026s
=== internal_console@5 (35 s)
ok  	github.com/carlosframework/platform/internal/console	33.139s
=== internal_console@6 (36 s)
ok  	github.com/carlosframework/platform/internal/console	33.893s
=== internal_console@7 (43 s)
ok  	github.com/carlosframework/platform/internal/console	41.203s
=== root@0 (32 s)
ok  	github.com/carlosframework/platform	30.399s
=== root@1 (44 s)
ok  	github.com/carlosframework/platform	42.314s
=== root@2 (41 s)
ok  	github.com/carlosframework/platform	39.566s
=== 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.023s
=== RUN   TestT4StorageProbeMinio
2026/10/21 23:09:18 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0AE2CA739DCAC, 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.021s
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 0550f0d4b..377fd6c3b 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 10, 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 11, 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).
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.
@@ -115,13 +115,13 @@ 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, move_id, incarnation, serving_seq, offline_at, estimate_seconds, display_name}]`, and every route in `routes` gains `incarnation` and `serving_seq`; 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.
+- **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, move_id, incarnation, serving_seq, offline_at, estimate_seconds, display_name}]`, every route in `routes` gains `incarnation` and `serving_seq`, and every alias route gains `target_host`; 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. The number is allocated **durably first**: the create door commits the increment in SQLite, then writes the record carrying it; a crash between the two burns a number (a gap is fine) and never reuses one; a record write that may or may not have landed is reread before any retry. 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.** 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). No alias revision is kept: an alias's binding is a console decision the publishers reflect within one refresh (the reconciler removes the alias row from the old target and adds it to the new one, and each box's next publication follows), and holding entries are short-lived (above). Among fresh publications naming the same alias with different targets, a ready route wins over a holding entry: that state lasts one refresh cycle after a repoint during a move, and the move's hold is on the instance, not on the alias. Explicit-to-derived and derived-to-explicit transitions therefore need no ordering: the old explicit holding entry stops being honoured within five minutes of its publisher dropping it or going unread, and the derived route is what remains.
+- **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. Allocation is serialised per hostname and committed only by a completed record write: the create door takes the console's per-hostname mutex (one console process), commits the allocation in SQLite as `pending` with the create's intent, writes the record carrying it, then commits `committed`; a second create or a delete of the same hostname waits on the mutex, and a `pending` allocation found on restart is resolved by reading the bucket (the record present with that number: `committed`; absent: `abandoned`, the number burned, and the next create gets a higher one; an uncertain read leaves it `pending` and the hostname's creates refused until it resolves). A create therefore cannot materialise an older number after a newer one has been published: the newer create could not have started until the older one committed or abandoned. Existing hosts' lazy migration binds the allocation to the particular record (its key and ETag at migration); a record replaced since is a new lifetime and allocates again. 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.** 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.
-- `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; the five-minute validity of holding entries at the edge (never last-good), with the fallback answer; derived aliases excluded from holding entries. 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; 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 and its restart resolution. 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; an alias repointed from a held instance to another during a move, the new target's ready route winning within one refresh; a detached explicit alias falling back to a derived one with the old holding entry gone within five minutes; 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; two creates of one hostname interleaved with a delete, the later number never losing to the earlier; 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