plan: self-serve moves 1, rev 8 (Astra plan review 7: findings 1 to 6; rollout guarantee, attach under the exclusion, root proof readers judged, corrupt quarantine, WithLifetimes)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+21
−19
docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md+21 −19
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (5 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (143 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.709s
ok github.com/carlosframework/platform/cmd/moveprobe 11.524s
ok github.com/carlosframework/platform/cmd/zddprobe 0.707s
ok github.com/carlosframework/platform/internal/activator 58.122s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.023s
ok github.com/carlosframework/platform/internal/appconfig 2.820s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.140s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.713s
? 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.012s
ok github.com/carlosframework/platform/internal/caps 0.020s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.709s
ok github.com/carlosframework/platform/internal/changelog 0.351s
ok github.com/carlosframework/platform/internal/claimactivation 0.865s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.702s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.015s
ok github.com/carlosframework/platform/internal/dnsrec 0.013s
ok github.com/carlosframework/platform/internal/docsite 0.016s
ok github.com/carlosframework/platform/internal/domains 2.858s
ok github.com/carlosframework/platform/internal/doorkey 0.017s
ok github.com/carlosframework/platform/internal/economics 0.015s
ok github.com/carlosframework/platform/internal/edge 45.233s
? 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.130s
ok github.com/carlosframework/platform/internal/gate 1.813s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.016s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.150s
ok github.com/carlosframework/platform/internal/host 133.040s
ok github.com/carlosframework/platform/internal/hostactors 6.102s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 3.128s
ok github.com/carlosframework/platform/internal/hostclaim 0.871s
ok github.com/carlosframework/platform/internal/hostenv 0.005s
ok github.com/carlosframework/platform/internal/hostevidence 0.008s
ok github.com/carlosframework/platform/internal/hostexec 5.449s
ok github.com/carlosframework/platform/internal/hostfence 0.061s
ok github.com/carlosframework/platform/internal/hostfiles 10.247s
ok github.com/carlosframework/platform/internal/hostfloor 0.024s
ok github.com/carlosframework/platform/internal/hostguard 1.039s
ok github.com/carlosframework/platform/internal/hostops 2.116s
? 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.008s
ok github.com/carlosframework/platform/internal/hostrelocate 0.674s
ok github.com/carlosframework/platform/internal/hostresolve 0.229s
ok github.com/carlosframework/platform/internal/hostwrite 0.008s
ok github.com/carlosframework/platform/internal/infraview 0.008s
ok github.com/carlosframework/platform/internal/instancectl 5.239s
ok github.com/carlosframework/platform/internal/instanceexec 2.767s
ok github.com/carlosframework/platform/internal/instancefs 0.013s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.873s
ok github.com/carlosframework/platform/internal/lbhealth 0.002s
ok github.com/carlosframework/platform/internal/lease 10.116s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.667s
ok github.com/carlosframework/platform/internal/ledger 0.017s
ok github.com/carlosframework/platform/internal/mail 0.169s
ok github.com/carlosframework/platform/internal/maintenance 0.082s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.514s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.100s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.203s
ok github.com/carlosframework/platform/internal/moves/archive 1.087s
ok github.com/carlosframework/platform/internal/objstore 0.328s
ok github.com/carlosframework/platform/internal/ops 0.211s
ok github.com/carlosframework/platform/internal/park 0.287s
ok github.com/carlosframework/platform/internal/plans 0.006s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 13.820s
ok github.com/carlosframework/platform/internal/publicrelease 0.008s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.660s
ok github.com/carlosframework/platform/internal/relay 7.241s
ok github.com/carlosframework/platform/internal/release 24.982s
ok github.com/carlosframework/platform/internal/rollout 3.810s
ok github.com/carlosframework/platform/internal/rolloutwords 0.007s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.064s
ok github.com/carlosframework/platform/internal/rootstate 0.030s
ok github.com/carlosframework/platform/internal/runs 0.571s
ok github.com/carlosframework/platform/internal/s3compat 0.007s
ok github.com/carlosframework/platform/internal/safefile 0.011s
ok github.com/carlosframework/platform/internal/schedule 2.662s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.299s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.438s
ok github.com/carlosframework/platform/internal/status 0.095s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.232s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/termfence 0.006s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.578s
=== internal_console@0 (77 s)
ok github.com/carlosframework/platform/internal/console 74.591s
=== internal_console@1 (37 s)
ok github.com/carlosframework/platform/internal/console 34.717s
=== internal_console@2 (42 s)
ok github.com/carlosframework/platform/internal/console 39.713s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 34.623s
=== internal_console@4 (36 s)
ok github.com/carlosframework/platform/internal/console 34.213s
=== internal_console@5 (34 s)
ok github.com/carlosframework/platform/internal/console 32.443s
=== internal_console@6 (36 s)
ok github.com/carlosframework/platform/internal/console 34.021s
=== internal_console@7 (42 s)
ok github.com/carlosframework/platform/internal/console 39.968s
=== root@0 (32 s)
ok github.com/carlosframework/platform 29.938s
=== root@1 (44 s)
ok github.com/carlosframework/platform 41.874s
=== root@2 (41 s)
ok github.com/carlosframework/platform 39.020s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.021s
=== RUN TestT4StorageProbeMinio
2026/10/22 08:28:39 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0CCB2924FE466, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md b/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md| index d81525ebd..c82718c20 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 7, 2026-10-22. 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 8, 2026-10-22. 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,8 @@ Rev 7, 2026-10-22. Reviews: rev 1 (`after236/selfserve-plan1-review.md`, 16 find |
| - Copy: two new sentences are copy rows pending Paul's review: "the instance changed. read it again and try the delete once more" and "another change to this instance is still settling. try again in a minute". 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). |
| -- **Record sequencing.** Every claims-off record write runs with `provision.WithRecordSequence(ctx)`: 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. |
| +- **Rollout guarantee (the late legacy delete).** The claims-off console is one process per deployment; an older console's `RetireRecord` deletes a record unconditionally and must never run beside this code. Stated and enforced: (a) `StartLifetimeRecoveryPass`, the migration pass and every termination branch (`refused`, `void`, `superseded`) wait `lifetimeSettle` (60 s) after the console's start, longer than any in-flight S3 request an older console could have issued before it stopped; (b) the recovery passes do nothing while `admit` for the host would refuse; (c) an older console is not reintroduced once any record carries `incarnation` (the same rule as plan 2's `active.json`); `TestNoTerminationBeforeTheSettleWindow` pins (a). Within that guarantee 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`. |
| ## Review Focus |
| @@ -110,7 +111,7 @@ The verdict, exactly, with `owned := slot != nil && slot.Account == rec.Account |
| - `func (s *Server) withHostname(host string, fn func() error) error` (a `sync.Map` of `*sync.Mutex`; not reentrant; every exported helper below takes it, every `...Locked` helper assumes it). |
| - `func (s *Server) admit(host, ownOpID string) error`: `errLifetimeSettling` when `PendingForHost(host, ownOpID) > 0`. |
| - `func (s *Server) classifyLocked(ctx context.Context, acct, app, host string) (provision.Observation, error)`: `s.judge.Observe(ctx, s.cfg.Releases, acct, app, host)` (Task 6: record first, slot after, bounded rounds); the error is non-nil only with `Verdict == LifetimeUnreadable`. |
| - - `func (s *Server) createRecordWithLifetime(ctx context.Context, rec provision.Record) (*provision.Record, error)`: the one function every claims-off creation path calls; returns the record as written. Errors: `release.ErrCASConflict` (a valid record under this key), `*hostHeldError` (`errors.As`; carries `*apexHold` for `hostHeldSentence`: a live slot owned by another key gives `&apexHold{Holder: slot.App, OtherAccount: slot.Account if ≠ rec.Account}`), `errLifetimeSettling`, `errLifetimePending`, or a read error from step 2 (nothing persisted). |
| + - `func (s *Server) createRecordWithLifetime(ctx context.Context, rec provision.Record) (*provision.Record, error)` (takes `withHostname`) and `func (s *Server) createRecordWithLifetimeLocked(ctx context.Context, rec provision.Record) (*provision.Record, error)` (assumes it): the one protocol every claims-off creation path runs; the create, seed and import doors call the locking form; the attach door calls the locked form inside its own exclusion (below). Returns the record as written. Errors: `release.ErrCASConflict` (a valid record under this key), `*hostHeldError` (`errors.As`; carries `*apexHold` for `hostHeldSentence`: a live slot owned by another key gives `&apexHold{Holder: slot.App, OtherAccount: slot.Account if ≠ rec.Account}`), `errLifetimeSettling`, `errLifetimePending`, or a read error from step 2 (nothing persisted). |
| - `func (s *Server) fenceRecord(ctx context.Context, acct, app, host string) error`: `GetRecordRaw`; absent → nil (nothing to fence; the slot is the fence for an absent key); present → `WriteRecord(WithRecordSequence(ctx), s, key, raw, raw, etag)` (a rewrite whose only change is the introduced or advanced `record_seq`); `ErrCASConflict` → re-read and retry, at most 3 times; any other failure → the error (the caller leaves its operation pending). Every recovery branch below and in Tasks 4 and 5 that would mark an operation `refused`, `void` or `superseded` because the record is not what the operation captured calls `fenceRecord` first and stays pending if it fails. |
| - `func (s *Server) recoverLifetimes(ctx context.Context) error`, `func (s *Server) StartLifetimeRecoveryPass(ctx context.Context)` (returns at once unless `lifetimesOn()`; once at start, then every 60 s). |
| @@ -133,17 +134,17 @@ Recovery (`recoverLifetimes`), per pending allocation, inside `withHostname` wit |
| - anything else → leave pending and log once per hour. |
| Then the retirements (Task 4) and the migrations (Task 5), in that order. |
| -Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host, hold)`; `errLifetimeSettling` → 409 settling; `errLifetimePending` → 502 "could not create the instance — try again", no undo; the **sibling undo** at line 1684 (the apex was claimed mid-create) becomes `retireRecordWithLifetime(ctx, acct.Sqid, app, host, retireExpectIncarnation(created.Incarnation))` with the record the create returned: it retires exactly the lifetime this create made and nothing else (Task 4). Seed and import: the same three onto their existing failure sentences; on `errLifetimePending` the door writes nothing further and the record recovery completes carries the seed state the body had, which the existing seed sweep then owns. Attach: `ErrCASConflict` keeps today's adoption branch; every other lifetime-helper failure answers its sentence (`errLifetimePending` the ambiguous one; `errLifetimeSettling` 409 settling; `*hostHeldError` 409 held; a step-2 read error the ambiguous one) and calls `rollbackAttach(ctx, acct, app, host, false, created)` with **`dropRecord` always false**: nothing a lifetime failure leaves behind is this call's to retire. The claims-off `rollbackAttach` changes: its claim release runs inside `withHostname(host)` and releases the claim **only** when `admit(host, "") == nil` (no pending allocation, retirement or migration for the hostname) and `classifyLocked` finds no valid record under this key (a record completed since the failing attempt began keeps its claim); otherwise it logs and keeps the claim (a later detach, under the same exclusion, releases it once the record is gone). The claims-off detach (`domainattach.go:349`, `apidomains.go:989`) likewise runs its retirement and its claim release inside `withHostname(host)`. The claims-on `rollbackAttach` and detach are untouched. A test pins that no claims-off attach failure calls retirement. |
| +Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host, hold)`; `errLifetimeSettling` → 409 settling; `errLifetimePending` → 502 "could not create the instance — try again", no undo; the **sibling undo** at line 1684 (the apex was claimed mid-create) calls `retireInstanceRecord(ctx, acct.Sqid, app, host, principal, incarnation, provision.RetireExpectation{Incarnation: created.Incarnation})` with the record the create returned (the one dispatch path: `retireHostRecord` → the claims-off branch → the locking `retireRecordWithLifetime`; the claims-on branch ignores the expectation as today): it retires exactly the lifetime this create made and nothing else (Task 4). Seed and import: the same three onto their existing failure sentences; on `errLifetimePending` the door writes nothing further and the record recovery completes carries the seed state the body had, which the existing seed sweep then owns. Attach (claims-off): the **first-route path runs inside `withHostname(host)` from the claim to the record**: `attachFirstRouteLocked(ctx, acct, app, host, ...)` = `attachDomainClaim` (the claim write or reuse) → `createRecordWithLifetimeLocked` → on success the audit and bump; on `ErrCASConflict` today's adoption branch; on any other lifetime failure its sentence (`errLifetimePending` the ambiguous one; `errLifetimeSettling` 409 settling; `*hostHeldError` 409 held; a step-2 read error the ambiguous one) and `rollbackAttachLocked(ctx, acct, app, host, created)`, which never retires a record and releases the claim **only** when this call minted it, `admit(host, "") == nil`, and `classifyLocked` finds no valid record under this key. Because the claim write, every competing attach's claim reuse and lifetime create, every rollback and every detach of the hostname all run under the one exclusion, no attach can reuse a claim that another attach is about to release, and no release can happen while another attach is between its claim and its record. The claims-off detach (`domainattach.go:349`, `apidomains.go:989`) is `detachFirstRouteLocked(ctx, ...)` inside `withHostname(host)`: `retireRecordWithLifetimeLocked(..., retireAny)` then the claim release, record before claim as today. The claims-on attach, rollback and detach are untouched. A test pins that no claims-off attach failure calls retirement. |
| - [ ] **Step 1: Write the failing tests** against an in-process console with `release.NewDirStore` and `HostClaim` off: |
| - `TestCreateAllocatesSlotThenRecord`, `TestCreateAfterRetirementGetsNextIncarnation`, `TestCreateReplacesStaleRecord`, `TestCreateOfALiveInstanceIsTheExistingConflict`, `TestCreateRefusesWhileAnOperationIsPending`, `TestCreateRefusesALiveSlotOfAnotherApp`, `TestCreateRefusesWhenTheAccountIsUnknown` (`AccountBySqid` nil → 502, nothing persisted), `TestTwoAppsCreatingOneHostnameSerialise`. |
| - `TestEveryPostAllocationFailureIsPending` (non-CAS errors injected at steps 4, 5, 6 and a SQLite failure at 7: each `pending` + `errLifetimePending`; recovery completes each). |
| - `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); `TestCompetingAttachCannotReleaseAPendingAllocationsClaim` (A creates the claim and pauses; B reuses it and leaves a pending allocation; A resumes, gets settling, its rollback keeps the claim; recovery completes B's record, still claimed; and the mirror: A's rollback finds B's completed valid record and keeps the claim). |
| - - `TestFenceMakesAnOutstandingWriteHarmless` (a record without `record_seq`; an outstanding tombstone PUT and, separately, a marker PUT against its ETag E paused; the record goes A→B→A so its bytes and ETag return to E; recovery fences before terminating; the released PUT fails CAS; with the fence disabled by a test hook the same PUT lands, which is the defect the fence closes). |
| + - `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 (the PUT is also released during a fence retry in one variant) 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); `TestNoTerminationBeforeTheSettleWindow` (a pending operation whose record changed: within 60 s of the console's start recovery leaves it pending; after, it terminates). |
| - `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). |
| + - `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). |
| - `TestSeedCreatePendingIsOwnedByTheSweep`. |
| - [ ] **Step 2: Run to verify they fail.** |
| @@ -160,7 +161,7 @@ Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host, |
| **Interfaces:** |
| - Consumes: Tasks 1 to 3; Task 5's `migrateHostLifetimeLocked`; Task 6's `Observation`. |
| -- Produces: `type provision.RetireExpectation struct { Absent bool; ETag string; Incarnation uint64 }` (at most one of the three set; `RetireRef.Expect RetireExpectation`); the console's named values `retireExpectAbsent = provision.RetireExpectation{Absent: true}`, `retireAny = provision.RetireExpectation{}` (delete whatever is current: detach), `retireExpectETag(etag)` (panics on ""), `retireExpectIncarnation(n)`; `func (s *Server) retireRecordWithLifetime(ctx context.Context, acct, app, host string, expect provision.RetireExpectation) error` (two fields set → a programming error, 500); `func (s *Server) retireSlotLocked(ctx context.Context, r Retirement) (slotDisposition, error)` with `slotDisposition` one of `slotRetired` (the write landed or the slot is already `{N, retired, this key}`), `slotSuperseded` (`> N` or another key), `slotUnreadable`; `var errRetireRefused`. The sibling undo and detach reach this through `retireInstanceRecord(ctx, acct, app, host, by, incarnation, expect)` → `retireHostRecord` → the claims-off branch; the claims-on branch ignores `Expect` and is unchanged. A nil return means the key is absent (or was left untouched on purpose) and the slot, if any, is retired or someone else's. |
| +- Produces: `type provision.RetireExpectation struct { Absent bool; ETag string; Incarnation uint64 }` (at most one of the three set; `RetireRef.Expect RetireExpectation`); the console's named values `retireExpectAbsent = provision.RetireExpectation{Absent: true}`, `retireAny = provision.RetireExpectation{}` (delete whatever is current: detach), `retireExpectETag(etag)` (panics on ""), `retireExpectIncarnation(n)`; `func (s *Server) retireRecordWithLifetime(ctx context.Context, acct, app, host string, expect provision.RetireExpectation) error` (takes `withHostname`) and `func (s *Server) retireRecordWithLifetimeLocked(...)` (assumes it; detach's and the attach rollback's form) (two expectation fields set → a programming error, 500); `func (s *Server) quarantineCorrupt(ctx context.Context, acct, app, host string, raw []byte, etag string) error`: copies the bytes to `control/quarantine/<account>/<app>/<host>.<unix nanos>.json` (create-only) then `DeleteIfMatch` the key on `etag` (a conflict or absence is success: the body changed or went; the quarantine copy stays); `func (s *Server) retireSlotLocked(ctx context.Context, r Retirement) (slotDisposition, error)` with `slotDisposition` one of `slotRetired` (the write landed or the slot is already `{N, retired, this key}`), `slotSuperseded` (`> N` or another key), `slotUnreadable`; `var errRetireRefused`. The sibling undo and detach reach this through `retireInstanceRecord(ctx, acct, app, host, by, incarnation, expect)` → `retireHostRecord` → the claims-off branch; the claims-on branch ignores `Expect` and is unchanged. A nil return means the key is absent (or was left untouched on purpose) and the slot, if any, is retired or someone else's. |
| Inside `withHostname(host)`: `admit(host, "")`; `obs := classifyLocked` (unreadable → the error). **Preconditions:** `expect.absent && obs.Present` → nil, nothing touched; `expect.etag != "" && (!obs.Present || obs.ETag != expect.etag)` → `release.ErrCASConflict`; `expect.incarnation != 0 && !(obs.Present && obs.Rec != nil && obs.Rec.Incarnation == expect.incarnation && slot owned by this key at that incarnation)` → nil, nothing touched (the lifetime this caller made is already gone or superseded). Then the first matching case, each beginning a retirement of its **kind**: |
| - (a) absent, slot absent → nil. |
| @@ -171,7 +172,7 @@ Inside `withHostname(host)`: `admit(host, "")`; `obs := classifyLocked` (unreada |
| - (f) present and valid at N under slot `{N, live, this key}` at S → kind **normal**: `BeginRetirement({Kind: normal, OpID, N, E, S, SlotIntent, pending})`; steps N1, N2, N3, done. |
| **The steps** (every write failure other than the conflicts named is `pending` + `errLifetimePending`; every re-read that fails is `pending` + `errLifetimePending`): |
| -- **N1** `WriteRecord(WithRecordSequence(ctx), s, key, raw, TombstoneBody(raw), E)`. `ErrCASConflict` → re-classify (unreadable → pending): a non-tombstone record at N under this key → `fenceRecord`, then `refused` (`errRetireRefused`); a corrupt body → `fenceRecord`, then `refused`; a tombstone at N → N2; absent beneath the live predecessor slot (an older delayed deletion took it) → N2 then `committed` (there is no tombstone to remove); a record of another incarnation → `fenceRecord`, then `void`, nil; a successor slot → `void`, nil. |
| +- **N1** `WriteRecord(WithRecordSequence(ctx), s, key, raw, TombstoneBody(raw), E)`. `ErrCASConflict` → re-classify (unreadable → pending): a non-tombstone record at N under this key → `fenceRecord`, then `refused` (`errRetireRefused`); a corrupt body → `quarantineCorrupt` then N2 (the key is absent beneath the live predecessor; an outstanding write against the corrupt body's ETag can no longer land because the key is gone, and a create-only write lands stale under the retired slot); a tombstone at N → N2; absent beneath the live predecessor slot (an older delayed deletion took it) → N2 then `committed` (there is no tombstone to remove); a record of another incarnation → `fenceRecord`, then `void`, nil; a successor slot → `void`, nil. |
| - **N2** `retireSlotLocked` (`WriteHostIncarnation({N, retired, Account, App, "retire " + OpID}, S)`; on `ErrCASConflict` the slot is re-read for the disposition): `slotRetired` → the kind's next step (normal: N3 when a tombstone exists, else `committed`; orphan: the body if present, then `committed`; cleanup: C2); `slotSuperseded` → `void`; `slotUnreadable` → `pending`, `errLifetimePending`. |
| - **N3** `DeleteIfMatch` the tombstone on its ETag (absence is success). `committed`. |
| - **O1** `retireSlotLocked` with the orphan's S; `slotRetired` → a body present under the key (a late write) is deleted conditionally on its ETag (a conflict is logged; it is stale under the retired slot), `committed`. |
| @@ -180,7 +181,7 @@ Inside `withHostname(host)`: `admit(host, "")`; `obs := classifyLocked` (unreada |
| **Recovery**, per pending retirement, inside `withHostname` with `admit(host, OpID)`, after `classifyLocked` (unreadable → pending), with `owned`, `succ := slot != nil && (slot.Incarnation > N || !owned)`, `pred := slot != nil && owned && slot.Incarnation == N && slot.State == live && slot.Intent == SlotIntent`, `retired := slot != nil && owned && slot.Incarnation == N && slot.State == retired`: |
| - any kind: `succ` → `void`; `retired` and absent → `committed`; `retired` and any present body (tombstone, late record, stale, corrupt) → `DeleteIfMatch` on its ETag (a conflict is left for the next create), `committed`; a slot that is neither `succ`, `pred` nor `retired` → pending, logged once per hour. |
| -- kind **normal**, `pred`: a present non-tombstone record at `RecordETag` → N1, N2, N3; a tombstone at N → N2, N3; absent → N2, `committed`; a present non-tombstone record at N with another ETag → `fenceRecord`, `refused`; a present record whose `Incarnation != N` (not a tombstone) → `fenceRecord`, `void`; a corrupt body → `fenceRecord`, `refused`. |
| +- kind **normal**, `pred`: a present non-tombstone record at `RecordETag` → N1, N2, N3; a tombstone at N → N2, N3; absent → N2, `committed`; a present non-tombstone record at N with another ETag → `fenceRecord`, `refused`; a present record whose `Incarnation != N` (not a tombstone) → `fenceRecord`, `void`; a corrupt body → `quarantineCorrupt`, N2, `committed`. |
| - kind **orphan**, `pred`: absent or any present body → O1 (the slot, then the body if present), `committed`. |
| - kind **cleanup**, `pred`: any observation → C1 then C2 (on the current body's ETag when present), `committed`. |
| @@ -195,7 +196,7 @@ Inside `withHostname(host)`: `admit(host, "")`; `obs := classifyLocked` (unreada |
| - `TestExpectationsGateRetirement` (`retireExpectIncarnation(1)` against a record at 2 → nil, untouched; `retireAny` retires what is current; `retireExpectETag` conflict → `ErrCASConflict`). |
| - `TestLegacyRetirementIsBoundToTheCallersRead` (the helper: an ordinary update between the caller's read and the marker CAS → `ErrCASConflict`, the updated record intact, the migration row superseded); `TestDeleteDoorRefusesAnImportThatAppearedAfterItsFirstRead` (the real DELETE door: the first read sees a legacy record; before the retirement the record becomes an import in state `ready`; the `again` re-decision refuses with the import rule's 409 and nothing is deleted). |
| - `TestDelayedDeleteCannotEraseASuccessor`. |
| - - `TestRetirementRecoveryBranches` (one subtest per kind and branch above, crashing each kind immediately after `BeginRetirement` and after each step, restarting from SQLite; a corrupt body under a cleanup; a changed body; a delayed slot write; N1 losing to a deletion then committing without N3); `TestTerminationFencesFirst` (every `refused`/`void` branch: the record's `record_seq` is present and advanced afterwards; a fence that fails leaves the row pending). |
| + - `TestRetirementRecoveryBranches` (one subtest per kind and branch above, crashing each kind immediately after `BeginRetirement` and after each step, restarting from SQLite; a corrupt body under a cleanup; a changed body; a delayed slot write; N1 losing to a deletion then committing without N3); `TestCorruptBodyIsQuarantinedNotFenced` (malformed JSON, not a path mismatch, injected between the observation and the tombstone CAS: the bytes land under `control/quarantine/`, the key is removed, the slot retires, the retirement commits; a later create proceeds); `TestTerminationFencesFirst` (every `refused`/`void` branch that terminates on a **changed record**: `record_seq` present and advanced afterwards; a fence that fails leaves the row pending; the successor-slot branches terminate on slot evidence without a record fence, asserted separately). |
| - `TestDetachMapsLifetimeErrors`. |
| - [ ] **Step 2: Run to verify they fail.** |
| - [ ] **Step 3: Implement.** |
| @@ -212,7 +213,7 @@ Inside `withHostname(host)`: `admit(host, "")`; `obs := classifyLocked` (unreada |
| **Interfaces:** |
| - Produces: `var ErrRecordMigrating = fmt.Errorf("%w: the record is being migrated; read it again", release.ErrCASConflict)`; `func WithMigration(ctx context.Context, id string) context.Context`; `func (s *Server) migrateHostLifetimeLocked(ctx context.Context, acct, app, host, expectETag string) (rec *provision.Record, raw []byte, etag string, state string, err error)` (`expectETag == ""` is unbound: retry from the current record; a real ETag binds the marker CAS to it); `func (s *Server) StartIncarnationMigrationPass(ctx context.Context)` (behind `CARLOS_LIFETIMES_MIGRATE=1` and `lifetimesOn()`, every 5 minutes, at most 50 records per pass, oldest first, each inside `withHostname` and `admit`, only fieldless unmarked records whose slot is absent, unbound). |
| -The protocol (inside the exclusion): `admit(host, "")`; `BeginMigration({OpID, Account, App, Host, ExpectETag, "", pending})`; then **the dispatcher**, `migrationDispatch(obs, m) action`, is the only way in, at the first entry, after every CAS conflict and at every recovery: `own marker` (the record carries `IncarnationMigrating == OpID`) → resume at `SetMigrationMarkedETag` then (2); `own slot and done` (record at `Incarnation 1`, slot `{1, live, this key, "migration " + OpID}`) → `committed`; `eligible` (present, fieldless, unmarked, slot absent; and for a bound call at `ExpectETag`) → (1); `bound and moved` (bound, eligible but another ETag) → `fenceRecord`, `superseded`, `ErrCASConflict`; `unreadable` → pending; anything else (gone, another lifetime, another op's marker, a slot not of this migration) → `fenceRecord` when a record is present, `superseded`. "Resume (1)" anywhere below means "dispatch again". (1): `WriteRecord(WithMigration(WithRecordSequence(ctx), OpID), s, key, raw, raw with IncarnationMigrating = OpID, E)` (the marked record carries `record_seq`); on `ErrCASConflict` → dispatch again (an unbound `eligible` result retries (1) from the fresh record, at most 5 times, then leaves pending); `SetMigrationMarkedETag(OpID, M)` (a SQLite failure → pending, `errLifetimePending`); (2) `WriteHostIncarnation({1, live, Account, App, "migration " + OpID, RecordETag: M}, "")` create-only, or `If-Match` when a slot `{1, live, this key, "migration " + OpID}` exists; a CAS conflict → re-read: this migration's slot → continue; otherwise `superseded`; (3) `WriteRecord(WithMigration(ctx, OpID), ..., Incarnation = 1, IncarnationMigrating = "", against M)` (a conflict → re-read: a record at `Incarnation 1` under this slot → continue; otherwise pending); `committed`. Every other error after `BeginMigration` → pending, `errLifetimePending`; every re-read that fails → pending. |
| +The protocol (inside the exclusion): `admit(host, "")`; `BeginMigration({OpID, Account, App, Host, ExpectETag, "", pending})`; then **the dispatcher**, `migrationDispatch(obs, m) action`, is the only way in, at the first entry, after every CAS conflict and at every recovery: `own marker` (the record carries `IncarnationMigrating == OpID`) → resume at `SetMigrationMarkedETag` then (2); `own slot and done` (record at `Incarnation 1`, slot `{1, live, this key, "migration " + OpID}`) → `committed`; `eligible` (present, fieldless, unmarked, slot absent; and for a bound call at `ExpectETag`) → (1); `bound and moved` (bound, eligible but another ETag) → `fenceRecord`, `superseded`, `ErrCASConflict`; `unreadable` → pending; a corrupt body → `quarantineCorrupt`, `superseded`; anything else (gone, another lifetime, another op's marker, a slot not of this migration) → `fenceRecord` when a record is present, `superseded`. "Resume (1)" anywhere below means "dispatch again". (1): `WriteRecord(WithMigration(WithRecordSequence(ctx), OpID), s, key, raw, raw with IncarnationMigrating = OpID, E)` (the marked record carries `record_seq`); on `ErrCASConflict` → dispatch again (an unbound `eligible` result retries (1) from the fresh record, at most 5 times, then leaves pending); `SetMigrationMarkedETag(OpID, M)` (a SQLite failure → pending, `errLifetimePending`); (2) `WriteHostIncarnation({1, live, Account, App, "migration " + OpID, RecordETag: M}, "")` create-only, or `If-Match` when a slot `{1, live, this key, "migration " + OpID}` exists; a CAS conflict → re-read: this migration's slot → continue; otherwise `superseded`; (3) `WriteRecord(WithMigration(ctx, OpID), ..., Incarnation = 1, IncarnationMigrating = "", against M)` (a conflict → re-read: a record at `Incarnation 1` under this slot → continue; otherwise pending); `committed`. Every other error after `BeginMigration` → pending, `errLifetimePending`; every re-read that fails → pending. |
| Recovery, per pending migration, inside the exclusion with `admit(host, OpID)`: `classifyLocked` then `migrationDispatch`; the `own marker` action continues at `SetMigrationMarkedETag` when `MarkedETag == ""` (then (2)), at (2) when the slot is absent, at (3) when the slot is bound to the record's current ETag. An `eligible` result resumes (1) against the observed ETag (bound: it equals `ExpectETag` by construction); the outstanding original write either lands first, in which case our CAS fails and the next dispatch sees our marker, or ours lands and the original fails: both harmless. Because the dispatcher runs again after every conflict and at every entry, a marker that lands between a classification and the write is always recognised as this operation's. |
| @@ -232,21 +233,21 @@ 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: `getEach` and `fetched` gain `etag`; after the path check and before `ValidHostShape`, `cfg.Judge.ObserveWith(...)`, replacing `rec`/`raw` with the observation's; stale hosts are recorded in `stale map[string]bool`; the removal loop: a row whose host is in `stale` skips the `KeepParticipant` check; `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 and the seed hook; every pass context on the box is `provision.WithJudge(ctx, boxJudge)`: the reconcile loop, the moves pass, the runs worker, 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(provision.WithJudge(r.Context(), s.judge))`, so browser handlers (`placeAppTarget`'s callers in `fleets.go` and `appsettings.go` included) and the API alike carry it; every pass start derives its context with `WithJudge(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` (`RecordIndex.Find` and `FindRecordByHost` read through `JudgeFrom(ctx)`: on a claims-on box or in the root process the context carries no judge and they behave as today; on a claims-off agent the callers' contexts (`proofnamed.go:166,176`, `rootinventory_install.go:62`, `rootrecord_claim.go:40`) are the agent's judged contexts, so a restored stale unit record is not proof evidence; "no root-side change" means the root process (`ClaimRootHTTP`), whose readers are claims-on and see a nil judge), `internal/host/seedwiring.go` (`seedPlacedHook(deploy, data, judge)`: the callback does `ctx = provision.WithJudge(ctx, judge)` first, whatever context the activator hands it; `wireSeedPlacement` passes `boxJudge`) |
| +- Modify: `internal/provision/reconcile.go` (the decode loop at lines 855–941: `getEach` and `fetched` gain `etag`; after the path check and before `ValidHostShape`, `cfg.Judge.ObserveWith(...)`, replacing `rec`/`raw` with the observation's; stale hosts are recorded in `stale map[string]bool`; the removal loop: a row whose host is in `stale` skips the `KeepParticipant` check; `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(provision.WithLifetimes(r.Context(), s.judge))`, 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` (`RecordIndex.Find` and `FindRecordByHost` read through `JudgeFrom(ctx)`), `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; 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:** |
| - Produces: |
| - `type Observation struct { Present bool; Rec *Record; Raw []byte; ETag string; Slot *HostIncarnation; SlotETag string; Verdict LifetimeVerdict; Err error }` (`Rec` nil when absent or corrupt; `Raw`/`ETag` set for a present corrupt body). |
| - `type LifetimeJudge struct{}` (stateless); `func NewLifetimeJudge() *LifetimeJudge`. **A nil judge disables slot checking only**: `Observe` reads the record once and returns `Present`, `Rec`, `Raw`, `ETag` and `Verdict` `valid` for a decodable body, `corrupt` for one that does not decode or disagrees with its path, `unreadable` with the error for a failed read; absence is `Present: false, Verdict: valid`. Every judged reader and listing therefore behaves exactly as its unjudged sibling under a nil judge. |
| - - `func WithJudge(ctx context.Context, j *LifetimeJudge) context.Context`; `func JudgeFrom(ctx context.Context) *LifetimeJudge`. |
| + - `func WithLifetimes(ctx context.Context, j *LifetimeJudge) context.Context` (= `WithRecordSequence(WithJudge(ctx, j))` when `j != nil`; the identity when nil); `func WithJudge(ctx context.Context, j *LifetimeJudge) context.Context`; `func JudgeFrom(ctx context.Context) *LifetimeJudge`. `updateRecordSeeding` and `claimRawUpdateRecord` read `JudgeFrom(ctx)`; the sequence flag they already read is set by the same helper, which is the one enforcement point for both. |
| - `func (j *LifetimeJudge) Observe(ctx context.Context, s release.Store, acct, app, host string) Observation` and `func (j *LifetimeJudge) ObserveWith(ctx context.Context, s release.Store, acct, app, host string, raw []byte, etag string, readErr error) Observation` (round 1 uses the given record read). With a non-nil judge, up to 3 rounds: read the record (`GetRecordRaw`), **then** the slot; a read error in either → `unreadable`; a present decodable record with `LifetimeOf == valid` → `valid`; absent → `{Present: false, Verdict: stale}` when a slot exists, `{Present: false, Verdict: valid}` when none; corrupt → `corrupt` with `Raw`, `ETag`; stale → loop if this is round 1 or the slot's ETag differs from the previous round's; stale with the slot ETag unchanged → `stale`. Exhausted → `unreadable` with an error naming the host. |
| - `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`, resolves every call whose callee is one of `provision.GetRecord`, `GetRecordETag`, `GetRecordRaw`, `ListRecords`, `ListRecordsStrict`, `ListRecordsComplete`, `ListAllRecords`, `ListAllRecordsStrict`, `ListAllRecordsTolerant`, `DecodeRecord`, `FindRecordByHost`, `(*RecordIndex).Find`, or `release.Store.Get` with a `RecordKey(...)` argument, and fails unless the enclosing function is one of the judged readers, the judge itself, `updateRecordSeeding`, `claimRawUpdateRecord`, `WriteRecord`, `TombstoneBody` or `fenceRecord`; **no other exemptions**: a claims-on path reads through a nil judge. |
| + - 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`. **Permitted enclosing functions** for the forbidden set: the judged readers, the judge itself, `updateRecordSeeding`, `claimRawUpdateRecord`, `WriteRecord`, `TombstoneBody`, `fenceRecord`, `quarantineCorrupt`, and a reviewed, named list of claims-on functions that decode a body **captured in an ETag-bound claim observation** without reading the store again (`claim_*.go` in the console and the `hostops` adapters), each listed by resolved symbol. 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 and pickup, `launchable`, the steering loop's desired state, retention's app records, `FindRecordByHost` and `RecordIndex.Find` on the agent all ignore 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); `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); `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. |
| @@ -283,6 +284,7 @@ Each `+1` sets `rec.MoveSeq++` and `rec.Move.Seq = rec.MoveSeq` (nil-safe) in th |
| - `func (r *Registry) InstallLifetimeIf(host string, expect, next Pair) (bool, error)`: `SET incarnation=?, serving_seq=? WHERE host=? AND incarnation=? AND serving_seq=? AND hold_op=''`. |
| **The existing-row rules** (reconcile, a provisioned row of this pass's app and account, in this order, each a separate write under `WithHostHoldLock`): |
| +- (0) **a held predecessor row of an older lifetime whose op is not the record's move** → rule (iv) first, whatever the record asks; only then: |
| - (i) **the record asks for a hold** (`cfg.Hold(rec)` ok; today's `holdExisting`): `SetHoldIfSeq(host, status, op, "", cfg.PairOf(fresh))` from the fresh observation. This covers a legacy row (0), the same lifetime and an unheld predecessor row (`incarnation <= rec.Incarnation`): the successor's lifetime, status and pair land together, so no ready row ever carries a hold's pair. A row already held by another op is left (today). |
| - (ii) **migration, unheld, no hold asked**: `existing.Incarnation == 0 && rec.Incarnation > 0 && existing.HoldOp == ""` → `InstallLifetimeIf(host, {0, existing.ServingSeq}, {rec.Incarnation, existing.ServingSeq})` (the row's own sequence is kept). |
| - (iii) **recreation, unheld, no hold asked**: `0 < existing.Incarnation < rec.Incarnation && existing.HoldOp == ""` → `InstallLifetimeIf(host, {existing.Incarnation, existing.ServingSeq}, {rec.Incarnation, rec.MoveSeq})`. |
| @@ -290,10 +292,10 @@ Each `+1` sets `rec.MoveSeq++` and `rec.Move.Seq = rec.MoveSeq` (nil-safe) in th |
| - (v) **same incarnation, unheld**: nothing; a held row whose op is the record's own move is left to the move. |
| - [ ] **Step 1: Write the failing tests**: |
| - - `TestOfflineTransitionsAdvanceMoveSeq` (one subtest per table row); `TestOfflineRecordDiffersOnlyWhereTheMoveSaysSo` with `move_seq`; `TestOfflineAbort` unchanged. |
| + - `TestOfflineTransitionsAdvanceMoveSeq` (one subtest per table row); `TestMoveSeqExhaustionAtEveryTransition` (`MoveSeq` at `MaxInt64`: start, start-staged, open, abort and finish each return `ErrLifetimeExhausted` with no write); `TestOfflineRecordDiffersOnlyWhereTheMoveSaysSo` with `move_seq`; `TestOfflineAbort` unchanged. |
| - `TestRegistryPairColumns` (and `Fingerprint`); `TestSetHoldIfSeqInstallsOnOlderUnheldRows` (rows at 0, 1 and 2 with a hold at 2: 0 and 1 take status, op and `(2, 1)` in one write, 2 takes it; a row at 3 unchanged; an already-held row unchanged; `SetHoldIf`'s validation errors reproduced); `TestReleaseHoldSeqNamesItsLifetime` (release at 1 against rows at 1 and 0 → ready `(1, s)`; against 2 → unchanged; empty op → no-op); `TestInstallLifetimeIfIsGuarded`. |
| - `TestRouteForCopiesThePair`; `TestReconcileInstallsTheRecordsPairOnAMintedRow` ((1, 3) after finish and after abort-then-finish); `TestAnExistingRowKeepsOpenSequenceThroughFinish`. |
| - - `TestExistingRowsAcrossLifetimes` (subtests run to their end state: **ready legacy row, migrated record** `(0, 0)` + `{1, 0}` → `(1, 0)` once, then unchanged by `{1, 5}`; **ready row, recreation, no move** `(1, 9)` + `{2, 0}` → `(2, 0)`; **ready predecessor row, recreation with a started move** `(1, 9)` unheld + record `{2, 1}` asking a hold → one write to held `(2, 1)`, never a ready row at `(2, 1)` (a hook after every registry write asserts it); **held legacy row, migration during a move** held `(0, 1)` by X, record migrates to 1 → untouched by the installer; open's `ReleaseHoldSeq(X, {1, 2})` releases it to ready `(1, 2)`; the abort path likewise; **hold across migration** ready `(0, 0)` + migrated `{1, 1}` asking a hold → one write; **held predecessor by a vanished op** held `(1, 1)` by X + record `{2, 0}` → `DeleteIfHold` then a ready mint at `(2, 0)` on the same pass; with the callback nil → logged, unchanged; **a stale setter** release for 1 against the successor's row at 2 → no change; **Phase 1 unchanged** `PairOf == nil`, a row carrying `(1, 3)` → `SetHoldIf`/`ReleaseHold`, pair untouched). |
| + - `TestExistingRowsAcrossLifetimes` (subtests run to their end state: **ready legacy row, migrated record** `(0, 0)` + `{1, 0}` → `(1, 0)` once, then unchanged by `{1, 5}`; **ready row, recreation, no move** `(1, 9)` + `{2, 0}` → `(2, 0)`; **ready predecessor row, recreation with a started move** `(1, 9)` unheld + record `{2, 1}` asking a hold → one write to held `(2, 1)`, never a ready row at `(2, 1)` (a hook after every registry write asserts it); **held legacy row, migration during a move** held `(0, 1)` by X, record migrates to 1 → untouched by the installer; open's `ReleaseHoldSeq(X, {1, 2})` releases it to ready `(1, 2)`; the abort path likewise; **hold across migration** ready `(0, 0)` + migrated `{1, 1}` asking a hold → one write; **held predecessor by a vanished op** held `(1, 1)` by X + record `{2, 0}` → `DeleteIfHold` then a ready mint at `(2, 0)` on the same pass; with the callback nil → logged, unchanged; **held predecessor, successor with its own move**: a row held `(1, 1)` by X, a record at `{2, 1}` asking a hold for Y → `DeleteIfHold(X)` then a held mint `(2, 1)` by Y on the same pass; **a stale setter** release for 1 against the successor's row at 2 → no change; **Phase 1 unchanged** `PairOf == nil`, a row carrying `(1, 3)` → `SetHoldIf`/`ReleaseHold`, pair untouched). |
| - In `TestOfflineMoveE2E`: the records are created through the lifetime with the judge on; after `start` the source row is held `(1, 1)`; after `open` the target row is ready `(1, 2)`; a second trace aborting from landed leaves the source ready `(1, 2)` and no target row; the release comes from `armOffline`'s wiring and the re-observed record (a hook that moves the record on before the release makes the release skip). |
| - [ ] **Step 2: Run to verify they fail.** |
| - [ ] **Step 3: Implement.** |
| @@ -332,6 +334,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 one stated deviation from §6.4's text is the uncached slot read; 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`, `retireSlotLocked`, `slotDisposition`, `migrationDispatch`, `WithRecordSequence` (existing), `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`, `quarantineCorrupt`, `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. |