plan: self-serve moves 3, rev 2 (Astra plan 3 review 1: 15 findings)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+72
−65
docs/superpowers/plans/2026-10-22-self-serve-moves-3-holding-page.md+72 −65
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok amadan.net/carlos/platform.git/launchctx 0.015s
? 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 (137 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.624s
ok github.com/carlosframework/platform/cmd/moveprobe 11.920s
ok github.com/carlosframework/platform/cmd/zddprobe 0.768s
ok github.com/carlosframework/platform/internal/activator 58.383s
ok github.com/carlosframework/platform/internal/agentsock 0.019s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.024s
ok github.com/carlosframework/platform/internal/appconfig 3.040s
ok github.com/carlosframework/platform/internal/apperr 0.110s
ok github.com/carlosframework/platform/internal/applog 0.258s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 1.052s
? 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.011s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.784s
ok github.com/carlosframework/platform/internal/changelog 0.503s
ok github.com/carlosframework/platform/internal/claimactivation 1.212s
ok github.com/carlosframework/platform/internal/clamd 0.062s
? 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.513s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.011s
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.022s
ok github.com/carlosframework/platform/internal/domains 2.868s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 45.166s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.017s
ok github.com/carlosframework/platform/internal/fleetapi 0.177s
ok github.com/carlosframework/platform/internal/gate 1.858s
ok github.com/carlosframework/platform/internal/genselect 0.023s
ok github.com/carlosframework/platform/internal/geo 0.023s
ok github.com/carlosframework/platform/internal/geoip 0.061s
ok github.com/carlosframework/platform/internal/historyreadback 0.274s
ok github.com/carlosframework/platform/internal/host 134.778s
ok github.com/carlosframework/platform/internal/hostactors 6.463s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.012s
ok github.com/carlosframework/platform/internal/hostbox 3.070s
ok github.com/carlosframework/platform/internal/hostclaim 0.835s
ok github.com/carlosframework/platform/internal/hostenv 0.013s
ok github.com/carlosframework/platform/internal/hostevidence 0.013s
ok github.com/carlosframework/platform/internal/hostexec 5.462s
ok github.com/carlosframework/platform/internal/hostfence 0.061s
ok github.com/carlosframework/platform/internal/hostfiles 10.245s
ok github.com/carlosframework/platform/internal/hostfloor 0.019s
ok github.com/carlosframework/platform/internal/hostguard 1.062s
ok github.com/carlosframework/platform/internal/hostops 2.080s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.011s
ok github.com/carlosframework/platform/internal/hostquarantine 0.012s
ok github.com/carlosframework/platform/internal/hostrelocate 0.671s
ok github.com/carlosframework/platform/internal/hostresolve 0.209s
ok github.com/carlosframework/platform/internal/hostwrite 0.012s
ok github.com/carlosframework/platform/internal/infraview 0.010s
ok github.com/carlosframework/platform/internal/instancectl 5.226s
ok github.com/carlosframework/platform/internal/instanceexec 2.785s
ok github.com/carlosframework/platform/internal/instancefs 0.010s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/instanceupdates 0.011s
ok github.com/carlosframework/platform/internal/layout 1.905s
ok github.com/carlosframework/platform/internal/lbhealth 0.006s
ok github.com/carlosframework/platform/internal/lease 10.181s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.566s
ok github.com/carlosframework/platform/internal/ledger 0.015s
ok github.com/carlosframework/platform/internal/mail 0.242s
ok github.com/carlosframework/platform/internal/maintenance 0.107s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.583s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.103s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.385s
ok github.com/carlosframework/platform/internal/moves/archive 1.005s
ok github.com/carlosframework/platform/internal/objstore 0.245s
ok github.com/carlosframework/platform/internal/ops 0.188s
ok github.com/carlosframework/platform/internal/park 0.257s
ok github.com/carlosframework/platform/internal/plans 0.008s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.255s
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.582s
ok github.com/carlosframework/platform/internal/relay 7.193s
ok github.com/carlosframework/platform/internal/release 24.826s
ok github.com/carlosframework/platform/internal/rollout 3.800s
ok github.com/carlosframework/platform/internal/rolloutwords 0.006s
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.026s
ok github.com/carlosframework/platform/internal/runs 0.543s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.007s
ok github.com/carlosframework/platform/internal/schedule 2.741s
ok github.com/carlosframework/platform/internal/seeds 0.013s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.696s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.682s
ok github.com/carlosframework/platform/internal/status 0.067s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.173s
ok github.com/carlosframework/platform/internal/svcrole 0.004s
ok github.com/carlosframework/platform/internal/telemetry 0.007s
ok github.com/carlosframework/platform/internal/termfence 0.011s
ok github.com/carlosframework/platform/internal/tunnelclient 0.010s
ok github.com/carlosframework/platform/internal/unixdial 0.007s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.004s
ok github.com/carlosframework/platform/internal/vet 1.635s
=== internal_console@0 (82 s)
ok github.com/carlosframework/platform/internal/console 80.061s
=== internal_console@1 (36 s)
ok github.com/carlosframework/platform/internal/console 34.689s
=== internal_console@2 (42 s)
ok github.com/carlosframework/platform/internal/console 40.132s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 35.119s
=== internal_console@4 (36 s)
ok github.com/carlosframework/platform/internal/console 34.637s
=== internal_console@5 (34 s)
ok github.com/carlosframework/platform/internal/console 32.557s
=== internal_console@6 (35 s)
ok github.com/carlosframework/platform/internal/console 33.966s
=== internal_console@7 (43 s)
ok github.com/carlosframework/platform/internal/console 40.990s
=== root@0 (31 s)
ok github.com/carlosframework/platform 29.979s
=== root@1 (43 s)
ok github.com/carlosframework/platform 42.040s
=== root@2 (40 s)
ok github.com/carlosframework/platform 38.702s
=== 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.021s
=== RUN TestT4StorageProbeMinio
2026/10/22 08:23:41 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0CC6D61EB4D9A, 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.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
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| index b48067ca0..da4e0f0a5 100644 |
| --- 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 |
| @@ -2,131 +2,138 @@ |
| > **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). |
| +Rev 2, 2026-10-22. Rev 1 was reviewed by Astra (`after236/selfserve-plan3-review.md`, NOT READY, 15 findings). Rev 2 answers them: holding publication is gated on plan 1's `lifetimes` mode, not the region-HA argument; the edge's reader is fully tagged and its candidate constructor carries the file's fetch time and publisher; the fold keeps **every candidate per name** and a single arbitration decides ready, holding and certificate-only entries at lookup time, so the alias rule, expiry and the local-versus-remote question are one computation without store I/O; explicit aliases carry provenance in the registry written by the alias reconciler; TLS coverage keeps wildcard parents and holding hosts; every request path (HTTPS, HTTP, hops, doors, local wildcards) is enumerated; the probe refusal sits at the one forwarding boundary and the identity compared is the pinned box id; the response wrapper covers implicit headers and flushes; one renderer contract with the client behaviour stated; a complete decoder inventory; and the §11 witnesses that need a holding publication. |
| + |
| +Builds on plan 1 (lifetimes, `(incarnation, serving_seq)` on rows and publications, `target_host` on alias publications, `edgesFileWriteClaims(..., lifetimes bool)`, the fold's pair rule) and plan 2 (`SelfServeMove{DisplayName, OfflineAt, EstimateSeconds}` on the record, `Participants`, `moveCfg.BoxID`, the job's health phase and `probeTarget`). |
| **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. |
| +**Architecture:** Every lifetimes-mode box publishes its held offline-move rows as holding entries in a new edges-file field `offline_moves`, separate from `hosts` so no decoder counts them as served. The edge keeps, per name, the list of candidates its files publish (ready routes, holding entries, certificate-only entries) and resolves a request by one arbitration over the local registry's candidates and the remote files' candidates: stamp first, real route over alias, then the pair within one lifetime, with the alias rule for different targets, and holding entries counted only while fresh. The same arbitration answers routing, the holding page and certificate admission. A `X-Carlos-Probe: local` request is refused with 503 at the forwarding boundary 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`. |
| +**Tech Stack:** Go. `internal/host/edgesfile.go` (the writer), `internal/edge` (home map, `ServeHTTP`, `pending.go`, `certsync.go`, `wildcard.go`), `internal/edgesfile`, `internal/domains` (the alias reconciler), `internal/registry` (`route_aliases`), `internal/console` (the probe), the agent's wiring. |
| **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. |
| +- `offline_moves` is never a reason to count a host as served: every decoder in Task 4's inventory reads `hosts` as today. |
| +- A holding entry is believed only while `now - fetchedAt < holdingValidity` (5 min), `fetchedAt` being when this edge last fetched **that box's file** successfully; a carried-forward file keeps its old `fetchedAt`. Expiry is applied at lookup time by re-arbitrating the retained candidates, never by store I/O, so the answer after expiry is exactly what the retained ready candidates give (today's routing), including after a whole refresh fails. |
| +- Explicit aliases only: the writer lists the alias rows whose registry row is `Explicit` and eligible (`dns_status == "pointing"`, not shadowed by a served row, the owner rule); derived aliases are never listed. |
| +- The display name is the only request-supplied text on the page, rendered through `html/template`; no header, path or diagnostic reaches it. |
| +- The page is a 503 with `Retry-After: 30`, `Cache-Control: no-store`, `X-Robots-Tag: noindex`; its script reloads by `location.replace` of the current URL (a GET, so a POST never repeats) every 30 s until `until`, then shows the taking-longer sentence and keeps reloading every 60 s; its sessionStorage key is `carlos-holding-<host>`, distinct from the pending page's. |
| +- No change to a claims-on box's publication or to claim-stamp precedence: the claims-on writer publishes no `offline_moves`; a stamped entry beats every holding entry. |
| +- 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 account is temporarily offline. Please check back in a few minutes." (no estimate); "This is taking longer than expected. Please keep checking back."; "not served here". |
| +- Census: the box units take rows in `plan14-root-reads-self-serve-moves-3.tsv`, loaded last by a new block at the end of `TestPlan14RootReadCensus`, `rev3=PROPOSED-self-serve-moves-3`, with reviewed dispositions for the changed envelopes (`internal/host`, `internal/edge`, `internal/domains`, `internal/registry`); the record-write census is unchanged by this plan (no record writer changes) and `TestPlan9B8RecordWriteCensus` stays green; the registry's new `explicit` column is a tracked field. |
| - 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`). |
| +1. A holding entry fetched 4 m 59 s ago versus 5 m 1 s ago; a whole refresh that fails (list, every get, some gets) after the entry was fetched: the first is honoured, the others give the retained ready route (Task 5, `TestHoldingEntriesExpireFromTheFetchClock`). |
| +2. An explicit alias repointed from the moving instance to another during the move, with the three publications in every order: the new target's ready route wins over the hold, fresh and retained; detached, the alias falls to a derived entry of another target (Task 3, `TestAliasRepointedDuringAMove`). |
| +3. The stale ready alias publication of the instance being moved loses to its own hold; a legacy alias publication without `target_host` is a different target and wins (Task 3, `TestAliasOfTheMovingInstanceLosesToItsHold`). |
| +4. The source box's own edge with a local hold `(1, 1)` and a retained remote ready `(1, 0)`: the page; a remote ready `(1, 2)` after open: backhaul; a local ready alias to A against a fetched hold for A at a higher pair: the page (Task 2, `TestLocalAndRemoteArbitrateTogether`). |
| +5. A probe reaching the ordinary, wildcard-exact and door-authorised forwarding paths: 503 "not served here", no dial; served locally: `X-Carlos-Served-By` names this box id on an implicit 200, a HEAD and a flushed response (Task 6, `TestProbeModeNeverPassesABackhauledAnswer`). |
| --- |
| -### Task 1: The writer publishes holding entries |
| +### Task 1: Alias provenance and the writer's 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` |
| +- Modify: `internal/registry/registry.go` (`route_aliases` gains `explicit INTEGER NOT NULL DEFAULT 0` in its ALTER list; `RouteAlias.Explicit bool`; `UpsertAlias` and `ListAliases` carry it), `internal/domains/aliases.go` (`ReconcileAliases`: an alias written for a claim with `Route != ""` is `Explicit: true`; `deriveInstanceAliases` writes `Explicit: false`; a legacy row (explicit 0) whose name matches a current claim is upgraded on the next reconcile), `internal/host/edgesfile.go` (`edgesFile.OfflineMoves []holdingEntry \`json:"offline_moves,omitempty"\``; `edgesFileWriteClaims(ctx, s, reg, ident, eip, last, refresh, claims, lifetimes bool, holds holdingSource)`: holding publication **only with `lifetimes`**, whatever `claims` is; a shared `aliasEligible(a registry.RouteAlias, served map[string]edgeHost, owners map[string]*hostOwner) bool` used by the served-alias loop and the holding entries), `internal/host/movesoffline.go` (`func (v *offlineView) holding(host string) (provision.MoveState, bool)` from the snapshot; nil or failed snapshot → false), `internal/host/agent.go` (`holds` from the armed view) |
| +- Test: `internal/host/edgesfile_holding_test.go`, `internal/domains/aliases_explicit_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. |
| +- `type holdingEntry struct { Host string \`json:"host"\`; Platform bool \`json:"platform,omitempty"\`; 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, Platform: r.Platform || r.Provisioned, Aliases, MoveID, Incarnation: r.Incarnation, ServingSeq: r.ServingSeq, OfflineAt: unix seconds of mv.SelfServe.OfflineAt (0 when nil or no SelfServe), EstimateSeconds, DisplayName}`; `Aliases` = the `route_aliases` rows with `Host == r.Host && Explicit && aliasEligible`. A ready row, or a hold the view does not know, publishes none; the entry stops when the record no longer carries that move id (the view) or the row is ready. The target's `landing` row publishes from flip until `ReleaseHoldSeq`. Entries 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 1: Write the failing tests**: `TestAliasReconcilerWritesProvenance` (a claim-backed alias → explicit; a derived instance alias → not; a legacy row upgraded); `TestWriterPublishesHoldingEntriesOnlyInLifetimesMode` (lifetimes on with region HA off and on → entries; lifetimes off with either → none; `applyEdgesClaims` still runs before the served alias construction); `TestHoldingEntryListsOnlyExplicitEligibleAliases` (explicit pointing → listed; explicit not pointing → not; derived exact name → not; shadowed by a served row → not); `TestTargetPublishesWhileLanding`; `TestHoldingEntryStopsWhenTheRecordClearsOrChangesID` (the row still held, the view's move id changes → no entry). |
| +- [ ] **Step 2: Run to verify they fail**: `go test ./internal/host -run 'Holding' -count=1`, `go test ./internal/domains -run 'Provenance' -count=1`, `go test ./internal/registry -run 'Alias' -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`. |
| +- [ ] **Step 5: Commit** `edges: explicit alias provenance in the registry; the lifetimes writer publishes held offline-move rows as holding entries`. |
| -### Task 2: The edge reads holding entries, folds them and renders the page |
| +### Task 2: Candidates, one arbitration, the page, and the local edge |
| **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` |
| +- Modify: `internal/edge/homemap.go` (`edgesFileView.OfflineMoves []holdingView \`json:"offline_moves"\`` and `fetchedAt time.Time` (set by `refreshHosts` on a successful parse; carried forward otherwise); `homeSnapshot` gains `cands map[string][]candidate` (every candidate per name, immutable once published) beside `hosts` (which becomes the fold's answer at `now` for the platform set and the wake only); `foldHomes` builds candidates; `arbitrate(cands []candidate, now time.Time) decision` is the one rule; `lookupHome`, `Home`, `ExactHome`, `Owner`, `KnownHost`, `Hosts`, `ServedHosts` resolve through `arbitrate` at call time), `internal/edge/holding.go` (new: `renderHoldingPage`, `holdingAnswer`), `internal/edge/edge.go` (`Reload` builds `localCands map[string][]candidate` from the registry: ready rows (`Status == "ready"`) as ready candidates with their pair, alias rows (`aliasEligible`'s registry half: pointing) as alias candidates with `targetHost`, held rows whose `HoldOp` the `HoldingSource` knows as holding candidates with the view's move; `e.resolve(host, now) decision` merges local and remote candidates and arbitrates; `ServeHTTP` and `httpFallbackHandler` act on the decision), `internal/edge/certsync.go`, `internal/edge/wildcard.go` (certificate admission and the wildcard parents through the same resolution), the agent (`edge.SetHoldingSource(view.holding)`, `edge.RefreshHolds()` after every `offlineView.refresh`, `edge.WithBoxID(boxID)`) |
| +- Test: `internal/edge/arbitrate_test.go`, `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`. |
| +- `type holdingView struct { Host string \`json:"host"\`; Platform bool \`json:"platform"\`; Aliases []string \`json:"aliases"\`; MoveID string \`json:"move_id"\`; Incarnation uint64 \`json:"incarnation"\`; ServingSeq uint64 \`json:"serving_seq"\`; OfflineAt int64 \`json:"offline_at"\`; EstimateSeconds int \`json:"estimate_seconds"\`; DisplayName string \`json:"display_name"\` }`. |
| +- `type candidate struct { kind candKind (candReady, candHolding, candCert); eip, box string; local bool; alias, wildcard, platform bool; targetHost string; incarnation, servingSeq uint64; stamp int64; fetchedAt time.Time; hold *holdingView }`. The constructor for a remote file copies `fetchedAt`, `box` and `eip` (`dialAddress`) into every candidate of that file; a holding entry yields one `candHolding` for its host (`targetHost = host`, `alias false`) and one `candHolding` per listed alias (`alias true`, `targetHost = host`); a ready alias entry carries its `target_host`; a certificate-only wildcard entry is `candCert`. |
| +- `type decision struct { kind decKind (decLocal, decRemote, decHolding, decNone); eip, box string; account, app string; platform, wildcard bool; hold *holdingView }`. |
| +- **`arbitrate(cands, now)`**, exactly: (1) drop `candHolding` with `now.Sub(fetchedAt) >= holdingValidity` (local holds have no expiry; they come from this box's registry); (2) `platform`/`wildcard` flags are the OR over every candidate (certificate classes merge, never decide); (3) `candCert` never decides; if nothing else remains → `decNone` (with the merged flags for the certificate parents); (4) **stamp:** if any remaining candidate is stamped, the highest stamp among stamped ready candidates wins (holding candidates are never stamped); (5) **real route over alias:** if any remaining candidate is a non-alias ready or holding candidate, alias candidates are dropped; (6) **the alias rule** (only alias candidates remain): if any ready alias's `targetHost` differs from every holding alias's `targetHost` (an empty `targetHost` counts as different), the holding aliases are dropped; (7) among what remains, the highest `(incarnation, servingSeq)` wins; a tie between a ready and a holding candidate → the ready one; a tie among ready candidates → the existing rule (the earlier file; a local candidate is "earliest"); a tie among holding candidates → the earlier file; (8) the winner's whole routing state is the decision: a ready winner → `decLocal` or `decRemote` with its `eip`, `box`, owner; a holding winner → `decHolding` with its view. |
| +- `func (m *homeMap) resolve(host string, now time.Time) decision` (exact name; wildcard fallback `*.<parent>` only when the exact name has **no** candidates at all, so a holding winner never falls through to a wildcard); `Home`/`ExactHome` answer only `decRemote`; `Owner` answers only a `decRemote` with an owner; `KnownHost(host, now)` is true when the decision is `decRemote` or `decHolding`; `Hosts()` (the wildcard parent set) includes a `decHolding` winner's host when the hold is `platform`. |
| +- `func (e *Edge) resolve(host string, now time.Time) decision`: the local candidates for `host` (and for its wildcard parent when the exact name has none) plus the remote ones, arbitrated together; the result drives: `decLocal` → the existing upstream path; `decRemote` → `backhaulTo`; `decHolding` → `holdingAnswer`; `decNone` → today's answers (`unknownHost`: pending or 404 or fallback). |
| +- **Insertion points, enumerated**: `ServeHTTP` (HTTPS): after `acceptHop`, replace `lookupUpstreamPath`'s decision with `e.resolve(host, now)`; a hopped request whose decision is `decHolding` → the page (no further forwarding, so the one-hop guarantee holds); `decRemote` on a hopped request → `staleHopRefusal` as today; a door caller (`isDoor`) → `decLocal` requires `doorOwnsRoute` as today; `decRemote` requires `Owner` to match the door as today; `decHolding` → the page; the local-wildcard branch and the `!ok` branch are both subsumed. `httpFallbackHandler` (HTTP): `decLocal`, `decRemote`, `decHolding` → the HTTPS redirect (a known answering host); `decNone` → today. `Allowed(host)` (TLS SNI): `decLocal` or `decHolding` with a local candidate; `deploymentHost` → `Allowed || KnownHost`. `currentHosts` (the wildcard parents and cert sync): the local ready **and local held** platform/provisioned non-alias rows; `homeMap.Hosts()` as above. |
| +- The page: `func renderHoldingPage(displayName string, untilUnix int64, now time.Time) []byte`: `untilUnix > now.Unix()` → with a name "<name> is temporarily offline. Please check back in approximately N minutes." (N = ceil((until − now)/60), minimum 1), without a name the generic sentence with N; `untilUnix == 0` → "This account is temporarily offline. Please check back in a few minutes."; `untilUnix <= now.Unix()` → "This is taking longer than expected. Please keep checking back."; the script carries `until` and the reload cadence; `func (e *Edge) holdingAnswer(w, r, h holdingView)` sets the headers and writes the page (HEAD writes none). |
| +- `localHolds` refresh: `RefreshHolds()` rebuilds the local candidates' holding part from the registry and the view and swaps the local candidate map atomically; `Watch`'s fingerprint reload does the same; a nil or failed view → no local holding candidates (fail open to today's answer). |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestHoldingViewDecodesTheWriterFixture` (a fixture emitted by the real writer; every field asserted); `TestArbitrate` (a table over every rule: stamp over hold; real over alias; the alias rule with three publications in every order; pair within one lifetime; ties; expiry; cert-only never decides; the winner's state copied); `TestResolveNeverFallsThroughAHoldingWinner`; `TestLocalAndRemoteArbitrateTogether` (the three cases of Review Focus 4, on a source edge and on a target edge); `TestHoldingPageRendersEveryCase` (name, no name, no estimate, past until, `</script>` in the name escaped; headers; HEAD); `TestEveryRequestPathHonoursTheDecision` (HTTPS local, remote, holding, none; HTTP each; a hopped request; a door caller; a local wildcard upstream against a fetched hold for the exact name); `TestLocalHoldsRefreshWithTheView` (the view clears the move while the row stays held → no page; startup before the view is readable → today's answer; a later view → the page without a registry change); `TestTLSCoverageForHoldingHosts` (real handshakes: a wildcard-only platform host held → its parent stays in `currentHosts` and `certFor` serves; an explicit alias held → `Allowed`; expired → not; a non-issuer; an arbitrary wildcard label refused). |
| +- [ ] **Step 2: Run to verify they fail**: `go test ./internal/edge -run 'Arbitrate|Holding|Resolve|LocalAndRemote|RequestPath|TLSCoverage' -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`. |
| +- [ ] **Step 4: Run to verify they pass**, plus `go test ./internal/edge -run 'HomeMap|Fold|Pending|Cert' -count=1`. |
| +- [ ] **Step 5: Commit** `edge: one arbitration over local and remote candidates decides routing, the holding page and certificate admission`. |
| -### Task 3: The alias rule |
| +### Task 3: The alias rule's witnesses |
| **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` |
| +- Test: `internal/edge/alias_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`. |
| +The rule is Task 2's steps (5) to (7); this task pins it. |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestAliasOfTheMovingInstanceLosesToItsHold` (A's stale ready alias `(1, 0)` target A vs A's hold `(1, 1)` → the page; the legacy publication without target → the route); `TestAliasRepointedDuringAMove` (A's stale ready alias target A `(1, 0)`, B's ready alias target B `(1, 0)`, A's holding alias `(1, 1)`: B wins in all six orders, fresh and retained; detached from B, a derived entry whose primary is C → C); `TestTwoHoldsForOneAlias`; `TestHoldingAliasNeverBeatsARealRoute`; `TestStampedAliasBeatsEveryHold`. |
| +- [ ] **Step 2: Run to verify they fail; Step 3: fix `arbitrate` if they find anything; Step 4: run** `go test ./internal/edge -run 'Alias|Arbitrate' -count=1`. |
| +- [ ] **Step 5: Commit** `edge: the alias rule pinned in every publication order`. |
| ### 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) |
| +- 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`, `internal/hostactors/references_offline_moves_test.go` |
| +- Create: `internal/edgesfile/testdata/offline-moves.json` (one served route, one holding entry for another host with one alias, one certificate-only wildcard entry) |
| -- [ ] **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`. |
| +The inventory, each with its assertion: `edgesfile.Parse` (`File.Hosts` has the route, not the hold); `edgesfile.ReadSnapshot`/`ReadLiveSnapshot` (`PlacementView.Hosts`); `edgesfile.DeploymentRoutes` (with fresh status evidence so a served route is present; the hold absent); the console's `loadEdgesJoin` (`instancesplacement.go`), `offlineServes` (`apiinstancemove_offline.go`), `boxchoice` and `regioncandidates` through their decision functions over the fixture's snapshot (a candidate box is chosen for the served route's region; the held host contributes nothing), `apidomains.go`'s read (the held alias is not "taken"); `domains.ReconcileAliases` through its `DeploymentRoutes` input (the held alias is not a served route); `internal/steering` (`deployment.go`, `steering.go`: the desired state ignores the hold); `internal/host`: `moveTargets` (`movespass.go`), `loadMoveFleet` (`movesreport.go`), the writer's prior-publication recovery and `seedEdgesFileGate` (`edgesfile.go`), the home map (`homemap.go`: the hold is a candidate, not a served host); `internal/hostactors/references.go` (the box-id reader ignores it). `TestEveryEdgesFileDecoderIsAudited` lists every file that unmarshals an edges file (found by `go/ast`: a `json.Unmarshal` whose target type has a `hosts` tag, or a call to `edgesfile.Parse`/`ReadSnapshot`/`ReadLiveSnapshot`/`DeploymentRoutes`) and fails on one missing from this list. |
| + |
| +- [ ] **Step 1: Write the tests** (they pass against the current decoders and pin the contract). **Step 2: 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) |
| +- Modify: `internal/edge/homemap.go` (`refreshHosts` sets `fetchedAt` on a successful parse and carries the old value forward with a carried-forward file; `arbitrate` applies the five-minute rule at call time) |
| - 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`. |
| +- [ ] **Step 1: Write the failing tests**: `TestHoldingEntriesExpireFromTheFetchClock` (4 m 59 s → the page; 5 m 1 s → the retained ready route, with no refresh in between; a failed `List`; every `Get` failing; a partial failure where this box's file fails and others succeed; the exact boundary); `TestExpiredHoldFallsBackToTheRetainedRoute` (the fold selected the hold over a retained ready `(1, 0)`; after expiry the route answers and `KnownHost` is still true for it); `TestExpiredHoldNoLongerAdmitsACertificate` (a holding-only host after expiry → `KnownHost` false). |
| +- [ ] **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 and expire without I/O`. |
| ### 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) |
| +- Modify: `internal/edge/edge.go` (`probe := r.Header.Get("X-Carlos-Probe") == "local"`; **in `backhaulTo`** (the one forwarding boundary, reached by the ordinary path, the wildcard-exact path and the door-authorised path): `probe` → 503 "not served here", no dial, before the owner and hop checks' side effects; for `decLocal` with `probe`: `w = &servedByWriter{ResponseWriter: w, box: e.boxID}`; `Edge.WithBoxID(id string)`), `internal/edge/servedby.go` (new: `servedByWriter`), `internal/console/selfserve_health.go` (`probeTarget` sends `X-Carlos-Probe: local` and requires `X-Carlos-Served-By == row.Participants.Target.BoxID` and `x-carlos-version == the sealed build`) |
| - 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`. |
| +**Interfaces:** |
| +- `type servedByWriter struct { http.ResponseWriter; box string; wrote bool }`: `WriteHeader(code)` and the first `Write` (an implicit 200) both call `commit()`, which deletes every `X-Carlos-Served-By` value and sets this box's id once; `Flush()` commits then flushes when the underlying writer is an `http.Flusher`; `Unwrap()` returns the underlying writer so `http.ResponseController` (hijack, flush, deadlines) keeps working; the wrapper is placed outermost for the probe request only, so ordinary responses (and their caching) are untouched; probe responses carry `Cache-Control: no-store` added at commit. The generation proxy's `X-Carlos-Version` stays authoritative. |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestProbeModeNeverPassesABackhauledAnswer` (the three forwarding paths → 503 and no dial; ordinary requests on those paths unchanged); `TestServedByStampedOnEveryCommitPath` (an explicit `WriteHeader`, an implicit 200 from `Write`, a HEAD, a `Flush` before any write, multiple app-supplied values replaced; a streaming upgrade still works through `Unwrap`); `TestConsoleProbeRequiresServedByAndVersion` (missing header, another id, a label instead of an id, wrong or missing version, a 3xx, the edge's 503 → fail; the pinned id and version → pass). |
| +- [ ] **Step 2: Run to verify they fail; Step 3: implement; Step 4: run** `go test ./internal/edge -run 'Probe|ServedBy' -count=1`, `go test ./internal/console -run 'ConsoleProbe' -count=1`. |
| +- [ ] **Step 5: Commit** `edge: probe mode refuses to forward at the one boundary and stamps the serving box id; 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) |
| +- Test: `internal/e2e/selfserve/holding_test.go` (plan 2's harness 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 1: Write the tests**: `TestHoldingPageOnAThirdEdgeAndACustomDomain` (from start to the target's opened publication the third edge answers 503 with the page for the host and the alias; after the open the page is replaced by the backhaul to the target **before** the health phase passes; an app that then fails on the target answers as the app does, no hold returns; the source box's own edge answers the page for its held row); `TestAbortAfterLandingThenSourceReconstructionWithRetainedTargetHold` (the §11 witness: abort from landed; delete the source row; reconcile reconstructs it at `(1, 3)`; the target's holding publication at `(1, 1)` is retained on the third edge; the reconstructed route wins); `TestReadyRouteWinsOverStaleHoldAndHoldOverStaleRoute`; `TestFirstMoveWithPairlessRetainedPublication`; `TestRecreatedHostOutranksOldHoldingAndReadyPublications`; `TestCurrentHoldBeatsPreviousLifetimesReadyRoute`; `TestEdgeRestartedIntoMixedPublications`; `TestExpiredHoldingEntryFallsBackIncludingAfterAWholeRefreshFails`; `TestAliasWitnessesEndToEnd`. |
| - [ ] **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 3: Run both census tests and the cross-builds**; write `docs/superpowers/notes/census-proposal-self-serve-moves-3.md` with the overlay's loader block, the changed envelopes and the registry field. |
| - [ ] **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). |
| +- Spec coverage: §6.4 Publication (T1), Rendering (T2), TLS (T2), The display name (T2), Aliases (T2, T3), the decoder audit (T4), Freshness (T5), Reopening (T2's arbitration and T7's witness), §7.4 probe mode (T6), §11's holding witnesses (T7). |
| +- Known limit, stated: the page's client script is pinned by assertions on the rendered script's text (`until`, the cadence, the storage key) and by the server-rendered sentences; no browser runs in CI. |
| +- Names used across tasks: `RouteAlias.Explicit`, `aliasEligible`, `holdingEntry`, `holdingSource`, `holdingView`, `candidate`/`candKind`, `decision`/`decKind`, `arbitrate`, `homeMap.resolve`, `Edge.resolve`, `holdingValidity`, `renderHoldingPage`, `holdingAnswer`, `SetHoldingSource`, `RefreshHolds`, `WithBoxID`, `servedByWriter`, `probeTarget` (plan 2). |
| - Review Focus 1 to 5 are pinned by T5, T3, T3, T2, T6. |