design: self-serve offline moves, rev 12 (Astra round 11)
The host incarnation becomes an authoritative bucket slot that is created once, never deleted, advanced by conditional writes on create and by recovery, and retired on delete; every consumer checks a record's incarnation against it, so a create whose record write lands after a crash and a recovery materialises nothing.
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 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 (139 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.879s
ok github.com/carlosframework/platform/cmd/moveprobe 11.854s
ok github.com/carlosframework/platform/cmd/zddprobe 0.725s
ok github.com/carlosframework/platform/internal/activator 58.454s
ok github.com/carlosframework/platform/internal/agentsock 0.026s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.022s
ok github.com/carlosframework/platform/internal/appconfig 2.975s
ok github.com/carlosframework/platform/internal/apperr 0.159s
ok github.com/carlosframework/platform/internal/applog 0.315s
ok github.com/carlosframework/platform/internal/awsinv 0.012s
ok github.com/carlosframework/platform/internal/boxchoice 0.009s
ok github.com/carlosframework/platform/internal/boxid 0.634s
? 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.017s
ok github.com/carlosframework/platform/internal/caps 0.015s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.751s
ok github.com/carlosframework/platform/internal/changelog 0.501s
ok github.com/carlosframework/platform/internal/claimactivation 1.240s
ok github.com/carlosframework/platform/internal/clamd 0.058s
? 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.258s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.013s
ok github.com/carlosframework/platform/internal/domains 2.759s
ok github.com/carlosframework/platform/internal/doorkey 0.010s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 45.295s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.013s
ok github.com/carlosframework/platform/internal/fleetapi 0.168s
ok github.com/carlosframework/platform/internal/gate 1.802s
ok github.com/carlosframework/platform/internal/genselect 0.033s
ok github.com/carlosframework/platform/internal/geo 0.030s
ok github.com/carlosframework/platform/internal/geoip 0.058s
ok github.com/carlosframework/platform/internal/historyreadback 0.198s
ok github.com/carlosframework/platform/internal/host 136.208s
ok github.com/carlosframework/platform/internal/hostactors 5.996s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.006s
ok github.com/carlosframework/platform/internal/hostbox 3.298s
ok github.com/carlosframework/platform/internal/hostclaim 0.778s
ok github.com/carlosframework/platform/internal/hostenv 0.009s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 5.602s
ok github.com/carlosframework/platform/internal/hostfence 0.050s
ok github.com/carlosframework/platform/internal/hostfiles 10.215s
ok github.com/carlosframework/platform/internal/hostfloor 0.019s
ok github.com/carlosframework/platform/internal/hostguard 1.050s
ok github.com/carlosframework/platform/internal/hostops 2.217s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.009s
ok github.com/carlosframework/platform/internal/hostquarantine 0.005s
ok github.com/carlosframework/platform/internal/hostrelocate 0.633s
ok github.com/carlosframework/platform/internal/hostresolve 0.239s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.009s
ok github.com/carlosframework/platform/internal/instancectl 5.243s
ok github.com/carlosframework/platform/internal/instanceexec 2.784s
ok github.com/carlosframework/platform/internal/instancefs 0.007s
ok github.com/carlosframework/platform/internal/instancekey 0.006s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.880s
ok github.com/carlosframework/platform/internal/lbhealth 0.006s
ok github.com/carlosframework/platform/internal/lease 10.216s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.562s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.205s
ok github.com/carlosframework/platform/internal/maintenance 0.095s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.596s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.104s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.359s
ok github.com/carlosframework/platform/internal/moves/archive 1.118s
ok github.com/carlosframework/platform/internal/objstore 0.282s
ok github.com/carlosframework/platform/internal/ops 0.221s
ok github.com/carlosframework/platform/internal/park 0.317s
ok github.com/carlosframework/platform/internal/plans 0.009s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 13.404s
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.658s
ok github.com/carlosframework/platform/internal/relay 7.241s
ok github.com/carlosframework/platform/internal/release 24.837s
ok github.com/carlosframework/platform/internal/rollout 3.796s
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.062s
ok github.com/carlosframework/platform/internal/rootstate 0.029s
ok github.com/carlosframework/platform/internal/runs 0.564s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.711s
ok github.com/carlosframework/platform/internal/seeds 0.014s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.773s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.097s
ok github.com/carlosframework/platform/internal/status 0.074s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.224s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.011s
ok github.com/carlosframework/platform/internal/tunnelclient 0.009s
ok github.com/carlosframework/platform/internal/unixdial 0.006s
ok github.com/carlosframework/platform/internal/updatewords 0.009s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.698s
=== internal_console@0 (85 s)
ok github.com/carlosframework/platform/internal/console 81.987s
=== internal_console@1 (38 s)
ok github.com/carlosframework/platform/internal/console 35.251s
=== internal_console@2 (43 s)
ok github.com/carlosframework/platform/internal/console 40.180s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 34.710s
=== internal_console@4 (38 s)
ok github.com/carlosframework/platform/internal/console 35.097s
=== internal_console@5 (35 s)
ok github.com/carlosframework/platform/internal/console 32.744s
=== internal_console@6 (36 s)
ok github.com/carlosframework/platform/internal/console 34.128s
=== internal_console@7 (43 s)
ok github.com/carlosframework/platform/internal/console 40.891s
=== root@0 (32 s)
ok github.com/carlosframework/platform 30.334s
=== root@1 (44 s)
ok github.com/carlosframework/platform 42.438s
=== root@2 (41 s)
ok github.com/carlosframework/platform 39.631s
=== 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:14:28 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0AE74E4386675, 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 377fd6c3b..947dab323 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 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). |
| +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). |
| 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.** 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. |
| +- **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. |
| - **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, 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 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 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; 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. |
| +- 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. |
| ## 12. Risks |