carlos / platform Public

plan: self-serve moves 3, holding entries, the holding page, probe mode and the alias rule (rev 1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7e0f610d04a9b40ffbf012cda39c9489c234fd1c parent c592a6b
1 file changed, +132 −0
  • docs/superpowers/plans/2026-10-22-self-serve-moves-3-holding-page.md +132 −0

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (2 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (5 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (148 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.785s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.561s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.705s
ok  	github.com/carlosframework/platform/internal/activator	59.544s
ok  	github.com/carlosframework/platform/internal/agentsock	0.021s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.852s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.143s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.525s
?   	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.008s
ok  	github.com/carlosframework/platform/internal/caps	0.014s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.713s
ok  	github.com/carlosframework/platform/internal/changelog	0.345s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.863s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.779s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.010s
ok  	github.com/carlosframework/platform/internal/dbverify	0.025s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.021s
ok  	github.com/carlosframework/platform/internal/docsite	0.025s
ok  	github.com/carlosframework/platform/internal/domains	2.809s
ok  	github.com/carlosframework/platform/internal/doorkey	0.025s
ok  	github.com/carlosframework/platform/internal/economics	0.023s
ok  	github.com/carlosframework/platform/internal/edge	45.246s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.137s
ok  	github.com/carlosframework/platform/internal/gate	1.811s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.018s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.151s
ok  	github.com/carlosframework/platform/internal/host	137.570s
ok  	github.com/carlosframework/platform/internal/hostactors	5.878s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.004s
ok  	github.com/carlosframework/platform/internal/hostbox	2.924s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.869s
ok  	github.com/carlosframework/platform/internal/hostenv	0.005s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	5.422s
ok  	github.com/carlosframework/platform/internal/hostfence	0.055s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.237s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.020s
ok  	github.com/carlosframework/platform/internal/hostguard	1.048s
ok  	github.com/carlosframework/platform/internal/hostops	2.062s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.006s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.713s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.175s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.006s
ok  	github.com/carlosframework/platform/internal/instancectl	5.233s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.786s
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.006s
ok  	github.com/carlosframework/platform/internal/layout	1.850s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.002s
ok  	github.com/carlosframework/platform/internal/lease	10.137s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.568s
ok  	github.com/carlosframework/platform/internal/ledger	0.013s
ok  	github.com/carlosframework/platform/internal/mail	0.261s
ok  	github.com/carlosframework/platform/internal/maintenance	0.103s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.615s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.105s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.412s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.023s
ok  	github.com/carlosframework/platform/internal/objstore	0.372s
ok  	github.com/carlosframework/platform/internal/ops	0.213s
ok  	github.com/carlosframework/platform/internal/park	0.333s
ok  	github.com/carlosframework/platform/internal/plans	0.006s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	13.896s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.012s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.604s
ok  	github.com/carlosframework/platform/internal/relay	7.247s
ok  	github.com/carlosframework/platform/internal/release	25.055s
ok  	github.com/carlosframework/platform/internal/rollout	3.838s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.008s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.065s
ok  	github.com/carlosframework/platform/internal/rootstate	0.027s
ok  	github.com/carlosframework/platform/internal/runs	0.593s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.006s
ok  	github.com/carlosframework/platform/internal/schedule	2.685s
ok  	github.com/carlosframework/platform/internal/seeds	0.014s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.037s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.586s
ok  	github.com/carlosframework/platform/internal/status	0.079s
ok  	github.com/carlosframework/platform/internal/steering	0.008s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.161s
ok  	github.com/carlosframework/platform/internal/svcrole	0.003s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.006s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.612s
=== internal_console@0 (80 s)
ok  	github.com/carlosframework/platform/internal/console	78.338s
=== internal_console@1 (37 s)
ok  	github.com/carlosframework/platform/internal/console	34.728s
=== internal_console@2 (42 s)
ok  	github.com/carlosframework/platform/internal/console	39.876s
=== internal_console@3 (37 s)
ok  	github.com/carlosframework/platform/internal/console	34.801s
=== internal_console@4 (37 s)
ok  	github.com/carlosframework/platform/internal/console	34.619s
=== internal_console@5 (35 s)
ok  	github.com/carlosframework/platform/internal/console	32.955s
=== internal_console@6 (36 s)
ok  	github.com/carlosframework/platform/internal/console	34.518s
=== internal_console@7 (43 s)
ok  	github.com/carlosframework/platform/internal/console	41.017s
=== root@0 (32 s)
ok  	github.com/carlosframework/platform	29.848s
=== root@1 (44 s)
ok  	github.com/carlosframework/platform	41.935s
=== root@2 (41 s)
ok  	github.com/carlosframework/platform	39.095s
=== 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.03s)
    --- 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.032s
=== RUN   TestT4StorageProbeMinio
2026/10/22 07:12:06 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0C88544A00EDE, 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.028s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-22-self-serve-moves-3-holding-page.md b/docs/superpowers/plans/2026-10-22-self-serve-moves-3-holding-page.md
new file mode 100644
index 000000000..b48067ca0
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-22-self-serve-moves-3-holding-page.md
@@ -0,0 +1,132 @@
+# Self-serve moves, plan 3: holding entries, the holding page, probe mode and the alias rule
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+Rev 1, 2026-10-22. Builds on plan 1 (lifetimes, `(incarnation, serving_seq)` on rows and publications, `target_host` on alias publications) and plan 2 (the self-serve move's record fields `SelfServe{DisplayName, OfflineAt, EstimateSeconds}`, the job and its health phase).
+
+**Goal:** From `start` until the target's row is ready and published, every edge answers the moving host and its explicit aliases with a 503 holding page ("Shop's account is temporarily offline. Please check back in approximately N minutes."), ordered against ready routes by the pair, never believed for more than five minutes after its last successful fetch; and the console's health probe can prove the target box itself answered.
+
+**Architecture:** Every box publishes its held offline-move rows as holding entries in a new edges-file field `offline_moves`, separate from `routes`/`hosts` so no decoder counts them as served. The edge's home map folds holding entries beside ready entries: for a primary hostname the higher `(incarnation, serving_seq)` wins; for an alias a ready route of a different `target_host` wins over a holding entry, the same target compares by pair. Each file's holding entries carry the time of the last successful fetch and expire five minutes after it. The edge renders the page locally for its own held rows too. A `X-Carlos-Probe: local` request is refused with 503 where it would backhaul and stamped `X-Carlos-Served-By: <box id>` where served locally.
+
+**Tech Stack:** Go. `internal/host/edgesfile.go` (the writer), `internal/edge` (home map, `ServeHTTP`, `pending.go`'s page mechanics, `certsync.go`), `internal/edgesfile` (the shared decoder), `internal/console` (the probe, plan 2's `probeTarget`), the agent's wiring in `internal/host`.
+
+**Spec:** `docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md` §6.4 (Publication, Rendering, TLS, The display name, Aliases, Freshness, Reopening), §7.4 (the probe mode), §11.
+
+## Global Constraints
+
+- `offline_moves` is never a reason to count a host as served: `edgesfile.File`, `edgesfile.PlacementView`, `DeploymentRoutes`, the console's `instancesplacement.go` join, `apidomains.go`, `apiinstancemove_offline.go` (`offlineServes`, the seal door's "still listed" check), `boxchoice.go`, `regioncandidates.go`, `internal/domains/aliases.go`, `internal/steering`, `internal/host/agent.go` and the home map all read `hosts`/`routes` as today; Task 4 pins every one.
+- A holding entry is believed only while `now - fetchedAt < 5 min`, where `fetchedAt` is the time this edge last fetched that box's file successfully; a carried-forward file (a failed fetch) keeps its old `fetchedAt`, so its entries expire on their own clock while its routes stay installed as today. An expired entry contributes nothing and the host gets today's answer.
+- Explicit aliases only: a holding entry lists the alias rows the writer would publish for the host as a served route under the same eligibility (`dns_status == "pointing"`, ownership); derived wildcard aliases are not listed and answer as today.
+- The display name is the only request-supplied text on the page, rendered through `html/template`'s contextual escaping; no header, path or diagnostic reaches it.
+- The page is a 503 with `Retry-After: 30`, `Cache-Control: no-store`, `X-Robots-Tag: noindex`, reusing `pending.go`'s reload mechanics (location.replace, sessionStorage) with the give-up replaced by the countdown's end.
+- No change to a claims-on box's publication or an edge's claim-stamp ordering: a claims-on writer publishes no `offline_moves`; a stamped entry never loses to a holding entry by pair (plan 1 Task 8's stamp rule applies first).
+- Copy rows pending Paul's review: "<display name> is temporarily offline. Please check back in approximately N minutes."; "This account is temporarily offline. Please check back in approximately N minutes."; "This is taking longer than expected. Please keep checking back."; "not served here". No tenant names in tests: "Shop's account", `shop.example.com`.
+- Census: changed units in `internal/host` take rows in `plan14-root-reads-self-serve-moves-3.tsv`, loaded last, `rev3=PROPOSED-self-serve-moves-3`; both census tests green.
+- Local tests: touched packages with `-run`; CI runs the full suite.
+
+## Review Focus
+
+1. A holding entry fetched 4 minutes 59 seconds ago versus 5 minutes 1 second ago, and a whole refresh that fails after the entry was fetched: the first is honoured, the second and third give today's answer (Task 5, `TestHoldingEntriesExpireFromTheFetchClock`).
+2. An explicit alias repointed from the moving instance to another during the move: the new target's ready route wins over the hold as a fresh and as a last-good publication; detached, the alias falls back to a derived wildcard entry naming another target and that target's route answers (Task 3, `TestAliasRepointedDuringAMove`).
+3. The stale ready alias publication of the instance being moved (same `target_host`) loses to its own hold; a legacy alias publication without `target_host` is a different target and wins (Task 3, `TestAliasOfTheMovingInstanceLosesToItsHold`).
+4. The box's own edge: a held row in its registry answers the page from the local source, and an alias of that host on the same box answers the page too (Task 2, `TestLocalEdgeServesTheHoldingPageForItsOwnHeldRows`).
+5. A probe request reaching an edge that would backhaul it: 503 "not served here" and no backhaul; a probe served locally: `X-Carlos-Served-By` names this box, any such header the app set is gone (Task 6, `TestProbeModeNeverPassesABackhauledAnswer`).
+
+---
+
+### Task 1: The writer publishes holding entries
+
+**Files:**
+- Modify: `internal/host/edgesfile.go` (`edgesFile` gains `OfflineMoves []holdingEntry \`json:"offline_moves,omitempty"\``; `edgesFileWriteClaims` gains a `holds holdingSource` parameter; the claims-off writer (`claims == nil`) fills the field; the claims-on writer never does), `internal/host/movesoffline.go` (the offline view exposes `func (v *offlineView) holding(host string) (provision.MoveState, bool)` from its snapshot), `internal/host/agent.go` (wire `holds` from the armed offline view)
+- Test: `internal/host/edgesfile_holding_test.go`
+
+**Interfaces:**
+- Produces: `type holdingEntry struct { Host string \`json:"host"\`; Aliases []string \`json:"aliases,omitempty"\`; MoveID string \`json:"move_id"\`; Incarnation uint64 \`json:"incarnation,omitempty"\`; ServingSeq uint64 \`json:"serving_seq,omitempty"\`; OfflineAt int64 \`json:"offline_at,omitempty"\`; EstimateSeconds int \`json:"estimate_seconds,omitempty"\`; DisplayName string \`json:"display_name,omitempty"\` }`; `type holdingSource func(host string) (provision.MoveState, bool)`.
+- The rule, per registry row `r`: `registry.IsHold(r.Status) && r.HoldOp != ""` and `holds(r.Host)` returns a move with `ID == r.HoldOp` → one entry `{Host: r.Host, MoveID, Incarnation: r.Incarnation, ServingSeq: r.ServingSeq, OfflineAt/EstimateSeconds/DisplayName from mv.SelfServe (zero when nil)}`; `Aliases` are the `route_aliases` rows with `Host == r.Host` that pass the alias loop's eligibility (`dns_status == "pointing"`, not taken by a served row, the owner rule at line 546); a row whose status is ready, or whose hold is not an offline move the view knows, publishes none. The target box's `landing` row (minted held by `mintHeld`) therefore publishes an entry from flip until its row is ready, as §6.4 asks. Entries are sorted by host.
+
+- [ ] **Step 1: Write the failing tests**: `TestWriterPublishesHoldingEntriesForHeldOfflineRows` (a source row held `(1, 1)` by an offline move with a display name → one entry with the fields; a ready row → none; a row held by a non-offline op → none); `TestHoldingEntryListsOnlyEligibleExplicitAliases` (an alias pointing → listed; `dns_status != pointing` → not; a derived wildcard alias → not); `TestClaimsOnWriterPublishesNoHoldingEntries`; `TestTargetPublishesWhileLanding` (a `landing` row → an entry; after `ReleaseHoldSeq` → none).
+- [ ] **Step 2: Run to verify they fail**: `go test ./internal/host -run 'Holding|ClaimsOnWriterPublishesNo' -count=1`.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run to verify they pass**, plus `go test ./internal/host -run 'EdgesFile' -count=1`.
+- [ ] **Step 5: Commit** `edges: the claims-off writer publishes held offline-move rows as holding entries`.
+
+### Task 2: The edge reads holding entries, folds them and renders the page
+
+**Files:**
+- Modify: `internal/edge/homemap.go` (`edgesFileView` gains `OfflineMoves []holdingView \`json:"offline_moves"\`` and `fetchedAt time.Time` (not from JSON: set by `refreshHosts` on a successful fetch; carried forward with the old file on a failed one); `homeEntry` gains `holding *holdingView`; `foldHomes` folds holding entries beside route entries; `homeSnapshot` keeps `holds map[string]holdingView` for lookups), `internal/edge/holding.go` (new: the page, `holdingPage`, `renderHoldingPage(entry, now)`), `internal/edge/edge.go` (`ServeHTTP`: when a host has no local upstream, before `backhaul`, `if h, ok := e.homes.Load().Holding(host, now); ok { e.holdingAnswer(w, r, h); return }`; for a local held row: `Reload` records `e.localHolds` from the registry rows in `HoldStatuses` whose `HoldOp` the agent's `HoldingSource` knows (the `Edge` gains `WithHoldingSource(func(host string) (provision.MoveState, bool))`), and `ServeHTTP` answers them before `unknownHost`; aliases of a local held row answer the same page), `internal/edge/certsync.go` and `edge.go` (`deploymentHost` also true for a host or alias in a live holding entry, local or remote, so the certificate is issued/kept and the page is reachable on HTTPS; `Allowed` true for local held rows and their aliases)
+- Test: `internal/edge/holding_test.go`, `internal/edge/homemap_test.go`
+
+**Interfaces:**
+- Produces: `type holdingView struct { Host string; Aliases []string; MoveID string; Incarnation, ServingSeq uint64; OfflineAt int64; EstimateSeconds int; DisplayName string; fetchedAt time.Time; box string }`; `func (m *homeMap) Holding(host string, now time.Time) (holdingView, bool)` (the winning entry for `host` or for an alias in an entry's `Aliases`, when the fold chose a holding entry for it and `now.Sub(fetchedAt) < holdingValidity` (5 min)); `func (e *Edge) holdingAnswer(w, r, h holdingView)` (503, the headers above, the page with `until = OfflineAt + EstimateSeconds`); `func renderHoldingPage(displayName string, untilUnix int64, now time.Time) []byte` (the sentences: with a name "<name> is temporarily offline. Please check back in approximately N minutes." where N = ceil((until - now) / 60) with a minimum of 1 while `until > now`; past `until`, "This is taking longer than expected. Please keep checking back."; without a name "This account is temporarily offline. ..."; the countdown runs client-side from `until`).
+- The fold for a primary hostname: a holding entry is a candidate with `dial: false, holding: &h` and the entry's pair; a ready route candidate has its pair; `dialBeats` picks the higher pair when both are unstamped (plan 1's rule; a holding entry is never stamped); a stamped route always beats a holding entry; equal pairs → the ready route. The winner being a holding entry means `Home` answers not found and `Holding` answers the entry. For an alias, Task 3.
+- The local edge: the box's own publication is excluded from the home map (today), so its own held rows come from `localHolds`; a local held row for `host` with a remote ready route for `host` at a higher pair (the target opened) → the remote route wins (the fold already prefers it; `localHolds` is consulted only when the fold has no dial winner for the host).
+
+- [ ] **Step 1: Write the failing tests**: `TestHoldingEntryParsesAndFolds` (a fixture file with one holding entry: `Holding` answers it, `Home` does not); `TestHoldBeatsStaleReadyRouteAndLosesToOpen` (`(1, 1)` hold vs retained `(1, 0)` route → hold; `(1, 2)` route vs `(1, 1)` hold → route; a stamped `(0, 0)` route vs a `(1, 1)` hold → route); `TestHoldingPageRendersTheCountdown` (name, 7 minutes → "approximately 7 minutes"; no name → the generic sentence; past until → the taking-longer sentence; a name with `<script>` is escaped); `TestHoldingAnswerHeaders` (503, Retry-After 30, no-store, noindex); `TestLocalEdgeServesTheHoldingPageForItsOwnHeldRows` (a held row in the edge's own registry with a `HoldingSource` → the page for the host and for its alias; a ready row → unchanged); `TestHoldingHostsAreAllowedForTLS` (`deploymentHost` true for a holding entry's host and alias; false once expired).
+- [ ] **Step 2: Run to verify they fail**: `go test ./internal/edge -run 'Holding|HoldBeats' -count=1`.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run to verify they pass**, plus `go test ./internal/edge -run 'HomeMap|Fold|Pending' -count=1`.
+- [ ] **Step 5: Commit** `edge: holding entries fold beside routes and render the holding page; the local edge serves it for its own held rows`.
+
+### Task 3: The alias rule
+
+**Files:**
+- Modify: `internal/edge/homemap.go` (`foldHomes`' alias candidates: a ready alias entry carries `targetHost` (plan 1); a holding entry's `Aliases` are alias candidates whose target is the entry's `Host`; the rule in `dialBeats` for alias-against-holding: a ready alias entry whose `targetHost` differs from the holding entry's host, or is empty (legacy), wins; the same target compares by pair; two holding entries for one alias → the higher pair; a holding alias candidate never beats a real (non-alias) route entry for the same name)
+- Test: `internal/edge/homemap_test.go`
+
+- [ ] **Step 1: Write the failing tests**: `TestAliasOfTheMovingInstanceLosesToItsHold` (alias → A, A's hold `(1, 1)`, A's retained ready alias publication `(1, 0)` with `target_host: A` → hold; the same with a legacy alias publication without `target_host` → the route wins); `TestAliasRepointedDuringAMove` (alias repointed to B: B's ready alias publication `target_host: B` at `(1, 0)` vs A's hold `(1, 1)` → B, fresh and last-good; detached from B, a derived wildcard entry whose primary is C → C's route); `TestTwoHoldsForOneAlias` (the higher pair); `TestHoldingAliasNeverBeatsARealRoute`.
+- [ ] **Step 2: Run to verify they fail.**
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run** `go test ./internal/edge -run 'Alias|Fold|HomeMap' -count=1`.
+- [ ] **Step 5: Commit** `edge: an alias follows a different target's ready route over a hold and its own target's pair otherwise`.
+
+### Task 4: The decoder audit
+
+**Files:**
+- Test: `internal/edgesfile/offline_moves_test.go`, `internal/console/edges_offline_moves_test.go`, `internal/domains/aliases_offline_moves_test.go`, `internal/steering/offline_moves_test.go`, `internal/host/edgesfile_decoders_test.go`
+- Create: `internal/edgesfile/testdata/offline-moves.json` (a file with one served route, one holding entry for another host, one alias in it)
+
+- [ ] **Step 1: Write the tests**: for each decoder in the Global Constraints list, feed the fixture and assert the holding entry's host and alias are **not** in the served/hosts set and the served route is: `edgesfile.Parse` (`File.Hosts`), `ReadSnapshot`/`PlacementView.Hosts`, `DeploymentRoutes`, the console's `loadEdgesJoin` and `offlineServes` (the seal door's check reads `hosts`), `boxchoice`, `regioncandidates`, `domains.ReconcileAliases`'s read, `steering`, and the agent's own reader. One test names every file in the list so a new decoder must be added here (`TestEveryEdgesFileDecoderIsAudited` greps for the decoder types and compares against the list).
+- [ ] **Step 2: Run them**; they pass against the current decoders (the field is unknown to them) and pin the contract.
+- [ ] **Step 3: Commit** `edges: every edges-file decoder ignores offline_moves for served-ness`.
+
+### Task 5: Freshness
+
+**Files:**
+- Modify: `internal/edge/homemap.go` (`refreshHosts`: a successful parse sets `f.fetchedAt = now`; a carried-forward file keeps the old `fetchedAt`; `foldHomes` takes `now` and skips holding entries with `now.Sub(fetchedAt) >= holdingValidity`; `Holding(host, now)` re-checks on every lookup so an entry expires between refreshes without a refresh)
+- Test: `internal/edge/homemap_test.go`
+
+- [ ] **Step 1: Write the failing tests**: `TestHoldingEntriesExpireFromTheFetchClock` (fetched 4 m 59 s ago → honoured; 5 m 1 s → today's answer for the host; a whole refresh that fails leaves routes installed and the holding entries expiring on their own clock; a refresh that succeeds for other boxes but fails for this one → likewise); `TestExpiredHoldDoesNotResurrectAStaleRoute` (an expired hold and a retained `(1, 0)` route of the same host: the route answers, as today).
+- [ ] **Step 2: Run to verify they fail.**
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run** `go test ./internal/edge -run 'Expire|Holding' -count=1`.
+- [ ] **Step 5: Commit** `edge: holding entries are believed for five minutes from their last successful fetch`.
+
+### Task 6: Probe mode and the console's probe
+
+**Files:**
+- Modify: `internal/edge/edge.go` (`ServeHTTP`: `probe := r.Header.Get("X-Carlos-Probe") == "local"`; when `probe` and the request would backhaul (`ExactHome` remote on a served-exactly miss, or `!ok` reaching `backhaul`) → 503 "not served here" and no dial; when `probe` and served locally → wrap `w` in `servedByWriter{box: e.boxID}` whose `WriteHeader` deletes any `X-Carlos-Served-By` and sets this box's id; the `Edge` gains `WithBoxID(id string)`; the agent passes `edgesfile.PlacementView.BoxID`'s source), `internal/console/selfserve_health.go` (plan 2's `probeTarget` sends `X-Carlos-Probe: local` and requires `X-Carlos-Served-By == row.TargetBox` and `x-carlos-version == the sealed build`; the function comment's limitation is removed)
+- Test: `internal/edge/probe_test.go`, `internal/console/selfserve_health_test.go`
+
+- [ ] **Step 1: Write the failing tests**: `TestProbeModeNeverPassesABackhauledAnswer` (a probe for a host homed elsewhere → 503 "not served here", the backhaul transport is never dialled; a probe for a local host → 200 with `X-Carlos-Served-By: <box>`; the app sets the header to another box → replaced; without the probe header the header is untouched and backhaul works as today); `TestConsoleProbeRequiresServedByAndVersion` (a 200 without the header → fail; with another box → fail; the right box and version → pass; a 3xx → fail; the edge's 503 → fail with the sentence).
+- [ ] **Step 2: Run to verify they fail**: `go test ./internal/edge -run 'Probe' -count=1`, `go test ./internal/console -run 'ConsoleProbe' -count=1`.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run to verify they pass.**
+- [ ] **Step 5: Commit** `edge: probe mode refuses to backhaul and stamps the serving box; the console's health probe requires it`.
+
+### Task 7: End to end
+
+**Files:**
+- Test: `internal/host/selfserve_holding_e2e_test.go` (the two-box harness of plan 2's `TestSelfServeForwardMove` with a third edge process reading the edges files, and a custom domain attached as an explicit alias)
+
+- [ ] **Step 1: Write the tests** (the §11 witnesses this plan owns): `TestHoldingPageOnAThirdEdgeAndACustomDomain` (from start to open the third edge answers 503 with the page for the host and the alias; after open and the next refresh it backhauls to the target; the source box's own edge answers the page for its held row); `TestReadyRouteWinsOverStaleHoldAndHoldOverStaleRoute`; `TestFirstMoveWithPairlessRetainedPublication` (an edge retaining the source's pre-plan-1 publication `(0, 0)` through the hold `(1, 1)` and the open `(1, 2)`: the hold then the target win); `TestRecreatedHostOutranksOldHoldingAndReadyPublications` (synthetic retained `(1, 9)` hold and `(1, 5)` route; a new lifetime's `(2, 0)` row wins); `TestCurrentHoldBeatsPreviousLifetimesReadyRoute`; `TestEdgeRestartedIntoMixedPublications`; `TestExpiredHoldingEntryFallsBackIncludingAfterAWholeRefreshFails`; `TestAliasWitnesses` (repointed fresh and last-good; detached to a derived alias of another target; the moving instance's stale ready alias loses to its hold).
+- [ ] **Step 2: Run them; fix what they find within this plan's scope.**
+- [ ] **Step 3: Run both census tests and the cross-builds**; write `docs/superpowers/notes/census-proposal-self-serve-moves-3.md`.
+- [ ] **Step 4: Commit** `self-serve moves: the holding page end to end on a third edge and a custom domain`; push.
+
+---
+
+## Self-review notes
+
+- Spec coverage: §6.4 Publication (T1), Rendering (T2), TLS (T2), The display name (T2), Aliases (T3), the decoder audit (T4), Freshness (T5), Reopening (T2's fold: the target's `(1, 2)` route wins once fetched; the window is the pending page's as today), §7.4 probe mode (T6), §11's holding witnesses (T7).
+- Names used across tasks: `holdingEntry` (writer), `holdingSource`, `holdingView` (edge), `homeMap.Holding`, `holdingValidity`, `renderHoldingPage`, `holdingAnswer`, `WithHoldingSource`, `WithBoxID`, `servedByWriter`, `probeTarget` (plan 2).
+- Review Focus 1 to 5 are pinned by T5, T3, T3, T2, T6.