plan: self-serve moves 1 rev 12 (Astra review 11: settle anchored after the last legacy unit, marker-backed activation, lifetime-aware purge, dispatch by unit) and 3 rev 6 (Astra review 5: explicit wildcard fallback in the decision, membership on unlabelled boxes, subscribe-and-seed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+23
−22
docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md+13 −12docs/superpowers/plans/2026-10-22-self-serve-moves-3-holding-page.md+10 −10
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok amadan.net/carlos/platform.git/launchctx 0.013s
? 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 (142 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.795s
ok github.com/carlosframework/platform/cmd/moveprobe 12.002s
ok github.com/carlosframework/platform/cmd/zddprobe 0.738s
ok github.com/carlosframework/platform/internal/activator 60.229s
ok github.com/carlosframework/platform/internal/agentsock 0.027s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.027s
ok github.com/carlosframework/platform/internal/appconfig 3.015s
ok github.com/carlosframework/platform/internal/apperr 0.152s
ok github.com/carlosframework/platform/internal/applog 0.266s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.007s
ok github.com/carlosframework/platform/internal/boxid 0.734s
? 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.030s
ok github.com/carlosframework/platform/internal/caps 0.016s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.806s
ok github.com/carlosframework/platform/internal/changelog 0.471s
ok github.com/carlosframework/platform/internal/claimactivation 1.241s
ok github.com/carlosframework/platform/internal/clamd 0.059s
? 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.962s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.021s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.020s
ok github.com/carlosframework/platform/internal/domains 2.947s
ok github.com/carlosframework/platform/internal/doorkey 0.008s
ok github.com/carlosframework/platform/internal/economics 0.009s
ok github.com/carlosframework/platform/internal/edge 45.321s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.158s
ok github.com/carlosframework/platform/internal/gate 1.856s
ok github.com/carlosframework/platform/internal/genselect 0.033s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.033s
ok github.com/carlosframework/platform/internal/historyreadback 0.197s
ok github.com/carlosframework/platform/internal/host 139.612s
ok github.com/carlosframework/platform/internal/hostactors 6.850s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 3.353s
ok github.com/carlosframework/platform/internal/hostclaim 0.873s
ok github.com/carlosframework/platform/internal/hostenv 0.005s
ok github.com/carlosframework/platform/internal/hostevidence 0.009s
ok github.com/carlosframework/platform/internal/hostexec 5.690s
ok github.com/carlosframework/platform/internal/hostfence 0.084s
ok github.com/carlosframework/platform/internal/hostfiles 10.246s
ok github.com/carlosframework/platform/internal/hostfloor 0.019s
ok github.com/carlosframework/platform/internal/hostguard 1.044s
ok github.com/carlosframework/platform/internal/hostops 2.391s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostrelocate 0.796s
ok github.com/carlosframework/platform/internal/hostresolve 0.214s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.009s
ok github.com/carlosframework/platform/internal/instancectl 5.231s
ok github.com/carlosframework/platform/internal/instanceexec 2.844s
ok github.com/carlosframework/platform/internal/instancefs 0.009s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.010s
ok github.com/carlosframework/platform/internal/layout 1.933s
ok github.com/carlosframework/platform/internal/lbhealth 0.006s
ok github.com/carlosframework/platform/internal/lease 10.212s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.620s
ok github.com/carlosframework/platform/internal/ledger 0.027s
ok github.com/carlosframework/platform/internal/mail 0.310s
ok github.com/carlosframework/platform/internal/maintenance 0.080s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.572s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.122s
ok github.com/carlosframework/platform/internal/metrics 0.009s
ok github.com/carlosframework/platform/internal/moves 21.533s
ok github.com/carlosframework/platform/internal/moves/archive 1.111s
ok github.com/carlosframework/platform/internal/objstore 0.338s
ok github.com/carlosframework/platform/internal/ops 0.237s
ok github.com/carlosframework/platform/internal/park 0.305s
ok github.com/carlosframework/platform/internal/plans 0.011s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.710s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.700s
ok github.com/carlosframework/platform/internal/relay 7.244s
ok github.com/carlosframework/platform/internal/release 25.494s
ok github.com/carlosframework/platform/internal/rollout 3.898s
ok github.com/carlosframework/platform/internal/rolloutwords 0.006s
ok github.com/carlosframework/platform/internal/rootaws 0.011s
ok github.com/carlosframework/platform/internal/rootparticipation 0.070s
ok github.com/carlosframework/platform/internal/rootstate 0.029s
ok github.com/carlosframework/platform/internal/runs 0.558s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 2.793s
ok github.com/carlosframework/platform/internal/seeds 0.018s
ok github.com/carlosframework/platform/internal/sim/hostlife 11.401s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.179s
ok github.com/carlosframework/platform/internal/status 0.074s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.208s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.006s
ok github.com/carlosframework/platform/internal/unixdial 0.011s
ok github.com/carlosframework/platform/internal/updatewords 0.007s
ok github.com/carlosframework/platform/internal/usage 0.016s
ok github.com/carlosframework/platform/internal/version 0.004s
ok github.com/carlosframework/platform/internal/vet 1.743s
=== internal_console@0 (78 s)
ok github.com/carlosframework/platform/internal/console 76.034s
=== internal_console@1 (38 s)
ok github.com/carlosframework/platform/internal/console 35.392s
=== internal_console@2 (42 s)
ok github.com/carlosframework/platform/internal/console 39.851s
=== internal_console@3 (38 s)
ok github.com/carlosframework/platform/internal/console 35.828s
=== internal_console@4 (37 s)
ok github.com/carlosframework/platform/internal/console 35.440s
=== internal_console@5 (35 s)
ok github.com/carlosframework/platform/internal/console 33.472s
=== internal_console@6 (36 s)
ok github.com/carlosframework/platform/internal/console 34.327s
=== internal_console@7 (43 s)
ok github.com/carlosframework/platform/internal/console 41.063s
=== root@0 (33 s)
ok github.com/carlosframework/platform 30.896s
=== root@1 (45 s)
ok github.com/carlosframework/platform 42.631s
=== root@2 (42 s)
ok github.com/carlosframework/platform 40.321s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/22 09:38:04 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0D07C79B68476, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md b/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md| index 826a65d5c..bb9eed807 100644 |
| --- a/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md |
| +++ b/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md |
| @@ -2,7 +2,7 @@ |
| > **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 11, 2026-10-22. Rev 10's review (`selfserve-plan1-review10.md`, findings 1 to 4) is answered: the cutover gains an **admission gate**: every claims-off unit of record work (a request's handler, a detached continuation, a worker iteration) is admitted under a counted gate while the state is `before`; the cutover door closes admission synchronously, refuses new work with 503, **waits for the admitted count to reach zero**, then starts the storage settle, then activates; so no unit admitted before the marker ever runs beside the lifetime protocol, and a unit never changes mode mid-flight. `on` is **latched**: once observed it is never left; at startup the state is `unknown` until the first successful marker read and every claims-off record door (reads and mutations) answers 503 until then, so a marker-read failure never restores legacy interpretation. The removal rule separates lifetime validity from placement eligibility: a valid own record that is **in this pass's scope** protects the legacy collision; a valid own record **outside** the scope follows today's removal rules (`KeepParticipant`, the app-trash rule), so offline finishing removes the source row as today; absence or staleness authorises the new path. One worker lifecycle (start once, loop, check the state each iteration), one migration interval, one context contract (`recordCtx`), the CLI command, the retry error, the three root constructions and the copy inventory are stated. Rev 10, 2026-10-22. Rev 9's review (`selfserve-plan1-review9.md`, findings 1 to 5) is answered: the cutover is a **mode** with a drain: from the marker's time until the window ends the console refuses every claims-off record mutation (503), so no legacy write can be admitted into the window, and the gate, the judge's installation, the recovery pass and the marker's failure modes are one contract with an implementation task for the operator door; the removal loop decides from the row's **own** record (observed by its own key), so a valid legacy owner in a hostname collision is kept as today and only an established absent or stale own record retires the row, with the keep view bypassed on that evidence and replacement stated as two passes; `fenceRecord` returns a classification so an own tombstone or marker that won resumes the operation instead of terminating it; the audit's allowlist names the two remaining claims-on callers of `hostops.ReadRecord` and is pinned both ways; and the remaining contracts are settled (the detach sentence, cleanup recovery through C2, corrupt index candidates, the root restart readers' judge). Rev 8's review (`selfserve-plan1-review8.md`, findings 1 to 5) is answered: the rollout guarantee becomes an **enforced cutover** (an operator marker written after the old console has stopped, plus the settle window, with the storage assumption stated) rather than a timer alone; quarantine copies the corrupt bytes and **never deletes under a live slot**: the retirement converts durably to the cleanup kind and retires the slot first, and a conditional-delete conflict re-classifies instead of counting as absence; a provisioned row owned by another record key than the hostname's current record is retired as "record gone" so a cross-owner recreation cannot leave a lagging box serving the predecessor; `RecordIndex.Find` judges its candidate keys before deciding ambiguity; and the remaining contradictions are resolved (the root scope sentence, one detach chain, the locked retirement's callers, reconcile's one sequence, the audit's enumerated list, the fence witness split). Rev 7's review (`selfserve-plan1-review7.md`, findings 1 to 6) is answered: a stated rollout guarantee closes the late legacy delete (one console per deployment, a settle window before any termination, old consoles never reintroduced over lifetimes records); the first-route attach, its rollback and detach run **inside** the hostname exclusion from the claim write to the record write, through locked variants of the create and retirement helpers; root's proof and restart record readers take the box's judge (the scope statement is corrected: only `ClaimRootHTTP`'s claims-on readers are slot-free); a corrupt body is quarantined (its bytes kept under `control/quarantine/`, the key removed conditionally) so the retirement or migration proceeds as for absence instead of promising a fence that cannot parse it; `provision.WithLifetimes` is the one context helper that carries the judge and enables sequencing, the runs worker wraps its detached execution context, the audit distinguishes approved judged wrappers from raw readers, the fence witnesses state their timing, exhaustion is witnessed at every transition, and the sibling undo has one dispatch path. Earlier reviews: rev 1 (`after236/selfserve-plan1-review.md`, 16 findings), rev 2 (F1 to F12), rev 3 (R1 to R10), rev 4 (1 to 9), rev 5 (1 to 6), rev 6 (`selfserve-plan1-review6.md`, findings 1 to 5). Rev 7 answers rev 6's five: every claims-off record write runs with `WithRecordSequence`, so `record_seq` exists after a record's first write and an ETag never recurs, and recovery writes a **fence** (a sequencing rewrite) before it terminates an operation on evidence of a changed record; one migration dispatcher classifies the observation at every entry, so a resume can never supersede its own marker; the attach door's claim rollback runs inside the hostname exclusion and never releases a claim a pending operation or a valid record needs; the judge reaches every mutation context (the console's top-level handler wraps every request, browser and API alike; the seed callback wraps its own context; the full-record updater's re-read is judged; `RecordIndex.Find` and `FindRecordByHost` take the judge from the context), and the audit is by AST; the remaining contracts (the running N1 table, a slot-retirement helper, one expectation type, listed-key absence, exhaustion at read, the import witness's state, the producer-state publication tests) are answered. Rev 5 removed the slot cache (every judgement reads the slot fresh, after the record, in bounded rounds) and made a successor lifetime's hold one row write. Rev 6 answers rev 5's six: a retirement persists its **kind** (normal, orphan, cleanup) and each kind has its own steps and recovery table; a migration's marker-CAS conflict is classified by a re-read for both the bound and the unbound form, so an own marker that landed is resumed, never superseded; every `retireInstanceRecord` caller states its expectation (the sibling undo names the lifetime it created; detach deletes what is current; the attach door never asks for a record rollback over a lifetime failure); a judge carried on the context makes `updateRecordSeeding`, and so every `SetRecord*` writer, refuse a stale record, and every box reader (Phase 1's included) and the import journal's readers go through the judge; the provenance mark is dropped (the fold compares pairs between unstamped entries, a pair-less entry is `(0, 0)` and equal pairs fall to the existing rule, exactly §6.4); the remaining interface gaps are answered. |
| +Rev 12, 2026-10-22. Rev 11's review (`selfserve-plan1-review11.md`, findings 1 to 7) is answered: the storage settle is anchored **after the last `before` unit released admission** (and after the process start on a restart), so a legacy request that was fully received before its client timed out has completed before activation; activation requires a **validated marker read** (`markerSeen`), never uncertainty alone; app purge is **lifetime-aware** on every path (browser, expiry pass, box auto-purge) through a retirement core in `provision` that `release.PurgeApp` calls for every instance record before its sweep; an in-scope valid own record defeats only the record-gone reason and still reaches the app-trash decision; the dispatch of create, delete, detach and `readDeletedRecord` is stated per admitted unit; both detached continuations (`runImportUpload`, `createFrom → runFrom`) are wired; the Task 3 witness no longer says draining reads are legacy; `claimAliasUnbacked` is named in the audit. Rev 11, 2026-10-22. Rev 10's review (`selfserve-plan1-review10.md`, findings 1 to 4) is answered: the cutover gains an **admission gate**: every claims-off unit of record work (a request's handler, a detached continuation, a worker iteration) is admitted under a counted gate while the state is `before`; the cutover door closes admission synchronously, refuses new work with 503, **waits for the admitted count to reach zero**, then starts the storage settle, then activates; so no unit admitted before the marker ever runs beside the lifetime protocol, and a unit never changes mode mid-flight. `on` is **latched**: once observed it is never left; at startup the state is `unknown` until the first successful marker read and every claims-off record door (reads and mutations) answers 503 until then, so a marker-read failure never restores legacy interpretation. The removal rule separates lifetime validity from placement eligibility: a valid own record that is **in this pass's scope** protects the legacy collision; a valid own record **outside** the scope follows today's removal rules (`KeepParticipant`, the app-trash rule), so offline finishing removes the source row as today; absence or staleness authorises the new path. One worker lifecycle (start once, loop, check the state each iteration), one migration interval, one context contract (`recordCtx`), the CLI command, the retry error, the three root constructions and the copy inventory are stated. Rev 10, 2026-10-22. Rev 9's review (`selfserve-plan1-review9.md`, findings 1 to 5) is answered: the cutover is a **mode** with a drain: from the marker's time until the window ends the console refuses every claims-off record mutation (503), so no legacy write can be admitted into the window, and the gate, the judge's installation, the recovery pass and the marker's failure modes are one contract with an implementation task for the operator door; the removal loop decides from the row's **own** record (observed by its own key), so a valid legacy owner in a hostname collision is kept as today and only an established absent or stale own record retires the row, with the keep view bypassed on that evidence and replacement stated as two passes; `fenceRecord` returns a classification so an own tombstone or marker that won resumes the operation instead of terminating it; the audit's allowlist names the two remaining claims-on callers of `hostops.ReadRecord` and is pinned both ways; and the remaining contracts are settled (the detach sentence, cleanup recovery through C2, corrupt index candidates, the root restart readers' judge). Rev 8's review (`selfserve-plan1-review8.md`, findings 1 to 5) is answered: the rollout guarantee becomes an **enforced cutover** (an operator marker written after the old console has stopped, plus the settle window, with the storage assumption stated) rather than a timer alone; quarantine copies the corrupt bytes and **never deletes under a live slot**: the retirement converts durably to the cleanup kind and retires the slot first, and a conditional-delete conflict re-classifies instead of counting as absence; a provisioned row owned by another record key than the hostname's current record is retired as "record gone" so a cross-owner recreation cannot leave a lagging box serving the predecessor; `RecordIndex.Find` judges its candidate keys before deciding ambiguity; and the remaining contradictions are resolved (the root scope sentence, one detach chain, the locked retirement's callers, reconcile's one sequence, the audit's enumerated list, the fence witness split). Rev 7's review (`selfserve-plan1-review7.md`, findings 1 to 6) is answered: a stated rollout guarantee closes the late legacy delete (one console per deployment, a settle window before any termination, old consoles never reintroduced over lifetimes records); the first-route attach, its rollback and detach run **inside** the hostname exclusion from the claim write to the record write, through locked variants of the create and retirement helpers; root's proof and restart record readers take the box's judge (the scope statement is corrected: only `ClaimRootHTTP`'s claims-on readers are slot-free); a corrupt body is quarantined (its bytes kept under `control/quarantine/`, the key removed conditionally) so the retirement or migration proceeds as for absence instead of promising a fence that cannot parse it; `provision.WithLifetimes` is the one context helper that carries the judge and enables sequencing, the runs worker wraps its detached execution context, the audit distinguishes approved judged wrappers from raw readers, the fence witnesses state their timing, exhaustion is witnessed at every transition, and the sibling undo has one dispatch path. Earlier reviews: rev 1 (`after236/selfserve-plan1-review.md`, 16 findings), rev 2 (F1 to F12), rev 3 (R1 to R10), rev 4 (1 to 9), rev 5 (1 to 6), rev 6 (`selfserve-plan1-review6.md`, findings 1 to 5). Rev 7 answers rev 6's five: every claims-off record write runs with `WithRecordSequence`, so `record_seq` exists after a record's first write and an ETag never recurs, and recovery writes a **fence** (a sequencing rewrite) before it terminates an operation on evidence of a changed record; one migration dispatcher classifies the observation at every entry, so a resume can never supersede its own marker; the attach door's claim rollback runs inside the hostname exclusion and never releases a claim a pending operation or a valid record needs; the judge reaches every mutation context (the console's top-level handler wraps every request, browser and API alike; the seed callback wraps its own context; the full-record updater's re-read is judged; `RecordIndex.Find` and `FindRecordByHost` take the judge from the context), and the audit is by AST; the remaining contracts (the running N1 table, a slot-retirement helper, one expectation type, listed-key absence, exhaustion at read, the import witness's state, the producer-state publication tests) are answered. Rev 5 removed the slot cache (every judgement reads the slot fresh, after the record, in bounded rounds) and made a successor lifetime's hold one row write. Rev 6 answers rev 5's six: a retirement persists its **kind** (normal, orphan, cleanup) and each kind has its own steps and recovery table; a migration's marker-CAS conflict is classified by a re-read for both the bound and the unbound form, so an own marker that landed is resumed, never superseded; every `retireInstanceRecord` caller states its expectation (the sibling undo names the lifetime it created; detach deletes what is current; the attach door never asks for a record rollback over a lifetime failure); a judge carried on the context makes `updateRecordSeeding`, and so every `SetRecord*` writer, refuse a stale record, and every box reader (Phase 1's included) and the import journal's readers go through the judge; the provenance mark is dropped (the fold compares pairs between unstamped entries, a pair-less entry is `(0, 0)` and equal pairs fall to the existing rule, exactly §6.4); the remaining interface gaps are answered. |
| **Goal:** Give every claims-off instance record a durable lifetime (the host incarnation slot) and every served row a serving order `(incarnation, serving_seq)` that boxes publish and edges honour, with crash-safe create, retirement and migration, so a later plan's holding pages and moves can never be out-ordered by a stale publication. |
| @@ -26,7 +26,7 @@ Rev 11, 2026-10-22. Rev 10's review (`selfserve-plan1-review10.md`, findings 1 t |
| - Copy: the new sentences are copy rows pending Paul's review: "the instance changed. read it again and try the delete once more"; "another change to this instance is still settling. try again in a minute"; "the console is switching on instance lifetimes. try again in a minute" (draining); "the console is starting. try again in a minute" (the marker not yet read); "this console runs host claims; lifetimes are not for it"; "the cutover marker exists but cannot be read. repair control/lifetimes-cutover.json by hand"; the CLI's output lines "instance lifetimes: switching on; N unit(s) of earlier work still finishing" and "instance lifetimes: on since <time>". Existing sentences are reused where the plan says so. No tenant names: `shop.example.com`, account `bab`, app `shop`. |
| - Boxes roll before the console (the roll order rule); the migration pass is behind `CARLOS_LIFETIMES_MIGRATE=1`, default off, until every box of a deployment runs this code. |
| - Counters: `incarnation` and `move_seq` are `uint64` on the record and `INTEGER` (int64) in SQLite and the registry; a value that would exceed `math.MaxInt64` is refused before any write (`ErrLifetimeExhausted`), never wrapped; a stored value above `math.MaxInt64` is `ErrLifetimeExhausted` at read (the doors answer 502, reconcile protects the row). |
| -- **Cutover (the late legacy delete).** An older console's `RetireRecord` deletes a record unconditionally and must never run beside this code; nor may this console let a legacy mutation admitted before the cutover land after lifetimes start. The cutover is an **admission gate plus a marker** (Task 0). The marker `control/lifetimes-cutover.json` (`{"at": <time>, "by": <operator>}`) is written create-only by the operator door `POST /api/cli/system/lifetimes/cutover` (`carlos system lifetimes cutover`), run **after** the old console process has stopped (one console process per deployment; a unit restart stops the old process before the new one starts). **Admission:** every claims-off unit of record work on the console (an API or browser request that may mutate or read a record; a detached continuation such as the import upload's; one iteration of a worker: the seed sweep, the import pass, the runs pass, the placement pass, the recovery and migration passes) begins with `unit, ok := s.lifetimesAdmit(ctx)` and ends with `unit.done()`; the unit runs in the **mode it was admitted in** for its whole life (a detached continuation inherits its request's unit and ends it when the continuation ends). **States:** `unknown` (startup, the marker not yet read successfully): every claims-off record door, read or mutation, answers 503 "the console is starting. try again in a minute" and workers idle; `before` (the marker read and absent): units are admitted without a judge and behave exactly as main; `draining` (the marker exists, or the door has just written it): no new unit is admitted (mutations and reads that would mutate answer 503 "the console is switching on instance lifetimes. try again in a minute"; plain reads are admitted **judged**, since a read never lands a write); activation waits until the admitted-before count is zero **and** `lifetimeSettle` (60 s) has passed since the later of the marker's `at` and this process's own start; `on`: units are admitted with the judge (`provision.WithLifetimes`), the passes run. `on` is **latched** in the process: once reached, no later marker read, failure or decode error leaves it; a process that starts and reads a marker older than `lifetimeSettle` enters `on` directly (no earlier process is alive to drain). A marker that cannot be read or does not decode: in `unknown` the state stays `unknown` (503s) and pages once per hour; in `before`/`draining` the state becomes `draining` (no new units); in `on` nothing changes. The storage assumption, stated: S3 applies a mutation before it acknowledges it and never after the client's connection closed before the request was fully received, and a fully received request completes within seconds; with the old process gone, every admitted-before unit of this process finished, and the settle passed, no legacy mutation is outstanding when lifetimes turn on. (b) the recovery and migration passes do no work unless admitted in `on`; (c) an older console is never reintroduced once the marker exists. The cutover is a rollout prerequisite and an addition to the design, stated beside the uncached slot read. Within it a key never disappears under a live owned slot except through this code's conditional deletes, so a record's `record_seq` is a non-repeating revision. |
| +- **Cutover (the late legacy delete).** An older console's `RetireRecord` deletes a record unconditionally and must never run beside this code; nor may this console let a legacy mutation admitted before the cutover land after lifetimes start. The cutover is an **admission gate plus a marker** (Task 0). The marker `control/lifetimes-cutover.json` (`{"at": <time>, "by": <operator>}`) is written create-only by the operator door `POST /api/cli/system/lifetimes/cutover` (`carlos system lifetimes cutover`), run **after** the old console process has stopped (one console process per deployment; a unit restart stops the old process before the new one starts). **Admission:** every claims-off unit of record work on the console (an API or browser request that may mutate or read a record; a detached continuation such as the import upload's; one iteration of a worker: the seed sweep, the import pass, the runs pass, the placement pass, the recovery and migration passes) begins with `unit, ok := s.lifetimesAdmit(ctx)` and ends with `unit.done()`; the unit runs in the **mode it was admitted in** for its whole life (a detached continuation inherits its request's unit and ends it when the continuation ends). **States:** `unknown` (startup, the marker not yet read successfully): every claims-off record door, read or mutation, answers 503 "the console is starting. try again in a minute" and workers idle; `before` (the marker read and absent): units are admitted without a judge and behave exactly as main; `draining` (the marker exists, or the door has just written it): no new unit is admitted (mutations and reads that would mutate answer 503 "the console is switching on instance lifetimes. try again in a minute"; plain reads are admitted **judged**, since a read never lands a write); activation requires a **successfully validated marker** (`markerSeen`), the admitted-before count at zero, and `lifetimeSettle` (60 s) elapsed since the **latest** of the marker's `at`, this process's start, and the instant the last `before` unit called `done()` (a legacy request that was fully received by storage before its client timed out completes within the settle, so nothing admitted before the marker lands after activation); `on`: units are admitted with the judge (`provision.WithLifetimes`), the passes run. `on` is **latched** in the process: once reached, no later marker read, failure or decode error leaves it; a process that starts into an existing marker, however old, enters `draining` with no units and activates `lifetimeSettle` after its own start (the previous process's fully received requests complete within that window). A marker that cannot be read or does not decode: in `unknown` the state stays `unknown` (503s) and pages once per hour; in `before`/`draining` the state becomes `draining` (no new units) **but `markerSeen` stays false, so activation cannot happen on uncertainty**; a later successful absent read returns `draining`-by-uncertainty to `before`; in `on` nothing changes. The storage assumption, stated: S3 applies a mutation before it acknowledges it and never after the client's connection closed before the request was fully received, and a fully received request completes within seconds; with the old process gone, every admitted-before unit of this process finished, and the settle passed, no legacy mutation is outstanding when lifetimes turn on. (b) the recovery and migration passes do no work unless admitted in `on`; (c) an older console is never reintroduced once the marker exists. The cutover is a rollout prerequisite and an addition to the design, stated beside the uncached slot read. Within it a key never disappears under a live owned slot except through this code's conditional deletes, so a record's `record_seq` is a non-repeating revision. |
| - **Record sequencing.** Every claims-off record write runs with `provision.WithRecordSequence(ctx)`, which `provision.WithLifetimes(ctx, judge)` (Task 6) applies together with the judge, so one context helper at each entry point (the console's `ServeHTTP`, every pass start, the agent's passes, the runs worker's execution, the seed callback, root's proof reader) enforces both: the lifetime create introduces `record_seq` (1) and every later `WriteRecord` advances it, so once a record has been written by this code its ETag never recurs. A pending operation is terminated (`refused`, `void`, `superseded`) on evidence that the record changed **only after a fence**: `fenceRecord` (Task 3) rewrites the current body against its ETag with the sequence enabled, so an outstanding conditional write against any earlier ETag can never land; a fence that cannot be written leaves the operation pending. |
| - Local tests: only the touched packages with `-run`; CI runs the full suite. Go needs the sandbox off; never set `GOCACHE`. |
| @@ -51,13 +51,13 @@ Rev 11, 2026-10-22. Rev 10's review (`selfserve-plan1-review10.md`, findings 1 t |
| **Interfaces:** |
| - `type cutoverMarker struct { At time.Time \`json:"at"\`; By string \`json:"by"\` }`; `const lifetimesCutoverKey = "control/lifetimes-cutover.json"`; `const lifetimeSettle = 60 * time.Second`; `type cutoverState int` (`cutoverUnknown`, `cutoverBefore`, `cutoverDraining`, `cutoverOn`). |
| -- `type cutoverGate struct { mu sync.Mutex; state cutoverState; at time.Time; started time.Time; admitted int; drained chan struct{}; pageAt time.Time }`: `func (g *cutoverGate) admit(now time.Time, mutating bool) (unit *admittedUnit, ok bool, refusal string)` (`unknown` → false, the starting sentence; `before` → a unit with `mode: before`, `admitted++`; `draining` → `mutating` → false, the draining sentence; a plain read → a unit with `mode: on` (judged; reads never write); `on` → a unit with `mode: on`); `func (u *admittedUnit) done()` (`admitted--` for a `before` unit; the last one closes `drained`); `func (g *cutoverGate) observe(m *cutoverMarker, readErr error, now time.Time)` (the watcher's and the door's input: the transitions of the Global Constraint; `on` is never left; `before`→`draining` closes admission synchronously); `func (g *cutoverGate) activateWhenDrained(now time.Time) bool` (`draining` and `admitted == 0` and `now >= max(at, started) + lifetimeSettle` → `on`); `func (g *cutoverGate) status() cutoverStatus` (`{State string; At time.Time; Pending int}`). |
| -- `func (s *Server) lifetimesAdmit(ctx context.Context, mutating bool) (*admittedUnit, bool, string)`; `func (s *Server) recordCtx(ctx context.Context, u *admittedUnit) context.Context` (`u.mode == on` → `provision.WithLifetimes(ctx, s.judge)`, else `ctx` unchanged); `func (s *Server) lifetimesOn() bool` (`!cfg.HostClaim && cfg.Releases != nil && s.cutover.state == cutoverOn`). Every claims-off record door: `u, ok, why := s.lifetimesAdmit(ctx, mutating); if !ok { 503 why }; defer u.done(); ctx = s.recordCtx(ctx, u)`. A detached continuation receives `u` and calls `u.done()` when it ends instead of the handler. Every worker iteration: admit (non-mutating workers as reads; the passes that write as mutating), skip the iteration when refused, `done()` at its end. |
| +- `type cutoverGate struct { mu sync.Mutex; state cutoverState; markerSeen bool; at time.Time; started time.Time; lastDone time.Time; admitted int; drained chan struct{}; pageAt time.Time }`: `func (g *cutoverGate) admit(now time.Time, mutating bool) (unit *admittedUnit, ok bool, refusal string)` (`unknown` → false, the starting sentence; `before` → a unit with `mode: before`, `admitted++`; `draining` → `mutating` → false, the draining sentence; a plain read → a unit with `mode: on` (judged; reads never write); `on` → a unit with `mode: on`); `func (u *admittedUnit) done()` (`admitted--` for a `before` unit; the last one records `lastDone = now` and closes `drained`; exactly one `done()` per unit, enforced by a `sync.Once`); `func (g *cutoverGate) observe(m *cutoverMarker, readErr error, now time.Time)` (the watcher's and the door's input: the transitions of the Global Constraint; `on` is never left; `before`→`draining` closes admission synchronously); `func (g *cutoverGate) activateWhenDrained(now time.Time) bool` (`draining` and `markerSeen` and `admitted == 0` and `now >= max(at, started, lastDone) + lifetimeSettle` → `on`; a read failure sets `draining` without `markerSeen`, which never activates); `func (g *cutoverGate) status() cutoverStatus` (`{State string; At time.Time; Pending int}`). |
| +- `func (s *Server) lifetimesAdmit(ctx context.Context, mutating bool) (*admittedUnit, bool, string)`; `func (s *Server) recordCtx(ctx context.Context, u *admittedUnit) context.Context` (`u.mode == on` → `provision.WithLifetimes(ctx, s.judge)`, else `ctx` unchanged); `func (s *Server) lifetimesOn() bool` (`!cfg.HostClaim && cfg.Releases != nil && s.cutover.state == cutoverOn`). Every claims-off record door: `u, ok, why := s.lifetimesAdmit(ctx, mutating); if !ok { 503 why }; defer u.done(); ctx = s.recordCtx(ctx, u)`. **Dispatch by unit:** every claims-off create, delete, detach, purge and `readDeletedRecord` site selects its path from `u.mode`: `before` → today's legacy call (the plain create, the unconditional `DeleteRecord`, the raw read) with no slot and no judge; `on` → the lifetime helper of Tasks 3 to 5; `!cfg.HostClaim` alone never selects the lifetime path. **Detached continuations** (`runImportUpload` and `createFrom → runFrom`): the handler transfers `u` to the job **before** launching the goroutine, the handler does not call `done()`, and the job calls it exactly once after its deferred abort or cleanup writes have returned. Every worker iteration: admit (non-mutating workers as reads; the passes that write as mutating), skip the iteration when refused, `done()` at its end. |
| - `func (s *Server) handleAPILifetimesCutover(w, r)`: an operator's own CLI token (else 404, as the move family); `cfg.HostClaim` → 409 "this console runs host claims; lifetimes are not for it"; writes the marker create-only with `At: now`, `By: addr`, then `s.cutover.observe(&m, nil, now)` **synchronously** (admission closes before the response); `ErrCASConflict` → read the existing marker: readable → `observe` it and answer with the status; unreadable or malformed → 502 "the cutover marker exists but cannot be read. repair control/lifetimes-cutover.json by hand" (never a success); the audit line "switched instance lifetimes on (cli)"; the response is `cutoverStatus` (200). The CLI prints "instance lifetimes: switching on; N unit(s) of earlier work still finishing" while `Pending > 0` or the settle has not passed, and "instance lifetimes: on since <time>" when `on`; `carlos system lifetimes cutover` polls the door every 5 s until `on` (the door is idempotent) or `--no-wait`. |
| - `StartLifetimesCutoverWatch`: reads the marker at start and every 10 s and calls `observe`, then `activateWhenDrained`; the door's synchronous `observe` is what makes refusal immediate at `At`; the watcher covers a restart and a marker written by hand (documented: a hand-written marker closes admission within 10 s). |
| - **Worker lifecycle (one rule):** every worker starts once at process start and loops for the life of the process; each iteration admits, does its work only when admitted in the mode it requires (`on` for the recovery and migration passes), and ends its unit. No worker returns early because the state was `before` or `unknown` at start. |
| -- [ ] **Step 1: Write the failing tests**: `TestCutoverDoorIsOperatorOnlyAndIdempotent` (second call → the status; an unreadable existing marker → 502, not a success); `TestCutoverStates` (unknown at start → 503 on every door incl. GET; a read marker absent → before; a marker written → draining at once; `on` after drain and settle; `on` is kept across a later read failure and a corrupt marker; a restart into a marker older than 60 s → on at once; a restart into a marker 10 s old → on at 60 s after the process start); `TestDrainingRefusesEveryClaimsOffMutation` (one subtest per door → 503, nothing written; GETs answer, judged); `TestActivationWaitsForAdmittedWork` (a real creation handler paused before its fresh record-write context, and a detached import continuation paused mid-body, both admitted before the marker; the marker written; 60 s pass; the state is still draining with `Pending == 2`; each finishes → its legacy write lands under `before` rules; the count reaches zero → `on` after the settle; a creation arriving at `At + 1 s` → 503); `TestNoUnitChangesModeMidFlight` (a unit admitted in `before` observes no judge after activation would otherwise have happened; one admitted in `on` has it); `TestWorkersLoopAcrossTheCutover` (the recovery and migration passes started before the marker do nothing, then run after activation without a restart; the seed sweep's iteration admitted before the marker finishes under legacy rules and the next iteration is judged); `TestMarkerFailureAfterActivationKeepsJudgedReads` (a retired slot and an obsolete body; the marker read fails; GET and listings refuse the body); `TestCutoverCLI` (the dispatch, the client call, the two output lines, `--no-wait`). |
| +- [ ] **Step 1: Write the failing tests**: `TestCutoverDoorIsOperatorOnlyAndIdempotent` (second call → the status; an unreadable existing marker → 502, not a success); `TestCutoverStates` (unknown at start → 503 on every door incl. GET; a read marker absent → before; a marker written → draining at once; `on` after drain and settle; `on` is kept across a later read failure and a corrupt marker; a restart into a marker older than 60 s → on at once; a restart into a marker 10 s old → on at 60 s after the process start); `TestDrainingRefusesEveryClaimsOffMutation` (one subtest per door → 503, nothing written; GETs answer, judged); `TestActivationWaitsForAdmittedWork` (a real creation handler paused before its fresh record-write context, and a detached import continuation paused mid-body, both admitted before the marker; the marker written; 60 s pass; the state is still draining with `Pending == 2`; each finishes → its legacy write lands under `before` rules; the count reaches zero → `on` after the settle; a creation arriving at `At + 1 s` → 503); `TestNoUnitChangesModeMidFlight` (a unit admitted in `before` observes no judge after activation would otherwise have happened; one admitted in `on` has it); `TestWorkersLoopAcrossTheCutover` (the recovery and migration passes started before the marker do nothing, then run after activation without a restart; the seed sweep's iteration admitted before the marker finishes under legacy rules and the next iteration is judged); `TestMarkerFailureAfterActivationKeepsJudgedReads` (a retired slot and an obsolete body; the marker read fails; GET and listings refuse the body); `TestCutoverCLI` (the dispatch, the client call, the two output lines, `--no-wait`); `TestSettleStartsAfterTheLastLegacyUnit` (a `before` unit whose final store call returns an ambiguous error while the server-side effect is still outstanding (a fake store that applies the delete 5 s after returning a timeout); the unit calls `done()` at `T`; the marker is 10 min old; activation happens no earlier than `T + 60 s`; the late delete lands before activation and after it no legacy write exists); `TestActivationRequiresAValidatedMarker` (absent marker; a read failure; no units; 60 s pass → still draining, not on; a successful absent read → before; a successful present read → draining then on after the settle; a restart into a 10 min old marker → on 60 s after the process start, not at once); `TestDispatchFollowsTheUnit` (a `before` unit's create, delete, detach and `readDeletedRecord` take the legacy path even after the marker; an `on` unit's take the lifetime helpers); `TestDetachedContinuationsOwnTheirUnit` (import upload and `runFrom`: the handler returns, the unit stays admitted until the job's last write, exactly one `done()`). |
| - [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Cutover|Draining|Activation|MidFlight|WorkersLoop|MarkerFailure' -count=1`, `go test . -run 'SystemLifetimes' -count=1`. |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run to verify they pass.** |
| @@ -163,7 +163,7 @@ Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host, |
| - `TestRecoveryReplaysCreateAndTheLateWriteIsHarmless`; `TestLateSlotWriteDoesNotAbandonTheAllocation` (both orders); `TestDeleteDuringCreateRecoveryIsRefused` (through the real DELETE door while the record is absent → 409 settling). |
| - `TestRecoveryWritesTheSlotWhenMissing`, `TestRecoveryContinuesFromAPredecessorSlot`, `TestRecoveryLeavesALandedCreateServing`, `TestRecoveryRemovesAStaleRecordThenWrites`, `TestRecoverySupersedesOnlyOnSuccessorEvidence`, `TestRecoveryLeavesPendingWhenUnreadable` (a failing slot read; a malformed slot body), `TestRecoverySurvivesASecondCrash`, `TestRecoveryUsesOnlyPersistedState`. |
| - `TestAttachKeepsItsClaimOverAPendingAllocation` (failure after the slot write; a SQLite failure at step 7); `TestAttachNeverRetiresOverALifetimeFailure` (every helper failure: a counting retirement sees no call); `TestCompetingAttachCannotReleaseAnotherAttachsClaim` (A creates the claim and pauses **before** its lifetime create; B's attach blocks on the exclusion until A's attempt ends; A fails pre-allocation and its rollback releases the claim; B then proceeds and mints its own claim and record: no record is ever left without its claim; the pending-allocation and completed-record variants of rev 7 stay; and detach in place of A's rollback blocks the same way); `TestDetachCompletesThroughTheRealHandlers` (browser and API first-route detach: the record goes before the claim, no deadlock). |
| - - `TestFenceMakesAnOutstandingWriteHarmless` (a record without `record_seq`; an outstanding tombstone PUT and, separately, a marker PUT against its ETag E paused; recovery's classification observes the record at B (changed); the test then restores A, so the bytes and ETag return to E, **before** recovery fences; recovery fences and terminates; the released PUT fails CAS; with the fence disabled by a test hook the same PUT lands, which is the defect the fence closes; a second variant releases the PUT **during** a fence retry and splits on who won: the fence won → the late PUT fails CAS; the operation's write won → the next classification sees the tombstone (or the own marker, `ErrRecordMigrating` to the fence) and the operation is completed or recovered from that observation, never terminated from the earlier changed-record classification); `TestCutoverGatesLifetimes` (no marker → the create door writes a plain record as main and the passes idle; a marker younger than 60 s → every mutation 503, reads as main, the passes idle; older → the lifetime protocol); `TestFenceResultsDriveTheCaller` (the fence's `fenced`, `ownWriteWon` for a tombstone and for a marker, and `absent` results each produce the stated caller behaviour through the production helpers). |
| + - `TestFenceMakesAnOutstandingWriteHarmless` (a record without `record_seq`; an outstanding tombstone PUT and, separately, a marker PUT against its ETag E paused; recovery's classification observes the record at B (changed); the test then restores A, so the bytes and ETag return to E, **before** recovery fences; recovery fences and terminates; the released PUT fails CAS; with the fence disabled by a test hook the same PUT lands, which is the defect the fence closes; a second variant releases the PUT **during** a fence retry and splits on who won: the fence won → the late PUT fails CAS; the operation's write won → the next classification sees the tombstone (or the own marker, `ErrRecordMigrating` to the fence) and the operation is completed or recovered from that observation, never terminated from the earlier changed-record classification); `TestCutoverGatesLifetimes` (no marker → the create door writes a plain record as main and the passes idle; a marker younger than 60 s → every mutation 503, reads judged, the passes idle; drained and settled → the lifetime protocol); `TestFenceResultsDriveTheCaller` (the fence's `fenced`, `ownWriteWon` for a tombstone and for a marker, and `absent` results each produce the stated caller behaviour through the production helpers). |
| - `TestCreatedRecordsCarryARecordSeq` and `TestEveryClaimsOffWriteAdvancesTheSequence` (`SetRecordRegion` on a legacy record under a judged context introduces `record_seq`; A→B→A yields three distinct ETags). |
| - `TestExhaustionIsRefusedBeforeAnyWrite` (an allocation at `MaxInt64` → `ErrLifetimeExhausted`, no row, no slot write; a slot body above it → unreadable; a record whose stored `incarnation` or `move_seq` is above it → unreadable at the doors and protected by reconcile). |
| - `TestSiblingUndoRetiresOnlyTheLifetimeItCreated` (pause the undo after the create returned incarnation 1; delete and recreate the host at incarnation 2; resume: the undo observes incarnation 2, answers nil without touching it, the successor serves). |
| @@ -176,7 +176,8 @@ Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host, |
| ### Task 4: Retirement through a tombstone, from every claims-off removal |
| **Files:** |
| -- Create: `internal/console/lifetimes_retire.go` |
| +- Create: `internal/console/lifetimes_retire.go`, `internal/provision/lifetimes_retire.go` (the **retirement core** `provision.RetireRecordWithLifetime(ctx, s release.Store, judge *LifetimeJudge, acct, app, host string, kind RetirementKind, expect RetireExpectation) error`: observe, tombstone, retire the slot, delete, as this task specifies; the console's `retireRecordWithLifetime` wraps it in the hostname exclusion; a box calls it under its own per-host hold lock) |
| +- Modify: `internal/release/trash.go` (`purgeApp` gains `retire func(ctx context.Context, acct, app, host string) error`; **before** the key sweep it calls `retire` for every key under the app prefix of the instance-record shape (`provision.RecordKey`), in sorted order; nil → today's unconditional sweep; `PurgeApp` and `PurgeAppIfTrashedAt` take the parameter), `internal/console/ownership.go` (`handlePurgeApp`: `retire` = `retireRecordWithLifetime(..., normal, retireAny)` for an `on` unit, nil for a `before` unit), `internal/console/expirypass.go` (`purgeOne`: the same by its iteration's unit), `internal/domains/reconcile.go` (the box's auto-purge at line 271: `retire` = `provision.RetireRecordWithLifetime(ctx, s, cfg.Judge, ...)` under `WithHostHoldLock`; nil when `cfg.Judge == nil`); a purge interrupted after k retirements is re-run as today (retirement with `retireAny` is idempotent: absent → committed); a pending allocation of a purged host finds its slot retired and voids (Task 3's recovery); the hostname is reusable by another app at incarnation N+1, |
| - Modify: `internal/console/hostclaimops.go` (claims-off `ref.Family == "instance"` → `retireRecordWithLifetime(ctx, ref.Account, ref.App, ref.Host, ref.Expect)`; `provision.RetireRef` gains `Expect retireExpectation`-shaped fields through a new `provision.RetireExpectation{Absent bool; ETag string; Incarnation uint64}` so each caller states its own), `internal/console/claim_retire.go` (`retireInstanceRecord(ctx, account, app, host, by, incarnation string, expect provision.RetireExpectation)`; its claims-off callers: `apiinstances.go:1684` (the sibling undo) with `{Incarnation: created.Incarnation}` through the locking `retireRecordWithLifetime`; **detach does not go through it on the claims-off path**: `apidomains.go:989` and `domainattach.go:349` call Task 3's `detachFirstRouteLocked`, which calls `retireRecordWithLifetimeLocked(..., retireAny)` inside the exclusion it already holds; the claims-on detach keeps today's `retireInstanceRecord` chain; `domainattach.go:557` (`rollbackAttach`) never retires on the claims-off path; `apiinstances.go:2245` is not reached on claims-off: `importDeleteRule` answers every claims-off delete), `internal/console/apiinstanceimport.go` (`readDeletedRecord` returns `(provision.Observation, error)` through `s.judge.Observe`; `importDeleteRule`: `!obs.Present` → `retireRecordWithLifetime(..., retireExpectAbsent)`; `obs.Verdict` stale or corrupt → `deleteRead(..., retireExpectETag(obs.ETag))`; valid → the import logic as today, and its ordinary delete `deleteRead(..., retireExpectETag(obs.ETag))`; `deleteRead` passes the expectation through: `ErrCASConflict` keeps the `again` re-decision; `errLifetimeSettling` → 409 settling; `errRetireRefused` → 409 its sentence), `internal/console/seedfinish.go` (`finishSeedOnce`'s final delete → `retireRecordWithLifetime(ctx, sqid, app, host, retireExpectETag(etag))`), `internal/console/apiinstances.go` (`handleAPIInstanceDelete`: `errRetireRefused`, `errLifetimeSettling` → 409), `internal/console/domainattach.go` and `internal/console/apidomains.go` (detach: `errLifetimeSettling` → 409 `claimDetachInProgressMsg`; `errRetireRefused` → 409 its sentence), `internal/console/lifetimes_recovery.go` |
| - Test: `internal/console/lifetimes_retire_test.go` |
| @@ -206,7 +207,7 @@ Inside `withHostname(host)`: `admit(host, "")`; `obs := classifyLocked` (unreada |
| - kind **orphan**, `pred`: absent or any present body → O1 (the slot, then the body if present), `committed`. |
| - kind **cleanup**, `pred`: any observation (absent, corrupt, stale, or a valid body at N: the DELETE this operation answers was for this lifetime, and under the retired slot every body is stale) → C1 then C2 on the current body's ETag when present, `committed`. |
| -- [ ] **Step 1: Write the failing tests** through the real doors and the function: |
| +- [ ] **Step 1: Write the failing tests** through the real doors and the function: `TestPurgeRetiresEveryInstanceRecord` (browser purge, the expiry pass and the box auto-purge: every record under the app has its slot retired and a tombstone written before the sweep; a purge interrupted after k retirements resumes and completes; a pending allocation of a purged host voids on recovery; another app then creates the hostname at N+1; a `before` unit's purge is today's sweep); |
| - `TestDeleteDoorRetiresThroughTheTombstone`; `TestSlotRetiresBeforeTheKeyIsAbsent`. |
| - `TestDeleteDoorAbsentPathAdmitsAndRetiresOrphans` (initial absence; absence found on the judge's re-read; a record created since is untouched and the door still answers "deleted"). |
| - `TestStaleCleanupRetiresALiveOwnedSlotFirst` (a fieldless late body beside `{1, live, this key}`: the slot is `{1, retired}` before the body is removed; a blocked incarnation-1 create-only write released afterwards lands stale; the same for a corrupt body). |
| @@ -254,7 +255,7 @@ Recovery, per pending migration, inside the exclusion with `admit(host, OpID)`: |
| **Files:** |
| - Create: `internal/provision/lifetime_judge.go`, `internal/console/lifetimes_read.go` |
| -- Modify: `internal/provision/reconcile.go` (the decode loop at lines 855–941 becomes **one sequence**: parse the key → `cfg.Judge.ObserveWith(ctx, s, acct, app, host, raw, etag, err)` (which decodes, path-validates and judges; a nil judge decodes and path-validates only) → the shape, host-shape, channel and scope checks on the returned observation's record; stale hosts are recorded in `stale map[string]bool`; **the removal loop**, claims-off (`cfg.Judge != nil`): lifetime validity and placement eligibility are **separate questions**. When `recs[rt.Host]` is absent or its key differs from `RecordKey(rt.Account, rt.App, rt.Host)`, the pass observes the row's own key (`cfg.Judge.Observe`): a present valid record **whose placement is in this pass's scope** (the same region and pool the pass lists; the record would have been in `recs` but for the hostname collision) → the row is kept (a legacy collision between two valid owners stays as today); a present valid record **outside the scope** (another region after a flip or a placement change, another pool) → **today's removal rules** apply unchanged (`KeepParticipant` keeps it while a move's view says so; the app-trash rule; otherwise removed), so an offline move's source row is kept while `offlineView.keep` says so and removed at finishing exactly as today; established absent or stale → "record gone" (a held row by an op its own record does not carry → `cfg.DeleteIfHold` first), and this evidence also bypasses `KeepParticipant` (the keep view keys by hostname and cannot vouch for a row whose own record is gone); unreadable → protected; the journal exemption and hand-managed rows untouched. Replacement by a cross-owner successor takes **two passes**: this pass retires the predecessor row, the next mints the successor (the minting loop runs before removal and must not mint over a row it does not own); the witness asserts convergence within two passes; `ListAllRecordsStrict` and `ListAllRecordsTolerant` gain judged siblings and `readAllRecords` takes the judge; `holdExisting` line 1716: `cfg.Judge.Observe`), `internal/provision/seedrollback.go` (`journalRecordByKey(ctx, s, j, judge)`: `Observe`; stale → `(nil, nil)` as a record that is gone; corrupt → the decode error as today; unreadable → the error), `internal/provision/seedreconcile.go` (`journalRecord` passes `cfg.Judge`; a stale journal record is "gone" to the journal's own rules, which already handle an absent record, and `known` stays true; unreadable keeps `known = false`), `internal/provision/provision.go` (`WithJudge`; `updateRecordSeeding`: when the context carries a judge, the read becomes `Observe` and a stale or absent observation returns `os.ErrNotExist` before `mut` runs, a corrupt one the decode error, an unreadable one its error; judged siblings of `GetRecordETag`, `ListRecordsStrict`, `ListRecordsComplete`, `ListRecords`), `internal/host/agent.go` (one `boxJudge *provision.LifetimeJudge`, built once in `provisionTickConfig`'s caller when `!hostwrite.Enabled(getenv)` and nil otherwise, set on `ReconcileConfig.Judge`, `moveCfg.Judge`, the offline view, the runs worker, the steering loop, retention, the seed hook and the proof binding source; every pass context on the box is `provision.WithLifetimes(ctx, boxJudge)`: the reconcile loop, the moves pass, the runs worker and its execution, the steering loop, retention, the status tick), `internal/host/movespass.go:1079`, `internal/host/movesoffline.go` (`refreshOfflineView`; `offlineReassert` line 420), `internal/host/movesoffline_stage.go` (75, 162), `internal/host/runs.go` (122: `ListAllRecordsStrictJudged` with today's strict error handling; 278), `internal/host/runexec.go` (318, 421), `internal/host/steering_loop.go` (100), `internal/host/retention.go` (`appRecords` line 308: `Observe`; stale → omitted; unreadable → the error, as every error is today), `internal/host/seedwiring.go` (`seedPlacedHook`: the ctx carries the judge, so `SetRecordSeed` itself refuses a stale record with `os.ErrNotExist`, which the hook returns; the conflict re-read at line 60 uses `ReadRecordJudged`), every other `provision.GetRecord`/`ListAllRecords` in `internal/host` (`movescut.go:102`, `moveslandcommit.go:73,197`, `moveshold.go:266`, `movesland.go:188`, `movesfinal.go:562,622,709`, `moveslandinstall.go:92`, `movespolicy.go:146`, `movesparks.go:62`: all become the judged readers with the box's judge; on a claims-on box the judge is nil and they behave as today, so no exemptions), `internal/console/server.go` (`s.judge`; **`ServeHTTP` at line 1683 wraps every request**: `r = r.WithContext(s.recordCtx(r.Context(), u))` after Task 0's `lifetimesAdmit` (the judge only for a unit admitted in `on`), so browser handlers (`placeAppTarget`'s callers in `fleets.go` and `appsettings.go` included) and the API alike carry the judge and sequencing; every pass start derives its context with `WithLifetimes(ctx, s.judge)`), `internal/console/*.go` (the inventory below; `placeSeedBlock` and `importInFlight` take a `*provision.LifetimeJudge` parameter), `internal/provision/provision.go` (`claimRawUpdateRecord`: its `s.Get` re-read becomes `Observe` under the context's judge; stale → `os.ErrNotExist`, corrupt → the decode error, unreadable → the error, and the caller's ETag condition is kept), `internal/provision/rootrecord.go` (`IndexRecords` keeps **every** candidate key per hostname instead of marking two keys ambiguous; `RecordIndex.Find` and `FindRecordByHost` observe each candidate through `JudgeFrom(ctx)`: exactly one valid → it; none valid and none corrupt or unreadable → `os.ErrNotExist`; two or more valid → `os.ErrPermission` (genuine ambiguity); a corrupt or path-mismatched candidate → `ErrRecordCorrupt` (never absence; today's schema and shape validation still applies to the chosen record); an unreadable candidate → its error; with a nil judge, two candidates → `os.ErrPermission` as today; the production proof source's cached index caches keys, not verdicts), `internal/host/proofbinding.go` (`productionProofBindingSource` gains `judge *provision.LifetimeJudge`, set by its constructor from `!hostwrite.Enabled(getenv)` in **both** the agent and the root process (`runAdoptUnderRootRelease` and the restart paths, the retained-census configuration included); `findRecord` wraps whatever context it is given with `WithLifetimes(ctx, s.judge)` before `FindRecordByHost`/`index.Find`, so `proofObserver.observe`'s `context.Background()` is covered; **`rootRestartRequestSource`** (its `target` and `creation` readers) takes a `judge` in **every production construction**: `productionRestartRequestPass` (both callers in `agent.go`), `rootHistoryCreation` and `productionRootUpdatesHeld`, each given the box's judge by its caller and wrapping its contexts with `WithLifetimes`; `TestRootRestartReadersAreJudged` constructs through those three production functions and asserts a stale record yields no restart target, no history creation and no held update; the scope statement corrected: root's proof and restart record readers are judged like the agent's; only `ClaimRootHTTP`'s claims-on readers stay slot-free), `internal/host/rootinventory_install.go:62` and `rootrecord_claim.go:40` (claims-on callers: their contexts carry no judge), `internal/host/runs.go` (`pickup` line 239: the detached execution context is `WithLifetimes(context.Background(), w.judge)`, so `execute`'s `SetRecordRun` calls are judged and sequenced), `internal/host/seedwiring.go` (`seedPlacedHook(deploy, data, judge)`: the callback does `ctx = provision.WithLifetimes(ctx, judge)` first; `wireSeedPlacement` passes `boxJudge`) |
| +- Modify: `internal/provision/reconcile.go` (the decode loop at lines 855–941 becomes **one sequence**: parse the key → `cfg.Judge.ObserveWith(ctx, s, acct, app, host, raw, etag, err)` (which decodes, path-validates and judges; a nil judge decodes and path-validates only) → the shape, host-shape, channel and scope checks on the returned observation's record; stale hosts are recorded in `stale map[string]bool`; **the removal loop**, claims-off (`cfg.Judge != nil`): lifetime validity and placement eligibility are **separate questions**. When `recs[rt.Host]` is absent or its key differs from `RecordKey(rt.Account, rt.App, rt.Host)`, the pass observes the row's own key (`cfg.Judge.Observe`): a present valid record **whose placement is in this pass's scope** (`cfg.inScope(..., everywhereClass(rec))`, the same test the listing applies: region, pool, fleet and serving class) → the **record-gone reason is defeated only**: the row still reaches today's independent app-trash decision and today's exemptions, so a trashed owner's row is removed as today (a legacy collision between two valid owners otherwise stays as today); a present valid record **outside the scope** (another region after a flip or a placement change, another pool) → **today's removal rules** apply unchanged (`KeepParticipant` keeps it while a move's view says so; the app-trash rule; otherwise removed), so an offline move's source row is kept while `offlineView.keep` says so and removed at finishing exactly as today; established absent or stale → "record gone" (a held row by an op its own record does not carry → `cfg.DeleteIfHold` first), and this evidence also bypasses `KeepParticipant` (the keep view keys by hostname and cannot vouch for a row whose own record is gone); unreadable → protected; the journal exemption and hand-managed rows untouched. Replacement by a cross-owner successor takes **two passes**: this pass retires the predecessor row, the next mints the successor (the minting loop runs before removal and must not mint over a row it does not own); the witness asserts convergence within two passes; `ListAllRecordsStrict` and `ListAllRecordsTolerant` gain judged siblings and `readAllRecords` takes the judge; `holdExisting` line 1716: `cfg.Judge.Observe`), `internal/provision/seedrollback.go` (`journalRecordByKey(ctx, s, j, judge)`: `Observe`; stale → `(nil, nil)` as a record that is gone; corrupt → the decode error as today; unreadable → the error), `internal/provision/seedreconcile.go` (`journalRecord` passes `cfg.Judge`; a stale journal record is "gone" to the journal's own rules, which already handle an absent record, and `known` stays true; unreadable keeps `known = false`), `internal/provision/provision.go` (`WithJudge`; `updateRecordSeeding`: when the context carries a judge, the read becomes `Observe` and a stale or absent observation returns `os.ErrNotExist` before `mut` runs, a corrupt one the decode error, an unreadable one its error; judged siblings of `GetRecordETag`, `ListRecordsStrict`, `ListRecordsComplete`, `ListRecords`), `internal/host/agent.go` (one `boxJudge *provision.LifetimeJudge`, built once in `provisionTickConfig`'s caller when `!hostwrite.Enabled(getenv)` and nil otherwise, set on `ReconcileConfig.Judge`, `moveCfg.Judge`, the offline view, the runs worker, the steering loop, retention, the seed hook and the proof binding source; every pass context on the box is `provision.WithLifetimes(ctx, boxJudge)`: the reconcile loop, the moves pass, the runs worker and its execution, the steering loop, retention, the status tick), `internal/host/movespass.go:1079`, `internal/host/movesoffline.go` (`refreshOfflineView`; `offlineReassert` line 420), `internal/host/movesoffline_stage.go` (75, 162), `internal/host/runs.go` (122: `ListAllRecordsStrictJudged` with today's strict error handling; 278), `internal/host/runexec.go` (318, 421), `internal/host/steering_loop.go` (100), `internal/host/retention.go` (`appRecords` line 308: `Observe`; stale → omitted; unreadable → the error, as every error is today), `internal/host/seedwiring.go` (`seedPlacedHook`: the ctx carries the judge, so `SetRecordSeed` itself refuses a stale record with `os.ErrNotExist`, which the hook returns; the conflict re-read at line 60 uses `ReadRecordJudged`), every other `provision.GetRecord`/`ListAllRecords` in `internal/host` (`movescut.go:102`, `moveslandcommit.go:73,197`, `moveshold.go:266`, `movesland.go:188`, `movesfinal.go:562,622,709`, `moveslandinstall.go:92`, `movespolicy.go:146`, `movesparks.go:62`: all become the judged readers with the box's judge; on a claims-on box the judge is nil and they behave as today, so no exemptions), `internal/console/server.go` (`s.judge`; **`ServeHTTP` at line 1683 wraps every request**: `r = r.WithContext(s.recordCtx(r.Context(), u))` after Task 0's `lifetimesAdmit` (the judge only for a unit admitted in `on`), so browser handlers (`placeAppTarget`'s callers in `fleets.go` and `appsettings.go` included) and the API alike carry the judge and sequencing; every pass iteration derives its context with `recordCtx(ctx, u)` from the unit it admitted (Task 0)), `internal/console/*.go` (the inventory below; `placeSeedBlock` and `importInFlight` take a `*provision.LifetimeJudge` parameter), `internal/provision/provision.go` (`claimRawUpdateRecord`: its `s.Get` re-read becomes `Observe` under the context's judge; stale → `os.ErrNotExist`, corrupt → the decode error, unreadable → the error, and the caller's ETag condition is kept), `internal/provision/rootrecord.go` (`IndexRecords` keeps **every** candidate key per hostname instead of marking two keys ambiguous; `RecordIndex.Find` and `FindRecordByHost` observe each candidate through `JudgeFrom(ctx)`: exactly one valid → it; none valid and none corrupt or unreadable → `os.ErrNotExist`; two or more valid → `os.ErrPermission` (genuine ambiguity); a corrupt or path-mismatched candidate → `ErrRecordCorrupt` (never absence; today's schema and shape validation still applies to the chosen record); an unreadable candidate → its error; with a nil judge, two candidates → `os.ErrPermission` as today; the production proof source's cached index caches keys, not verdicts), `internal/host/proofbinding.go` (`productionProofBindingSource` gains `judge *provision.LifetimeJudge`, set by its constructor from `!hostwrite.Enabled(getenv)` in **both** the agent and the root process (`runAdoptUnderRootRelease` and the restart paths, the retained-census configuration included); `findRecord` wraps whatever context it is given with `WithLifetimes(ctx, s.judge)` before `FindRecordByHost`/`index.Find`, so `proofObserver.observe`'s `context.Background()` is covered; **`rootRestartRequestSource`** (its `target` and `creation` readers) takes a `judge` in **every production construction**: `productionRestartRequestPass` (both callers in `agent.go`), `rootHistoryCreation` and `productionRootUpdatesHeld`, each given the box's judge by its caller and wrapping its contexts with `WithLifetimes`; `TestRootRestartReadersAreJudged` constructs through those three production functions and asserts a stale record yields no restart target, no history creation and no held update; the scope statement corrected: root's proof and restart record readers are judged like the agent's; only `ClaimRootHTTP`'s claims-on readers stay slot-free), `internal/host/rootinventory_install.go:62` and `rootrecord_claim.go:40` (claims-on callers: their contexts carry no judge), `internal/host/runs.go` (`pickup` line 239: the detached execution context is `WithLifetimes(context.Background(), w.judge)`, so `execute`'s `SetRecordRun` calls are judged and sequenced), `internal/host/seedwiring.go` (`seedPlacedHook(deploy, data, judge)`: the callback does `ctx = provision.WithLifetimes(ctx, judge)` first; `wireSeedPlacement` passes `boxJudge`) |
| - Test: `internal/provision/reconcile_lifetime_test.go`, `internal/provision/lifetime_judge_test.go`, `internal/provision/seedreconcile_lifetime_test.go`, `internal/console/lifetimes_read_test.go`, `internal/host/lifetime_gating_test.go` |
| **Interfaces:** |
| @@ -266,9 +267,9 @@ Recovery, per pending migration, inside the exclusion with `admit(host, OpID)`: |
| - `func ReadRecordJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app, host string) (*Record, string, error)`: stale, absent or a tombstone → `os.ErrNotExist`; corrupt → `ErrRecordCorrupt`; unreadable → the error. |
| - `func ListRecordsStrictJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, unreadable map[string]bool, err error)`; `func ListRecordsCompleteJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, truncated bool, skipped []string, err error)`; `func ListRecordsJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, truncated bool, err error)`; `func ListAllRecordsStrictJudged(ctx context.Context, s release.Store, j *LifetimeJudge) (recs []Record, unreadable map[string]bool, err error)`; `func ListAllRecordsTolerantJudged(ctx context.Context, s release.Store, j *LifetimeJudge) (recs []Record, complete bool, err error)`: a **stale** record is dropped silently; a valid one is replaced by its observation; an **unreadable** observation is that host unreadable (strict: the listing error; complete: `skipped`; tolerant: `complete = false`); a listed key whose body is **absent** by the time it is read keeps today's behaviour exactly (strict: the read error; complete: `skipped`; tolerant: `complete = false`), with or without a judge: absence is not staleness. |
| - `ReconcileConfig.Judge *LifetimeJudge`; `moveCfg.Judge *provision.LifetimeJudge`. |
| - - Console: `s.judge`; `func (s *Server) readRecord(ctx context.Context, acct, app, host string) (*provision.Record, string, error)`; `func (s *Server) observeRecord(ctx context.Context, acct, app, host string) provision.Observation`; `listRecords`, `listRecordsStrict`, `listRecordsComplete`, `listAllRecordsStrict`, `listAllRecordsTolerant` wrappers. Inventory to route: every instance-record read in `apiinstancemove.go`, `apiinstances.go`, `seedfinish.go`, `channelmove.go`, `apiruns.go`, `apiinstancemove_offline.go`, `apiinstanceimport.go`, `updatescore.go`, `movesrecovery.go`, `domainattach.go`, `appsettings.go`, `apirunsfollow.go`, `apiretry.go`, `apidb.go`, `apex.go`, `apidomains.go`, `apiinstanceseed.go`, `apilegacyroutes.go`, `relayops.go`, `seedsweep.go`, `server.go`, `regiondrift.go`, `runsweep.go`, `seedmove.go`. The audit `TestNoUnjudgedRecordReads` parses `internal/console`, `internal/host` and `internal/provision` (non-test) with `go/ast` and `go/types`. **Forbidden callees** (raw readers): `provision.GetRecord`, `GetRecordETag`, `GetRecordRaw`, `ListRecords`, `ListRecordsStrict`, `ListRecordsComplete`, `ListAllRecords`, `ListAllRecordsStrict`, `ListAllRecordsTolerant`, `release.Store.Get` with a `RecordKey(...)` argument, and `DecodeRecord` on bytes read in the same function from the store. **Approved callees** (judged wrappers, allowed anywhere): the `*Judged` listings, `ReadRecordJudged`, `Observe`, `ObserveWith`, `FindRecordByHost`, `(*RecordIndex).Find`. The forbidden set also includes `hostops.ReadRecord`. **Permitted enclosing functions** for the forbidden set, by resolved symbol: `provision.ReadRecordJudged`, `(*LifetimeJudge).Observe`, `(*LifetimeJudge).ObserveWith`, the five `List*Judged`, `provision.FindRecordByHost`, `(*RecordIndex).Find`, `provision.updateRecordSeeding`, `provision.claimRawUpdateRecord`, `provision.WriteRecord`, `provision.TombstoneBody`, `provision.GetRecordRaw` (the raw primitive the judge uses), `(*console.Server).fenceRecord`, `(*console.Server).quarantineCopy`, and the claims-on functions that decode a body captured in an ETag-bound claim observation or run only under `cfg.HostClaim`: `(*console.ClaimPlacementCoordinator).Resume`, `(*console.Server).movesRecovery`, `(*console.Server).adoptHostRecord`, `(*console.Server).refreshClaimRetireRecord`, `host.relocationRecord` (the `hostops.ReadRecord` callers today, pinned: the audit test lists them and fails on any new `hostops.ReadRecord` caller, and a fixture claims-off call is shown to fail), and the legacy unjudged getters themselves (`GetRecord`, `GetRecordETag`, the unjudged listings) which delegate to `GetRecordRaw`/`getEach`. `hostops` is outside the three audited packages and is claims-on. Reconcile's decode moves into `ObserveWith`. No other exemptions. |
| + - Console: `s.judge`; `func (s *Server) readRecord(ctx context.Context, acct, app, host string) (*provision.Record, string, error)`; `func (s *Server) observeRecord(ctx context.Context, acct, app, host string) provision.Observation`; `listRecords`, `listRecordsStrict`, `listRecordsComplete`, `listAllRecordsStrict`, `listAllRecordsTolerant` wrappers. Inventory to route: every instance-record read in `apiinstancemove.go`, `apiinstances.go`, `seedfinish.go`, `channelmove.go`, `apiruns.go`, `apiinstancemove_offline.go`, `apiinstanceimport.go`, `updatescore.go`, `movesrecovery.go`, `domainattach.go`, `appsettings.go`, `apirunsfollow.go`, `apiretry.go`, `apidb.go`, `apex.go`, `apidomains.go`, `apiinstanceseed.go`, `apilegacyroutes.go`, `relayops.go`, `seedsweep.go`, `server.go`, `regiondrift.go`, `runsweep.go`, `seedmove.go`. The audit `TestNoUnjudgedRecordReads` parses `internal/console`, `internal/host` and `internal/provision` (non-test) with `go/ast` and `go/types`. **Forbidden callees** (raw readers): `provision.GetRecord`, `GetRecordETag`, `GetRecordRaw`, `ListRecords`, `ListRecordsStrict`, `ListRecordsComplete`, `ListAllRecords`, `ListAllRecordsStrict`, `ListAllRecordsTolerant`, `release.Store.Get` with a `RecordKey(...)` argument, and `DecodeRecord` on bytes read in the same function from the store. **Approved callees** (judged wrappers, allowed anywhere): the `*Judged` listings, `ReadRecordJudged`, `Observe`, `ObserveWith`, `FindRecordByHost`, `(*RecordIndex).Find`. The forbidden set also includes `hostops.ReadRecord`. **Permitted enclosing functions** for the forbidden set, by resolved symbol: `provision.ReadRecordJudged`, `(*LifetimeJudge).Observe`, `(*LifetimeJudge).ObserveWith`, the five `List*Judged`, `provision.FindRecordByHost`, `(*RecordIndex).Find`, `provision.updateRecordSeeding`, `provision.claimRawUpdateRecord`, `provision.WriteRecord`, `provision.TombstoneBody`, `provision.GetRecordRaw` (the raw primitive the judge uses), `(*console.Server).fenceRecord`, `(*console.Server).quarantineCopy`, and the claims-on functions that decode a body captured in an ETag-bound claim observation or run only under `cfg.HostClaim`: `(*console.ClaimPlacementCoordinator).Resume`, `(*console.Server).movesRecovery`, `(*console.Server).adoptHostRecord`, `(*console.Server).refreshClaimRetireRecord`, `host.relocationRecord` (the `hostops.ReadRecord` callers today), `(*console.Server).claimAliasUnbacked` (a raw `Releases.Get` of a record key on the claims-on alias path, allowed as claims-on and pinned: a claims-off copy of the same shape fails the audit) (the allowlist, pinned: the audit test lists them and fails on any new `hostops.ReadRecord` caller, and a fixture claims-off call is shown to fail), and the legacy unjudged getters themselves (`GetRecord`, `GetRecordETag`, the unjudged listings) which delegate to `GetRecordRaw`/`getEach`. `hostops` is outside the three audited packages and is claims-on. Reconcile's decode moves into `ObserveWith`. No other exemptions. |
| -- [ ] **Step 1: Write the failing tests**: `TestJudgeReadsRecordThenSlot`; `TestJudgeAcrossConcurrentLifetimeChanges` (each subtest asserts `Present`, `Raw`, `ETag` and `SlotETag`, not only the verdict); `TestNilJudgeIsClaimsOn` (absence, a corrupt body and a read error behave exactly as the unjudged reader); `TestUpdateRecordRefusesAStaleRecordUnderAJudge` (`SetRecordSeed`, `SetRecordRegion` and `SetRecordMove` with `WithJudge` against a stale record → `os.ErrNotExist`, no write; without a judge → today); `TestReconcileRemovesARecordBehindItsSlot`; `TestReconcileRemovesATombstone`; `TestReconcileKeepsALegacyRecordWithNoSlot`; `TestReconcileKeepsAMarkedRecordUnderItsMigrationSlot`; `TestReconcileProtectsWhenTheSlotIsUnreadable`; `TestReconcileUsesTheObservation`; `TestStaleOverridesAPopulatedKeepView`; `TestImportJournalIgnoresAStaleRecord` (a real import journal in `swapped` beside a stale committed record: the gate is not opened, no swap replay; a stale `ready` record likewise; an unreadable observation leaves the import alone as today); `TestEveryClaimsOffConsumerIgnoresAFieldlessRecordBesideASlot` (slot `{1, retired}`, a fieldless record carrying an offline move, a queued run and a placeable seed: `ReadRecordJudged` not found; the five judged listings omit it; the console GET answers 404; the browser placement handler and `placeAppTarget` refuse to mutate it; reconcile mints nothing and retires its row; the edges file stops listing it; `offlineReassert`, the staging deletion gate, the runs boot pass, pickup **and the real pickup-to-execution path's `SetRecordRun`**, `launchable`, the steering loop's desired state, retention's app records all ignore it; **the production proof binding source, constructed as the agent and as the root process construct it (host claims off, with and without a retained census), invoked with `context.Background()`, yields no proof evidence from it**; the seed callback installed by the real `wireSeedPlacement`, invoked with a plain context, refuses and the seed snapshot is **not** deleted; `claimRawUpdateRecord` under a judged context refuses it); `TestListedKeyAbsenceIsNotStaleness` (list, delete, read: strict errors, complete skips, tolerant reports incomplete, with and without a judge); `TestCrossOwnerRecreationWithdrawsThePredecessorRow` (two apps, two boxes; box B misses A's retirement; C recreates the hostname at incarnation 2; B retires A's row on its next pass (held and unheld variants; A's record physically absent, and present-but-stale; `KeepParticipant` returning true) and mints C's on the following pass; two valid legacy owners for one hostname with the row belonging to the non-selected key → the row is kept as today; the journal exemption and a hand-managed row are untouched); `TestOutOfScopeValidRecordFollowsTodaysRemoval` (a valid record moved to another region, and one moved to another pool: the row is removed as today; with `KeepParticipant` true it is kept; the app-trash rule still removes); `TestOfflineFinishingRemovesTheSourceRow` (through `reconcileSerializedWith`: the source row kept during the move, removed once the record reaches `offline-finishing` and `offlineView.keep` stops; `offlineSourceGone` reports gone; the full `TestOfflineMoveE2E` finishing assertions intact); `TestIndexFindJudgesCandidates` (a stale key beside a valid successor key → the successor; two valid legacy candidates → `ErrPermission`; through the production proof source's cached index); `TestClaimsOnConsumersReadNoSlot`; `TestBoxJudgeIsSharedAndGated`; `TestNoUnjudgedRecordReads`. |
| +- [ ] **Step 1: Write the failing tests**: `TestJudgeReadsRecordThenSlot`; `TestJudgeAcrossConcurrentLifetimeChanges` (each subtest asserts `Present`, `Raw`, `ETag` and `SlotETag`, not only the verdict); `TestNilJudgeIsClaimsOn` (absence, a corrupt body and a read error behave exactly as the unjudged reader); `TestUpdateRecordRefusesAStaleRecordUnderAJudge` (`SetRecordSeed`, `SetRecordRegion` and `SetRecordMove` with `WithJudge` against a stale record → `os.ErrNotExist`, no write; without a judge → today); `TestReconcileRemovesARecordBehindItsSlot`; `TestReconcileRemovesATombstone`; `TestReconcileKeepsALegacyRecordWithNoSlot`; `TestReconcileKeepsAMarkedRecordUnderItsMigrationSlot`; `TestReconcileProtectsWhenTheSlotIsUnreadable`; `TestReconcileUsesTheObservation`; `TestStaleOverridesAPopulatedKeepView`; `TestImportJournalIgnoresAStaleRecord` (a real import journal in `swapped` beside a stale committed record: the gate is not opened, no swap replay; a stale `ready` record likewise; an unreadable observation leaves the import alone as today); `TestEveryClaimsOffConsumerIgnoresAFieldlessRecordBesideASlot` (slot `{1, retired}`, a fieldless record carrying an offline move, a queued run and a placeable seed: `ReadRecordJudged` not found; the five judged listings omit it; the console GET answers 404; the browser placement handler and `placeAppTarget` refuse to mutate it; reconcile mints nothing and retires its row; the edges file stops listing it; `offlineReassert`, the staging deletion gate, the runs boot pass, pickup **and the real pickup-to-execution path's `SetRecordRun`**, `launchable`, the steering loop's desired state, retention's app records all ignore it; **the production proof binding source, constructed as the agent and as the root process construct it (host claims off, with and without a retained census), invoked with `context.Background()`, yields no proof evidence from it**; the seed callback installed by the real `wireSeedPlacement`, invoked with a plain context, refuses and the seed snapshot is **not** deleted; `claimRawUpdateRecord` under a judged context refuses it); `TestListedKeyAbsenceIsNotStaleness` (list, delete, read: strict errors, complete skips, tolerant reports incomplete, with and without a judge); `TestCrossOwnerRecreationWithdrawsThePredecessorRow` (two apps, two boxes; box B misses A's retirement; C recreates the hostname at incarnation 2; B retires A's row on its next pass (held and unheld variants; A's record physically absent, and present-but-stale; `KeepParticipant` returning true) and mints C's on the following pass; two valid legacy owners for one hostname with the row belonging to the non-selected key → the row is kept as today; the journal exemption and a hand-managed row are untouched); `TestTrashedOwnerCollisionIsRemoved` (two valid legacy records collide on H, `recs[H]` selects B, the row belongs to A, A is trashed → the row is removed as today); `TestOutOfScopeValidRecordFollowsTodaysRemoval` (a valid record moved to another region, and one moved to another pool: the row is removed as today; with `KeepParticipant` true it is kept; the app-trash rule still removes); `TestOfflineFinishingRemovesTheSourceRow` (through `reconcileSerializedWith`: the source row kept during the move, removed once the record reaches `offline-finishing` and `offlineView.keep` stops; `offlineSourceGone` reports gone; the full `TestOfflineMoveE2E` finishing assertions intact); `TestIndexFindJudgesCandidates` (a stale key beside a valid successor key → the successor; two valid legacy candidates → `ErrPermission`; through the production proof source's cached index); `TestClaimsOnConsumersReadNoSlot`; `TestBoxJudgeIsSharedAndGated`; `TestNoUnjudgedRecordReads`. |
| - [ ] **Step 2: Run to verify they fail.** |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run** the touched packages' targeted tests, then the two census tests and add the rows they name. |
| @@ -356,6 +357,6 @@ These are §11's witnesses plan 1 owns. Every witness that needs a holding publi |
| ## Self-review notes |
| - §6.4 coverage: slot, owner key and tombstone form (T1, T4), create and recovery (T3), every claims-off removal with an explicit expectation and its recovery (T4), migration, its fence and recovery (T5), the observation protocol in every claims-off consumer (T1, T6), `move_seq`, the setters that install a lifetime with its hold and the existing-row rules (T7), publication by the lifetimes writer and §6.4's fold order (T8), witnesses (T9). Deferred by name: holding entries and their five-minute validity, the probe mode, the different-target alias rule and every holding-dependent witness (plan 3); the door, token, job, reservations and reports (plan 2); cleanup (plan 4). The stated additions to §6.4's text are the uncached slot read and the cutover (an operator step with an admission gate, a rollout prerequisite); the fold rule is §6.4's own. |
| -- Names used across tasks: `LifetimeOf`, `LifetimeVerdict`, `HostIncarnation.Valid`, `ErrSlotMalformed`, `ErrRecordCorrupt`, `ReadHostIncarnation`, `WriteHostIncarnation`, `TombstoneBody`, `GetRecordRaw`, `Observation{Present, Rec, Raw, ETag, Slot, SlotETag, Verdict, Err}`, `LifetimeJudge.Observe/ObserveWith`, `ReadRecordJudged`, `List*Judged`, `withHostname`, `admit`, `classifyLocked`, `createRecordWithLifetime`, `retireRecordWithLifetime`, `provision.RetireExpectation` (`retireExpectAbsent`, `retireAny`, `retireExpectETag`, `retireExpectIncarnation`), `Retirement.Kind` (`normal`, `orphan`, `cleanup`), `WithJudge`, `JudgeFrom`, `ErrLifetimeExhausted`, `fenceRecord`/`fenceResult`/`fenceOwner`, `quarantineCopy`, `ConvertRetirementKind`, `cutoverMarker`, `cutoverState` (`cutoverUnknown/Before/Draining/On`), `cutoverGate`, `admittedUnit`, `lifetimesAdmit`, `recordCtx`, `activateWhenDrained`, `cutoverStatus`, `StartLifetimesCutoverWatch`, `handleAPILifetimesCutover`, `SystemLifetimesCutover`, `retireSlotLocked`, `slotDisposition`, `migrationDispatch`, `WithLifetimes`, `WithRecordSequence` (existing), `createRecordWithLifetimeLocked`, `retireRecordWithLifetimeLocked`, `attachFirstRouteLocked`, `rollbackAttachLocked`, `detachFirstRouteLocked`, `lifetimeSettle`, `migrateHostLifetimeLocked`, `errLifetimeSettling`, `errLifetimePending`, `*hostHeldError`, `errRetireRefused`, `ErrRecordMigrating`, `WithMigration`, `ReconcileConfig.Judge/PairOf/DeleteIfHold`, `moveCfg.Judge/ReleaseHoldSeq`, `releaseHeldSeq`, `registry.Pair`, `SetHoldIfSeq`, `ReleaseHoldSeq`, `InstallLifetimeIf`, `Route.Incarnation/ServingSeq`, `MoveState.Seq`, `edgeHost.Incarnation/ServingSeq/TargetHost`. |
| +- Names used across tasks: `LifetimeOf`, `LifetimeVerdict`, `HostIncarnation.Valid`, `ErrSlotMalformed`, `ErrRecordCorrupt`, `ReadHostIncarnation`, `WriteHostIncarnation`, `TombstoneBody`, `GetRecordRaw`, `Observation{Present, Rec, Raw, ETag, Slot, SlotETag, Verdict, Err}`, `LifetimeJudge.Observe/ObserveWith`, `ReadRecordJudged`, `List*Judged`, `withHostname`, `admit`, `classifyLocked`, `createRecordWithLifetime`, `retireRecordWithLifetime`, `provision.RetireExpectation` (`retireExpectAbsent`, `retireAny`, `retireExpectETag`, `retireExpectIncarnation`), `Retirement.Kind` (`normal`, `orphan`, `cleanup`), `WithJudge`, `JudgeFrom`, `ErrLifetimeExhausted`, `fenceRecord`/`fenceResult`/`fenceOwner`, `provision.RetireRecordWithLifetime`, `purgeApp(..., retire)`, `quarantineCopy`, `ConvertRetirementKind`, `cutoverMarker`, `cutoverState` (`cutoverUnknown/Before/Draining/On`), `cutoverGate` (`markerSeen`, `lastDone`), `admittedUnit`, `lifetimesAdmit`, `recordCtx`, `activateWhenDrained`, `cutoverStatus`, `StartLifetimesCutoverWatch`, `handleAPILifetimesCutover`, `SystemLifetimesCutover`, `retireSlotLocked`, `slotDisposition`, `migrationDispatch`, `WithLifetimes`, `WithRecordSequence` (existing), `createRecordWithLifetimeLocked`, `retireRecordWithLifetimeLocked`, `attachFirstRouteLocked`, `rollbackAttachLocked`, `detachFirstRouteLocked`, `lifetimeSettle`, `migrateHostLifetimeLocked`, `errLifetimeSettling`, `errLifetimePending`, `*hostHeldError`, `errRetireRefused`, `ErrRecordMigrating`, `WithMigration`, `ReconcileConfig.Judge/PairOf/DeleteIfHold`, `moveCfg.Judge/ReleaseHoldSeq`, `releaseHeldSeq`, `registry.Pair`, `SetHoldIfSeq`, `ReleaseHoldSeq`, `InstallLifetimeIf`, `Route.Incarnation/ServingSeq`, `MoveState.Seq`, `edgeHost.Incarnation/ServingSeq/TargetHost`. |
| - Review Focus 1 to 5 are pinned by T3+T5, T6, T6+T3, T7, T7+T9. |
| - Store primitives, as verified in the reviews: `Store.Put(..., "")` is create-only on both backends; `WriteRecord` with `before={}` and `etag=""` is the create and needs the real CAS base otherwise; `DeleteIfMatch` treats absence as success and has no unconditional fallback; `OfflineAbort` on a reverting record returns `errRecordUnchanged`, a success without a write; `AccountBySqid` returns `(nil, nil)` for an unknown sqid. |
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 1e8dc93f5..d64b18746 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,7 +2,7 @@ |
| > **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 5, 2026-10-22. Rev 4's review (`selfserve-plan3-review4.md`) is answered: the ready stage **calls the shared fold** and no longer restates it (F2); the invariants are scoped to the four stages and (e), (g), (h), (j) are qualified (F1); the holding stage is a **sorted fold** with a pinned three-hold witness (N2); hold rebuilds read the latest view snapshot and the registry under one mutex and are triggered by both the view and `Reload`, and the view publishes under its own mutex (F5); the dispatch exceptions for accepted hops, door requests and a failed backhaul over a local wildcard are preserved and tested (N1); the home map's wake compares the effective parent set including platform holds (N3); local ready aliases are never platform, `candCert` is constructed explicitly, the unlabelled-box refusal is kept, and the view snapshot crosses packages as an exported `edge.HoldSnapshot` (F4); `AliasPublishable` checks `a.Alias` (F7); the census overlay loads in `plan14_root_reads_test.go` before `compareRootReads` (F10). Reviews: rev 1 (`after236/selfserve-plan3-review.md`, 15 findings), rev 2 (`selfserve-plan3-review2.md`, 14), rev 3 (`selfserve-plan3-review3.md`, F1 to F10). Rev 4 answers rev 3's ten and is self-contained (nothing is "as rev 2"; F8). The ranking is restated so that it **cannot** disagree with plan 1's fold (F1, F2): a holding candidate's only power over ready candidates is to **suppress** the stale ready publications of its own target (same `targetHost`, unstamped, lower pair); the ready candidates that survive are folded by the **same function** `foldHomes` uses, so ready-only routing is plan 1's by construction and a differential test pins it; only when no ready candidate survives does a hold answer. The invariants are restated to be consistent with that algorithm and with §6.4 ("eligible under ordinary routing" = not a suppressed stale publication of the moving target); the "real hold beats a stamped alias" test is withdrawn as contradicting §6.4. The decision carries the whole local table it was resolved against (F3). Candidate construction is complete for local ready, local holding (aliases expanded through the writer's own predicate) and remote, with the existing admissions kept (F4). Local holds live in their own atomic snapshot that `Reload` never touches, and the view publishes revision and contents in one immutable value (F5). `Allowed` keeps the tunnel exception; a separate certificate-name enumeration covers holds for warming without making them served evidence (F6). `AliasPublishable` includes the primary, the name exclusions, and no owner parameter (F7). The countdown arithmetic and the inherited contracts are restated in full (F8). The discovery check includes `internal/steering` and a reviewed exclusion list (F9). The census names `internal/edgesfile` and the loader (F10). |
| +Rev 6, 2026-10-22. Rev 5's review (`selfserve-plan3-review5.md`) is answered: the kept dispatch exceptions are an **explicit step** over a local wildcard fallback captured in the decision with its table (N1), and the Global Constraint says what blocks the fallback (a live exact holding winner, not any hold); invariant (e) says "stamped, or not lower than any live same-target hold" (F1); an unlabelled box keeps today's split, refusing to **dial** while `KnownHost`, `Hosts`, `HoldingHosts` and `certNames` still see admitted remote candidates (N4); subscription to the holding view is one operation under `pubMu` that installs the callback and delivers the current snapshot (N5). Rev 4's review (`selfserve-plan3-review4.md`) is answered: the ready stage **calls the shared fold** and no longer restates it (F2); the invariants are scoped to the four stages and (e), (g), (h), (j) are qualified (F1); the holding stage is a **sorted fold** with a pinned three-hold witness (N2); hold rebuilds read the latest view snapshot and the registry under one mutex and are triggered by both the view and `Reload`, and the view publishes under its own mutex (F5); the dispatch exceptions for accepted hops, door requests and a failed backhaul over a local wildcard are preserved and tested (N1); the home map's wake compares the effective parent set including platform holds (N3); local ready aliases are never platform, `candCert` is constructed explicitly, the unlabelled-box refusal is kept, and the view snapshot crosses packages as an exported `edge.HoldSnapshot` (F4); `AliasPublishable` checks `a.Alias` (F7); the census overlay loads in `plan14_root_reads_test.go` before `compareRootReads` (F10). Reviews: rev 1 (`after236/selfserve-plan3-review.md`, 15 findings), rev 2 (`selfserve-plan3-review2.md`, 14), rev 3 (`selfserve-plan3-review3.md`, F1 to F10). Rev 4 answers rev 3's ten and is self-contained (nothing is "as rev 2"; F8). The ranking is restated so that it **cannot** disagree with plan 1's fold (F1, F2): a holding candidate's only power over ready candidates is to **suppress** the stale ready publications of its own target (same `targetHost`, unstamped, lower pair); the ready candidates that survive are folded by the **same function** `foldHomes` uses, so ready-only routing is plan 1's by construction and a differential test pins it; only when no ready candidate survives does a hold answer. The invariants are restated to be consistent with that algorithm and with §6.4 ("eligible under ordinary routing" = not a suppressed stale publication of the moving target); the "real hold beats a stamped alias" test is withdrawn as contradicting §6.4. The decision carries the whole local table it was resolved against (F3). Candidate construction is complete for local ready, local holding (aliases expanded through the writer's own predicate) and remote, with the existing admissions kept (F4). Local holds live in their own atomic snapshot that `Reload` never touches, and the view publishes revision and contents in one immutable value (F5). `Allowed` keeps the tunnel exception; a separate certificate-name enumeration covers holds for warming without making them served evidence (F6). `AliasPublishable` includes the primary, the name exclusions, and no owner parameter (F7). The countdown arithmetic and the inherited contracts are restated in full (F8). The discovery check includes `internal/steering` and a reviewed exclusion list (F9). The census names `internal/edgesfile` and the loader (F10). |
| Builds on plan 1 (rev 10: `(incarnation, serving_seq)` on rows and publications, `target_host` on alias publications, the fold's pair rule in `dialBeats`/`foldHomes`, `edgesFileWriteClaims(..., lifetimes bool)`, the cutover gate) and plan 2 (rev 4: `SelfServeMove{DisplayName, OfflineAt, EstimateSeconds}`, `Participants`, `moveCfg.BoxID`, `probeTarget`). |
| @@ -19,7 +19,7 @@ Builds on plan 1 (rev 10: `(incarnation, serving_seq)` on rows and publications, |
| - `offline_moves` is never a reason to count a host as served (Task 4's inventory). The claims-on writer publishes no `offline_moves`. |
| - A remote holding candidate is believed only while `now.Sub(fetchedAt) < holdingValidity` (5 min; at exactly 5 m 0 s it is expired); expiry is applied at ranking time from the retained candidates, never by store I/O; the answer after expiry is what the remaining candidates give, including the wildcard fallback. |
| - **Exact resolution and request resolution are different questions.** `Allowed`, `KnownHost` and `ExactHome` use exact candidates only and reject `*.` names as today; request resolution tries the exact name and falls back to the wildcard parent only when the exact name has no **effective** routing candidate (a live exact holding winner blocks the fallback). |
| -- **Ready-only routing is plan 1's fold.** The remote ready stage **calls** the shared fold (`foldHomes`'s loop over `dialBeats`, with plan 1 Task 8's pair rule: stamps first, pairs only when both entries are unstamped and share a non-empty target); the plan never restates that rule. With no holding candidate in play, `rank` returns exactly what the fold plus the local-first rule return today (Task 3's differential test); local ready candidates win over remote ones as today (the upstream table is consulted first). The dispatch exceptions of today's `ServeHTTP` are kept: an accepted hop or a door request whose exact name has no local candidate but a local wildcard upstream is served from that wildcard, whatever the remote candidates say; a failed `backhaulTo` (no mesh key) falls through to the local wildcard as today. |
| +- **Ready-only routing is plan 1's fold.** The remote ready stage **calls** the shared fold (`foldHomes`'s loop over `dialBeats`, with plan 1 Task 8's pair rule: stamps first, pairs only when both entries are unstamped and share a non-empty target); the plan never restates that rule. With no holding candidate in play, `rank` returns exactly what the fold plus the local-first rule return today (Task 3's differential test); local ready candidates win over remote ones as today (the upstream table is consulted first). The dispatch exceptions of today's `ServeHTTP` are kept as an explicit step (Task 2's `fallback`): when the exact name has no local candidate but a local wildcard upstream exists and the exact decision is `decRemote`, an accepted hop or a door request is served from that wildcard, and an ordinary request whose `backhaulTo` returns false for want of a signing key falls through to it (saturation and proxy failures are answered remotely, as today). What **blocks** the fallback is a **live exact holding winner** for the name (the exact decision is `decHolding`); a hold that lost to a surviving ready candidate blocks nothing. |
| - Local ready candidates are the entries of the upstream table `Reload` builds (`e.current()`): its eligibility (ready status, a usable backend, no sidecars, aliases whenever their primary has an upstream, shadowing) is unchanged. DNS pointing gates **publication** of aliases, not local service. |
| - Explicit aliases only in holding publications: the alias row is `Explicit` and `AliasPublishable` (below). |
| - The display name is the only request-supplied text on the page, rendered through `html/template`; no other request text (URL, headers, host) appears in the body. |
| @@ -49,7 +49,7 @@ Builds on plan 1 (rev 10: `(incarnation, serving_seq)` on rows and publications, |
| - `type holdingEntry struct { Host string \`json:"host"\`; Platform bool \`json:"platform,omitempty"\`; Account string \`json:"account,omitempty"\`; App string \`json:"app,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"\` }` (`Account`/`App` from the row's owner projection, possibly empty). |
| - `type holdingSource func(host string) (provision.MoveState, bool)` = `offlineView.holding`. |
| - **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, Platform: r.Platform || r.Provisioned, Account/App: the owner projection of r, Aliases, MoveID: mv.ID, Incarnation: r.Incarnation, ServingSeq: r.ServingSeq, OfflineAt: mv.SelfServe.OfflineAt.Unix() (0 when `SelfServe == nil` or the time is zero), EstimateSeconds: mv.SelfServe.EstimateSeconds (0 when nil), DisplayName: mv.SelfServe.DisplayName ("" when nil)}`; `Aliases` = the `route_aliases` rows with `a.Host == r.Host && a.Explicit && AliasPublishable(a, true, shadowedByServed(a.Alias))`, sorted. **Stop conditions:** a ready row publishes none; a held row whose `HoldOp` the view does not know (no move, or a move with another ID) publishes none; the entry stops when the record no longer carries that move id (the view) or the row turns ready. The target's `landing` row publishes from flip until `ReleaseHoldSeq`. Entries sorted by host. Only in lifetimes mode (`lifetimes == true`); region HA on or off makes no difference. |
| -- **The view's snapshot (F5):** `type offlineHolds struct { Rev uint64; Holds map[string]provision.MoveState; Failed bool }` immutable once published; publication (`refresh` and `fail`) runs under `offlineView.pubMu`, so revisions are allocated and stored in one critical section (no two publishers share a revision); `offlineView.snapshot() *offlineHolds` (atomic pointer load); `refresh` publishes `{Rev: prev+1, Holds: new, Failed: false}`; `fail` publishes `{Rev: prev+1, Holds: nil, Failed: true}`; `holding(host)` reads the current snapshot; `offlineView.onChange func(edge.HoldSnapshot)` is called after every publication, inside `pubMu`, with the **adapter** of the snapshot it published: `edge.HoldSnapshot{Rev, Failed, Holds: map[host]edge.HoldState{MoveID, DisplayName, OfflineAt (unix seconds), EstimateSeconds}}` (an exported DTO declared in `internal/edge`; `internal/host` already depends on `internal/edge`, never the reverse). |
| +- **The view's snapshot (F5):** `type offlineHolds struct { Rev uint64; Holds map[string]provision.MoveState; Failed bool }` immutable once published; publication (`refresh` and `fail`) runs under `offlineView.pubMu`, so revisions are allocated and stored in one critical section (no two publishers share a revision); `offlineView.snapshot() *offlineHolds` (atomic pointer load); `refresh` publishes `{Rev: prev+1, Holds: new, Failed: false}`; `fail` publishes `{Rev: prev+1, Holds: nil, Failed: true}`; `holding(host)` reads the current snapshot; `offlineView.Subscribe(fn func(edge.HoldSnapshot))` installs `onChange` under `pubMu` and delivers the current snapshot first; `onChange` is called after every publication, inside `pubMu`, with the **adapter** of the snapshot it published: `edge.HoldSnapshot{Rev, Failed, Holds: map[host]edge.HoldState{MoveID, DisplayName, OfflineAt (unix seconds), EstimateSeconds}}` (an exported DTO declared in `internal/edge`; `internal/host` already depends on `internal/edge`, never the reverse). |
| - [ ] **Step 1: Write the failing tests**: `TestAliasReconcilerWritesProvenance` (a claim-backed alias → explicit; a derived instance alias → not; a legacy row upgraded); `TestDerivedAliasClearsExplicitOnTheSameTarget` (a detached explicit claim whose name is then derived to the same target → `Explicit: false`, the fingerprint changes, the next holding publication omits it); `TestAliasFingerprintIncludesExplicit`; `TestAliasPublishable` (absent primary → false; `*.` name → false; empty name → false; not pointing → false; shadowed → false; pointing with an empty owner projection → true); `TestWriterPublishesHoldingEntriesOnlyInLifetimesMode` (lifetimes on with region HA off and on → entries; lifetimes off with either → none; `applyEdgesClaims` still runs before the served alias construction); `TestHoldingEntryListsOnlyExplicitPublishableAliases` (explicit pointing → listed; explicit not pointing → not; derived exact name → not; shadowed by a served row → not; the synthetic catch-all row → not; a mixed-app prefix host → listed with empty `Account/App`); `TestTargetPublishesWhileLanding`; `TestHoldingEntryStopsWhenTheRecordClearsOrChangesID`; `TestViewSnapshotIsImmutableAndRevisioned` (refresh then fail then refresh → revs 1, 2, 3; `onChange` receives each published value; a reader holding snapshot 1 still sees snapshot 1's holds; the moves pass's listing error publishes a failed snapshot). |
| - [ ] **Step 2: Run to verify they fail; Step 3: implement; Step 4: run** `go test ./internal/host -run 'Holding|ViewSnapshot' -count=1`, `go test ./internal/domains -run 'Explicit|Provenance' -count=1`, `go test ./internal/registry -run 'Alias' -count=1`, `go test ./internal/edgesfile -run 'AliasPublishable' -count=1`. |
| @@ -58,7 +58,7 @@ Builds on plan 1 (rev 10: `(incarnation, serving_seq)` on rows and publications, |
| ### Task 2: Candidates, the ranking, the page, and the local edge |
| **Files:** |
| -- Modify: `internal/edge/homemap.go` (`edgesFileView.OfflineMoves []holdingView \`json:"offline_moves"\`` and `fetchedAt time.Time`; `homeSnapshot.cands map[string][]candidate`; `foldHomes` is **kept as the ready-only fold** and gains the pair rule from plan 1; `buildCandidates(files, self, privateDial) map[string][]candidate` runs the same per-file admission loop; `rank`, `resolveExact`, `resolveRequest`; `Home`, `ExactHome`, `Owner`, `KnownHost`, `Hosts`, `ServedHosts` over the exact resolution; `HoldingHosts()` new), `internal/edge/holding.go` (new: `HoldSnapshot`, `HoldState`, `renderHoldingPage`, `holdingAnswer`, `holdSnapshot`, `SetHoldView`, `RefreshHolds`), `internal/edge/edge.go` (`resolve`, the request paths, `Allowed`, `certNames`), `internal/edge/certsync.go`, `internal/edge/certwarm.go` (`warmSet` adds `certNames()`), `internal/edge/wildcard.go` (exact predicates; `currentHosts` adds held platform rows), the agent (`view.onChange = edge.SetHoldView` (which stores the snapshot and calls `RefreshHolds()`), `edge.WithBoxID(boxID)` where `boxID` is the same value plan 2 gives `moveCfg.BoxID`) |
| +- Modify: `internal/edge/homemap.go` (`edgesFileView.OfflineMoves []holdingView \`json:"offline_moves"\`` and `fetchedAt time.Time`; `homeSnapshot.cands map[string][]candidate`; `foldHomes` is **kept as the ready-only fold** and gains the pair rule from plan 1; `buildCandidates(files, self, privateDial) map[string][]candidate` runs the same per-file admission loop; `rank`, `resolveExact`, `resolveRequest`; `Home`, `ExactHome`, `Owner`, `KnownHost`, `Hosts`, `ServedHosts` over the exact resolution; `HoldingHosts()` new), `internal/edge/holding.go` (new: `HoldSnapshot`, `HoldState`, `renderHoldingPage`, `holdingAnswer`, `holdSnapshot`, `SetHoldView`, `RefreshHolds`), `internal/edge/edge.go` (`resolve`, the request paths, `Allowed`, `certNames`), `internal/edge/certsync.go`, `internal/edge/certwarm.go` (`warmSet` adds `certNames()`), `internal/edge/wildcard.go` (exact predicates; `currentHosts` adds held platform rows), the agent (`view.Subscribe(edge.SetHoldView)`: under `pubMu`, installs the callback **and delivers the current snapshot** (when one has been published) before releasing, so a publication before registration is seen and one racing registration is delivered exactly once in revision order; `SetHoldView` stores the snapshot and calls `RefreshHolds()`; `edge.WithBoxID(boxID)` where `boxID` is the same value plan 2 gives `moveCfg.BoxID`) |
| - Test: `internal/edge/rank_test.go`, `internal/edge/holding_test.go`, `internal/edge/homemap_test.go`, `internal/edge/decision_test.go` |
| **Interfaces:** |
| @@ -66,21 +66,21 @@ Builds on plan 1 (rev 10: `(incarnation, serving_seq)` on rows and publications, |
| - `type candidate struct { kind candKind (candReady, candHolding, candCert); local bool; order int (the file's sorted index; local is -1); eip, box string; account, app string; alias, wildcard, platform bool; targetHost string; incarnation, servingSeq uint64; stamp int64; fetchedAt time.Time; hold *holdingView; up *upstream (local only); table map[string]upstream (local only: the table `up` came from) }`. |
| - **Remote constructors** (`buildCandidates`): the per-file admission is `foldHomes`'s today, unchanged: files iterated in sorted key order (`order` = index); `f.Box == ""` or `f.Box == self` skipped (own-box exclusion); `addr = dialAddress(f, own, ownKnown, privateDial)` (so same-region private dialling is retained); `hosts` entries: role `home` with `addr == ""` → a `candCert{wildcard: true, platform: h.Platform && !h.Alias && !h.Wildcard}` only when `h.Wildcard && strings.HasPrefix(host, "*.")`, else skipped; role `wildcard` → the same `candCert` only when `h.Wildcard && strings.HasPrefix(host, "*.")`, else skipped; unknown roles skipped (`candCert` is constructed explicitly in those two branches and nowhere else); role `home` with an address → a ready candidate `{candReady, eip: addr, box, account, app, alias: h.Alias, wildcard: h.Wildcard, platform: h.Platform && !h.Alias && !h.Wildcard, targetHost: h.TargetHost (plan 1; "" when absent) for an alias, the host itself for a route, incarnation/servingSeq (plan 1), stamp: h.Stamp, fetchedAt}`; `offline_moves` entries (only when `addr != ""`, the same requirement a dial target has): one `candHolding` for `Host` `{targetHost: Host, platform: entry.Platform, account/app: entry's, incarnation/servingSeq: entry's, alias: false, hold: &entry}` and one alias `candHolding` per listed alias `{alias: true, targetHost: Host, platform: false, the same owner and pair, hold: &entry}`; holds carry `stamp: 0` always. |
| - **Local constructors** (`Reload` for ready, `RefreshHolds` for holds): for every entry `(name, up)` of the table `Reload` just built: a `candReady{local: true, order: -1, alias: up.alias, targetHost: up.route.Host when up.alias else name, account/app: from up.route, incarnation/servingSeq: up.route's pair, platform: (up.route.Platform || up.route.Provisioned) && !up.alias && !wildcard (an alias is never a platform class, as `currentHosts` already rules), wildcard: strings.HasPrefix(name, "*."), stamp: 0 (local candidates' stamps are not consulted: local-first is the rule, as today), up: &up, table: the new table}`. Local holds: for every registry row the hold snapshot knows (`registry.IsHold(r.Status) && r.HoldOp == mv.ID`): one real `candHolding{local, targetHost: r.Host, platform: r.Platform || r.Provisioned, owner from the row, pair from the row, hold: a holdingView built from the row and the move}` and one alias `candHolding{alias: true, targetHost: r.Host, platform: false}` per `route_aliases` row with `a.Host == r.Host && a.Explicit && edgesfile.AliasPublishable(a, true, shadowed)` (the same predicate the writer uses, so the source box serves its own alias the page even though the home map excludes its own publication). |
| -- `type decision struct { kind decKind (decLocal, decRemote, decHolding, decNone); up *upstream; table map[string]upstream; eip, box, account, app string; stamp int64; platform, wildcard bool; hold *holdingView }`. |
| +- `type decision struct { kind decKind (decLocal, decRemote, decHolding, decNone); up *upstream; table map[string]upstream; eip, box, account, app string; stamp int64; platform, wildcard bool; hold *holdingView; fallback *localFallback }` with `type localFallback struct { up *upstream; table map[string]upstream }`: set by `Edge.resolve` when the exact name has no local candidate, the local table has the `*.<parent>` entry, and the exact decision is `decRemote`; captured from the **same** table snapshot the resolution read, so a reload between resolution and a failed backhaul cannot change the fallback's backend or owner. |
| - **`rank(name string, cands []candidate, now time.Time) decision`:** |
| 1. remote `candHolding` with `now.Sub(fetchedAt) >= holdingValidity` are removed; |
| 2. `platform`/`wildcard` are the OR over all candidates; `candCert` never decides; nothing else present → `decNone`; |
| 3. **suppression:** for every remaining `candHolding` h: every `candReady` c with `c.targetHost == h.targetHost && c.targetHost != "" && c.stamp == 0 && (c.incarnation, c.servingSeq) < (h.incarnation, h.servingSeq)` is removed (a stale publication of the moving target). Nothing else is removed: a stamped ready, a ready of another target, a ready with an empty target, a ready with an equal or higher pair all survive; |
| 4. **ready:** if any `candReady` survives: a local one → `decLocal` with its `up` and `table` (local first, as today; the table has one entry per name); else the remote survivors go through **the shared fold** (`foldHomes`'s loop in `order`, deciding with `dialBeats` as plan 1 Task 8 defines it: the plan does not restate the rule; the fold is one function called from `foldHomes` and from here) → `decRemote` with the winner's `eip, box, account, app, stamp`; |
| 5. **holding (a sorted fold):** no ready survives: iterate the `candHolding` survivors in `order` (local `-1` first); the first is the incumbent; a later candidate **replaces** the incumbent only when (it is real and the incumbent is an alias) or (both are the same kind, share a non-empty `targetHost`, and its pair is higher); otherwise the incumbent stays → `decHolding` with the winner's `hold`. (Witness: A-old alias `(1,1)` at 0, B alias at 1, A-new alias `(1,2)` at 2 → A-new.) |
| -- **Invariants, scoped to the stages (Task 3 asserts them on the small model):** *suppression:* (d) a hold removes exactly the unstamped ready candidates sharing its non-empty target with a lower pair; (b) a stamped ready candidate is never suppressed; (f) an expired remote hold neither suppresses nor decides; (g) no pair is compared between candidates with different or empty targets, at any stage. *local precedence:* (l) a surviving local ready candidate wins over every remote candidate and every hold. *remote fold:* (a) with no live hold the remote answer equals `foldHomes`'s and the complete answer equals today's dispatch; (h) among the remote survivors a real ready beats an alias ready whatever the pairs and stamps (#287), as the fold's own rule; (c) a surviving ready of a different target than every hold beats every hold. *holding:* (e) a ready candidate of target T that survives suppression (its pair is not lower than that of any live hold of T) beats every hold; (n) the holding winner is the sorted fold's and is independent of input order except through `order`. *classes and ownership:* (j) `platform`/`wildcard` are OR'd over the candidates (classes are facts about the name) while the owner, dial address, stamp and `up`/`table` come from the winner alone; an alias candidate never carries the platform class. |
| -- `func (m *homeMap) resolveExact(name string, now time.Time) decision` (the exact candidates; never a wildcard; with `m.self == ""` (an unlabelled box) no remote candidate is admitted, so `Home`/`ExactHome` keep today's refusal to dial from an unlabelled box); `func (m *homeMap) resolveRequest(name string, now time.Time) decision` (exact; if `decNone` and the exact name has no live `decHolding` → `rank` over `*.<parent>`'s candidates); `Home`/`ExactHome` answer only `decRemote` from `resolveRequest`/`resolveExact` as today's split; `Owner` from the same decision; `KnownHost(name, now)` = `resolveExact` is `decRemote` or `decHolding` and `name` is not a `*.` label; `Hosts()` adds a `decHolding` winner's host when `platform`; **the wake** (`refreshHosts`' `samePlatformSet`) compares the effective parent inputs, `hosts`' platform set **plus the platform real holds**, so a platform hold arriving while the ready fold is unchanged wakes the wildcard manager; `ServedHosts()` unchanged (ready winners only; **a hold is never served evidence**); `HoldingHosts(now)` new: the exact names (hosts and aliases) whose `resolveExact` is a live `decHolding`. |
| +- **Invariants, scoped to the stages (Task 3 asserts them on the small model):** *suppression:* (d) a hold removes exactly the unstamped ready candidates sharing its non-empty target with a lower pair; (b) a stamped ready candidate is never suppressed; (f) an expired remote hold neither suppresses nor decides; (g) no pair is compared between candidates with different or empty targets, at any stage. *local precedence:* (l) a surviving local ready candidate wins over every remote candidate and every hold. *remote fold:* (a) with no live hold the remote answer equals `foldHomes`'s and the complete answer equals today's dispatch; (h) among the remote survivors a real ready beats an alias ready whatever the pairs and stamps (#287), as the fold's own rule; (c) a surviving ready of a different target than every hold beats every hold. *holding:* (e) a ready candidate of target T that survives suppression (it is stamped, or its pair is not lower than that of any live hold of T) beats every hold; (n) the holding winner is the sorted fold's and is independent of input order except through `order`. *classes and ownership:* (j) `platform`/`wildcard` are OR'd over the candidates (classes are facts about the name) while the owner, dial address, stamp and `up`/`table` come from the winner alone; an alias candidate never carries the platform class. |
| +- `func (m *homeMap) resolveExact(name string, now time.Time) decision` (the exact candidates; never a wildcard); **the unlabelled box keeps today's split**: `Home`, `ExactHome` and `Edge.resolve` refuse to **dial** (no `decRemote`, no `decHolding` from remote candidates) when `m.self == ""`, while the membership queries `KnownHost`, `Hosts`, `HoldingHosts` and `certNames` still inspect the admitted remote candidates, exactly as today's `Home == false && KnownHost == true` on an unlabelled edge; `func (m *homeMap) resolveRequest(name string, now time.Time) decision` (exact; if `decNone` and the exact name has no live `decHolding` → `rank` over `*.<parent>`'s candidates); `Home`/`ExactHome` answer only `decRemote` from `resolveRequest`/`resolveExact` as today's split; `Owner` from the same decision; `KnownHost(name, now)` = `resolveExact` is `decRemote` or `decHolding` and `name` is not a `*.` label; `Hosts()` adds a `decHolding` winner's host when `platform`; **the wake** (`refreshHosts`' `samePlatformSet`) compares the effective parent inputs, `hosts`' platform set **plus the platform real holds**, so a platform hold arriving while the ready fold is unchanged wakes the wildcard manager; `ServedHosts()` unchanged (ready winners only; **a hold is never served evidence**); `HoldingHosts(now)` new: the exact names (hosts and aliases) whose `resolveExact` is a live `decHolding`. |
| - **The local edge:** `e.holdView atomic.Pointer[HoldSnapshot]` is set by `SetHoldView(s HoldSnapshot)` (the view's `onChange`), which then calls `RefreshHolds()`. `type holdSnapshot struct { cands map[string][]candidate }` in `e.holds atomic.Pointer[holdSnapshot]` is written **only** by `func (e *Edge) RefreshHolds()`: under `e.holdsMu` it reads the **latest** `e.holdView.Load()` and the registry's current rows and aliases, builds the local holding candidates (none when the view is nil or `Failed`), installs them, and after the lock sends the wildcard manager the same parent-set notification `Reload` sends after installing a new table. Because the inputs are read inside the same lock that installs, rebuilds install in the order they read, so no rebuild can install older inputs than one before it; there is no revision comparison. `RefreshHolds()` is called by `SetHoldView` (a view change) **and at the end of `Reload`** (a registry change: a row turning held, an alias detach, a provenance or DNS eligibility change), so a hold that the registry gains while the view is unchanged is installed on the next `Reload`. `Reload` writes `e.holds` only through that call. `func (e *Edge) resolve(host string, now time.Time) decision`: the exact candidates = `e.current()`'s entry for `host` (as a local ready candidate), `e.holds.Load().cands[host]`, and the home map's exact candidates; through `rank`; `decNone` and no live exact hold → the same over `*.<parent>` (the local wildcard entry and the remote wildcard candidates). **The handler uses `dec.up` and `dec.table`:** path-prefix selection runs over `dec.up.paths` and resolves the target hostname in `dec.table` (`selectUpstream(dec, r.URL.Path) *upstream`, preserving today's cleaned/raw segment matching, exact target lookup and one-hop selection); the login re-resolution looks its path up in the same `dec.table`; the door's `doorOwnsRoute` checks the **finally selected** upstream's route; a remote decision's owner is `dec.account/app`; `backhaulTo` is called with `dec.eip, dec.box`: one decision and one table for the whole request. |
| -- **Request paths:** HTTPS `ServeHTTP`: after `acceptHop`: `decLocal` → the local path from `selectUpstream`; `decRemote` → an accepted hop → `staleHopRefusal`; a door → `Owner` must match else `unknownHost`; else `backhaulTo`; `decHolding` → an accepted hop → the page (no forwarding); a door → the page only when `hold.Account/App` equal the door's and are non-empty, else `unknownHost`; else the page; `decNone` → an accepted hop → `staleHopRefusal`; else `unknownHost`. HTTP fallback: `decLocal`/`decRemote`/`decHolding` → the HTTPS redirect; `decNone` → today. |
| +- **Request paths:** HTTPS `ServeHTTP`: after `acceptHop`: `decLocal` → the local path from `selectUpstream`; `decRemote` with `fallback != nil` → an accepted hop → the local path from `selectUpstream` over `fallback` (as today); a door → the local path over `fallback`, then `doorOwnsRoute` on the finally selected local route (as today); else `backhaulTo`, and when it returns false for want of a signing key → the local path over `fallback` (as today; saturation and proxy failures stay remote answers); `decRemote` without `fallback` → an accepted hop → `staleHopRefusal`; a door → `Owner` must match else `unknownHost`; else `backhaulTo`; `decHolding` → an accepted hop → the page (no forwarding); a door → the page only when `hold.Account/App` equal the door's and are non-empty, else `unknownHost`; else the page; `decNone` → an accepted hop → `staleHopRefusal`; else `unknownHost`. HTTP fallback: `decLocal`/`decRemote`/`decHolding` → the HTTPS redirect; `decNone` → today. |
| - **TLS (F6):** `Allowed(host)`: `*.` → false; the tunnel hostname (`e.tunnels != nil && host == e.tunnels.Host()`) → true, as today; else a local exact `candReady` or a local exact `candHolding` exists for `host`. `deploymentHost = Allowed || KnownHost`. `currentHosts()` = the platform/provisioned non-alias names among the local table **and** the local real holds (so wildcard parents persist through a hold). `func (e *Edge) certNames() []string` = the local hold names (hosts and aliases) plus `homes.HoldingHosts(now)`; `warmSet` considers `certNames()` after `ServedHosts()` under the same exclusions (apps domain, wildcard-covered, `*.`); the issuer election is unchanged. `ServedHosts` is not widened. |
| - The page: `func renderHoldingPage(displayName string, untilUnix int64, now time.Time) []byte` and `func (e *Edge) holdingAnswer(w, r, h *holdingView)` with `untilUnix = h.OfflineAt + int64(h.EstimateSeconds)` when both are non-zero, else 0; the sentences, N, the headers and the client's branches exactly as the Global Constraints state. |
| -- [ ] **Step 1: Write the failing tests**: `TestHoldingViewDecodesTheWriterFixture` (`internal/edgesfile/testdata/offline-moves.json`); `TestRankSuppressionAndFold` (the hold suppresses only its target's lower unstamped pairs; a retained unstamped `(1,2)` against a stamped `(0,0)` with no hold → the stamped one, as the fold; the three-hold sorted-fold witness → A-new; an old hold `(1,1)`, a ready `(1,2)` and a current hold `(2,1)` of one target → the ready is suppressed and the `(2,1)` hold answers; the F1 four-candidate set (A-ready `(1,0)`, A-hold `(1,1)`, B-ready `(1,0)`, B-hold `(1,1)`) → the lower-`order` hold; the F2 three-publication set (A-old `(1,0)` at 0, B-ready `(1,0)` at 1, A-new `(1,2)` at 2, no holds) → A-new, as `foldHomes`; `TestHomeMapAStampedAliasDoesNotBeatAnUnstampedRoute` still passing; a stamped alias against a real hold → the stamped alias (§6.4)); `TestResolveExactVersusRequest` (an expired exact hold → the wildcard; a live exact hold blocks it; `KnownHost` false for an invented wildcard label; `Allowed` rejects `*.` and admits the tunnel host); `TestDecisionCarriesTheTable` (a prefix target and the login re-resolution come from `dec.table`; a reload between resolution and dispatch that changes the prefix target's owner and backend does not change what this request serves; the door check runs against the finally selected route); `TestLocalCandidatesAreTheUpstreamTable` (a non-pointing local alias serves; sidecars and backend-less rows absent); `TestLocalHoldsIncludeExplicitAliases` (the own publication excluded from the home map; the source's explicit pointing alias → the page; its non-pointing alias → serves as today; a derived alias → not held); `TestRemoteCandidatesKeepTheAdmissions` (own box excluded; `Box == ""` skipped; unknown role skipped; a file without an address yields cert-only wildcards and no holds; same-region private dialling); `TestLocalAndRemoteArbitrateTogether` (Review Focus 4); `TestEveryRequestPathHonoursTheDecision` (the table above including hops and doors with and without ownership and with an empty-owner hold; **the kept exceptions**: a local `*.example` upstream and a remote exact `x.example` publication: an accepted hop → served from the local wildcard, a door request → served from the local wildcard, an ordinary request with no mesh key whose backhaul fails → the local wildcard, exactly as today's `ServeHTTP`; the same three with a hold for `x.example` → hop: the page; door: the page when owned; ordinary: the page); `TestLocalHoldsFollowTheViewAndTheRegistry` (a failed snapshot after an installed hold drops it; a view published while the row is still ready installs no hold, then the row turns held and `Reload` installs it with the view unchanged; an alias detach, a provenance change and a DNS eligibility change each rebuild on `Reload`; a slow rebuild that read view A overlapped by one that read view B installs in read order, so the installed holds are B's; success → failure → recovery without registry changes; two concurrent publishers never share a revision); `TestHoldingPageRendersEveryCase` (name, no name, no estimate, past `until`, N = ceil with minimum 1 at 61 s, 60 s, 59 s, 1 s, escaping of `<b>`, HEAD; the script's `until`, both cadences, the local sentence switch at `until`, the storage key and the try/catch asserted by text); `TestTLSCoverageForHoldingHosts` (real handshakes; a wildcard-only platform host held keeps its parent; a platform hold arriving on the home map after the wildcard manager started, with the ready fold unchanged, wakes it and the parent's certificate serves; an invented label gets no issuance and no challenge read; the tunnel host still admitted; a holding-only custom domain is in `certNames` and not in `ServedHosts`; an issuer warms it, then a non-issuer's handshake for it succeeds from the shared cache). |
| +- [ ] **Step 1: Write the failing tests**: `TestHoldingViewDecodesTheWriterFixture` (`internal/edgesfile/testdata/offline-moves.json`); `TestRankSuppressionAndFold` (the hold suppresses only its target's lower unstamped pairs; a retained unstamped `(1,2)` against a stamped `(0,0)` with no hold → the stamped one, as the fold; the three-hold sorted-fold witness → A-new; an old hold `(1,1)`, a ready `(1,2)` and a current hold `(2,1)` of one target → the ready is suppressed and the `(2,1)` hold answers; the F1 four-candidate set (A-ready `(1,0)`, A-hold `(1,1)`, B-ready `(1,0)`, B-hold `(1,1)`) → the lower-`order` hold; the F2 three-publication set (A-old `(1,0)` at 0, B-ready `(1,0)` at 1, A-new `(1,2)` at 2, no holds) → A-new, as `foldHomes`; `TestHomeMapAStampedAliasDoesNotBeatAnUnstampedRoute` still passing; a stamped alias against a real hold → the stamped alias (§6.4)); `TestResolveExactVersusRequest` (an expired exact hold → the wildcard; a live exact hold blocks it; a hold that lost to a surviving ready candidate blocks nothing; `KnownHost` false for an invented wildcard label; `Allowed` rejects `*.` and admits the tunnel host; **an unlabelled edge** with a remote `home` publication and no local route → `Home == false`, `KnownHost == true`, as today); `TestDecisionCarriesTheTable` (a prefix target and the login re-resolution come from `dec.table`; a reload between resolution and dispatch that changes the prefix target's owner and backend does not change what this request serves; the door check runs against the finally selected route); `TestLocalCandidatesAreTheUpstreamTable` (a non-pointing local alias serves; sidecars and backend-less rows absent); `TestLocalHoldsIncludeExplicitAliases` (the own publication excluded from the home map; the source's explicit pointing alias → the page; its non-pointing alias → serves as today; a derived alias → not held); `TestRemoteCandidatesKeepTheAdmissions` (own box excluded; `Box == ""` skipped; unknown role skipped; a file without an address yields cert-only wildcards and no holds; same-region private dialling); `TestLocalAndRemoteArbitrateTogether` (Review Focus 4); `TestEveryRequestPathHonoursTheDecision` (the table above including hops and doors with and without ownership and with an empty-owner hold; **the kept exceptions**: a local `*.example` upstream and a remote exact `x.example` publication: an accepted hop → served from the local wildcard, a door request → served from the local wildcard, an ordinary request with no mesh key whose backhaul fails → the local wildcard, exactly as today's `ServeHTTP`; the same three with a live hold for `x.example` → hop: the page; door: the page when owned; ordinary: the page; a reload between the remote resolution and the failed backhaul does not change the fallback's backend or owner); `TestLocalHoldsFollowTheViewAndTheRegistry` (a publication **before** the edge subscribes is delivered at subscription and the hold installs; a publication racing the subscription is delivered once, in order; a failed snapshot after an installed hold drops it; a view published while the row is still ready installs no hold, then the row turns held and `Reload` installs it with the view unchanged; an alias detach, a provenance change and a DNS eligibility change each rebuild on `Reload`; a slow rebuild that read view A overlapped by one that read view B installs in read order, so the installed holds are B's; success → failure → recovery without registry changes; two concurrent publishers never share a revision); `TestHoldingPageRendersEveryCase` (name, no name, no estimate, past `until`, N = ceil with minimum 1 at 61 s, 60 s, 59 s, 1 s, escaping of `<b>`, HEAD; the script's `until`, both cadences, the local sentence switch at `until`, the storage key and the try/catch asserted by text); `TestTLSCoverageForHoldingHosts` (real handshakes; a wildcard-only platform host held keeps its parent; a platform hold arriving on the home map after the wildcard manager started, with the ready fold unchanged, wakes it and the parent's certificate serves; an invented label gets no issuance and no challenge read; the tunnel host still admitted; a holding-only custom domain is in `certNames` and not in `ServedHosts`; an issuer warms it, then a non-issuer's handshake for it succeeds from the shared cache). |
| - [ ] **Step 2: Run to verify they fail; Step 3: implement; Step 4: run** `go test ./internal/edge -run 'Rank|Holding|Resolve|Decision|LocalCandidates|LocalHolds|RemoteCandidates|LocalAndRemote|RequestPath|TLSCoverage|HomeMap' -count=1`. |
| - [ ] **Step 5: Commit** `edge: holds suppress their target's stale publications and the fold decides the rest; one decision and one table per request; local holds in their own snapshot`. |
| @@ -140,5 +140,5 @@ The reviewed inventory, each with one assertion over the fixture: `edgesfile.Par |
| - Spec coverage: §6.4 (T2 ranking, T3 witnesses, T5 expiry), §7.4 (T2 page, T6 probe), §11 (T7 reconstruction witness). |
| - The ranking's ready-only path is the fold itself, so plan 1 and plan 3 cannot disagree (T3's differential test). |
| - Known limit, stated: the page's client script is pinned by text assertions on each branch; no browser runs in CI. |
| -- Names used across tasks: `RouteAlias.Explicit`, `edgesfile.AliasPublishable`, `holdingEntry`, `holdingSource`, `offlineHolds`, `offlineView.snapshot/onChange/pubMu`, `edge.HoldSnapshot`/`HoldState`, `SetHoldView`, `RefreshHolds()`, `holdingView`, `candidate`/`candKind`/`order`/`table`, `decision`/`decKind`, `buildCandidates`, `rank`, `resolveExact`, `resolveRequest`, `HoldingHosts`, `Edge.resolve`, `selectUpstream`, `holdSnapshot`, `certNames`, `holdingValidity`, `renderHoldingPage`, `holdingAnswer`, `SetHoldingSource`, `WithBoxID` (package-level `RunOption`), `servedByWriter`, `probeTarget` (plan 2). |
| +- Names used across tasks: `RouteAlias.Explicit`, `edgesfile.AliasPublishable`, `holdingEntry`, `holdingSource`, `offlineHolds`, `offlineView.snapshot/onChange/pubMu`, `edge.HoldSnapshot`/`HoldState`, `Subscribe`, `SetHoldView`, `RefreshHolds()`, `localFallback`, `holdingView`, `candidate`/`candKind`/`order`/`table`, `decision`/`decKind`, `buildCandidates`, `rank`, `resolveExact`, `resolveRequest`, `HoldingHosts`, `Edge.resolve`, `selectUpstream`, `holdSnapshot`, `certNames`, `holdingValidity`, `renderHoldingPage`, `holdingAnswer`, `SetHoldingSource`, `WithBoxID` (package-level `RunOption`), `servedByWriter`, `probeTarget` (plan 2). |
| - Review Focus 1 to 5 are pinned by T5, T3, T3, T2, T6. |