plan: self-serve moves 1, rev 20 (Astra review 19: the box floor is the edges-file capability lifetimes-1; old binaries after the cutover and retention's prune race declared out of scope; restore evidence split by key kind; cancellation plumbing declared)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+7
−7
docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md+7 −7
CI cancelled — superseded by a newer push — run details
CI log
=== gate === .amadan/test-gate.sh amadan: job cancelled — a newer push superseded it
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 63f8c2fa3..87a7d2670 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 19, 2026-10-22. Rev 18's review (`selfserve-plan1-review18.md`: 5, 19, 22, 24 closed; 18, 20 declarations; 23, 25 P1) is answered: **claims-off boxes no longer auto-purge at all** from this release (a check-then-delete can always pause between the two; no fence is possible on a box), the console's expiry pass owns the store trash sweep, and the cutover door **refuses while any box of the fleet reports a version older than this release or no fresh status**, so by the time a marker can exist no box purge code is running (23); the materialisation worker takes `s.lockApp` per app **before** collecting evidence, re-reads the row and the marker under it and holds it through the SQLite transition, candidates being hints only (25); the lifetime the door read travels to the create helpers as an argument, the schema, the `Allocation` struct, the real `AppLife(accountID, app)` and `release.ReadAppTombstone` names, and the SQL audit's patterns are reconciled (18); the lock table no longer lists `retireSlotOrphanLocked` as assuming the app lock (20). Rev 17's review (`selfserve-plan1-review17.md`: 10, 17, 21 closed; 5, 18, 19, 20, 22 narrowed; 23, 24 new) is answered: a box purge is fenced at **every** destructive step (`purgeApp` gains a `before func(ctx) error` hook the box fills with the marker re-read and the 30 s clock, called before each `Delete`, the final marker delete included) (23); the app lifetime is minted by **one insertion helper** every app-row insert uses (`CreateAppInRegion`, `CreateTemporaryAppInRegion`, `BackfillOpsMemberships`, the migration's `apps_new` copy, the open-time backfill), pinned by a test that scans every `INSERT INTO apps`, and the declarations agree (`TEXT`, `Allocation.AppLifetime`, the recovery wording) (18); the create's step 0 also requires a successful read of the store's trash marker as absent (5); the cross-key stale-body cleanup is **best effort within the running create** and the promise is narrowed to that (19); the initial migration pass takes app → hostname and the cross-owner orphan retirement needs only the hostname lock and its captured slot ETag, stated with its reason (20); startup materialisation runs as an admitted worker iteration, never before the gate has a state (22); restore evidence has an explicit **unknown** outcome that defers the decision (24). Rev 16's review (`selfserve-plan1-review16.md`: 5, 10, 17, 18, 19 narrowed; 20, 21, 22 new) is answered: the create protocol checks the app's lifecycle **under the app lock** (present, not trashed, the same `lifetime` the door read) before allocating (5); a box purge re-reads the cutover marker **immediately before every conditional delete** and abandons the purge when more than 30 s passed since that check, so a sweep paused across the cutover deletes nothing numbered (10); cancellation writes the orphan retirement row and the allocation's `void` in **one SQLite transaction** and the retirement names the allocation it cancels, so no two pending rows block each other (17); the app lifetime is a non-repeating `apps.lifetime` id captured by `BeginAllocation` (18); the stale-body promise is narrowed to the same key and the create's step 5 also deletes a stale body under the retired slot's owner key (19); a **lock table** states what every wrapper acquires and every `Locked` helper assumes, and the attach door takes the app lock outside its hostname exclusion (20); the purge's absent-record branch acts only on a slot **owned by the purged app** (21); `settleApp` and `MaterializeApps` count only judged-valid instance records as restore evidence (22). Rev 15's review (`selfserve-plan1-review15.md`: 13, 14, 15, 16 closed; 5, 10, 17, 18, 19 open) is answered with **one lock order** instead of a snapshot: the create protocol, allocation recovery and the console's purge all take `s.lockApp(acct, app)` and then `withHostname(host)`, so a purge and a create or recovery of the same app never interleave, and the purge observes and retires each instance **inside** the same locked step (no delayed callback can see a successor; a legacy record is retired by **ETag**); an allocation is bound to the **app lifetime** that authorised it (`app_lifetime` = the apps row's `created_at`), so a reused name never replays an old allocation; **boxes stop auto-purging once the cutover marker exists** and the console's expiry pass runs the trash sweep under the app lock, so after the cutover the only purger is the one that can see pending allocations; a slot-bound orphan entry point (`retireSlotOrphanLocked`) exists and the void path's cleanup is stated honestly (a late stale body stays until the next create of the hostname removes it in step 5; a late slot PUT leaves a live orphan the orphan rule reclaims). Rev 14's review (`selfserve-plan1-review14.md`: 9, 11, 12 closed; 5, 10, 13, 14, 15, 16 the purge protocol) is answered by **removing the purge protocol**: four rounds of a lifetime-aware purge each found the next race (snapshot vs recovery, in-flight box sweeps, legacy incarnation 0, the app lock, guard ordering, the schema), so purge goes back to **today's sweep** with one conditional step (an instance record is deleted by `DeleteIfMatch` on an ETag a `Get` captured just before), on the console under the existing `s.lockApp` mutex its callers already hold, on the box as today; and the lifetime protocol **absorbs the consequences** with two rules it already nearly had: a live slot whose record is absent and for which this console holds no pending operation is an **orphan**, retired as kind `orphan` by the next create that meets it (so a purged app's slots never block a hostname); and allocation recovery **never replays a create for a trashed or purged app** (it retires the slot, and anything that landed at its incarnation, as `orphan`, and voids the row), so a late record or slot PUT of a purged app's allocation is cleaned by the same recovery. No generation table, no slot fence, no on-marker, no console trash sweep. Rev 13's review (`selfserve-plan1-review13.md`: findings 1 and 8 closed; 5, 9, 10, 11, 12 the purge) is answered by **taking boxes out of purging once lifetimes are on**: a box that has seen `lifetimes-on.json` never purges an app again (the console's expiry pass gains the store-driven trash sweep the box runs today), so no purger exists that cannot see the console's pending allocations; before the on-marker a box purge deletes an instance record only by `DeleteIfMatch` on an ETag it captured **before** its final marker check. The console's purge runs under the app mutation lock, records the **generation** it observed for every host (slot incarnation or record ETag) before any destructive step, and every retirement states that expectation (never `retireAny`), so a delayed callback cannot touch a successor; a pending allocation is terminalised by **fencing its slot** (a CAS write of a retired slot at its incarnation, against absence or the predecessor's ETag) so an outstanding allocation PUT can never land, then `void`; the purge calls the **locked** retirement under its own `withHostname`; the box has no retirement code at all; the witnesses are split into console and box expectations with their real terminal states. Rev 12's review (`selfserve-plan1-review12.md`: findings 2, 3, 4, 6, 7 closed; 1 a witness contradiction; 5, 8, 9 the purge) is answered: the restart witness no longer activates "at once"; the console writes a durable **`control/lifetimes-on.json`** as the first act of `on`, and that marker, not judge availability, is a box's authority for any slot mutation; purge **excludes instance-record keys from its sweep** whenever a retire hook is installed; the console's purge **voids the app's pending allocations durably** (`purged_at`) and retires their slots before touching records, and recovery treats a purged pending row as `void`; the box's purge needs **no migration, no journal and no cross-process lock**: it retires by CAS alone (slot CAS to `retired`, then `DeleteIfMatch` on the observed record ETag; a marked record is skipped; any CAS loss returns an error and the next pass retries idempotently), and before the on-marker it deletes only by `DeleteIfMatch` on the listed ETag after re-reading the marker. Rev 12'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. |
| +Rev 20, 2026-10-22. Rev 19's review (`selfserve-plan1-review19.md`: 18, 20, 25 closed; 23, 26, 27, 28 open) is answered: the box floor is a **capability in the edges file** (`lifetimes-1`, the inventory the console already reads for every fleet decision), the cutover door refuses while any edges file lacks it or is stale, and a box whose file later loses it pages "box <label> regressed below the lifetimes floor" (23); **the return of an older box binary after the cutover, and retention's prune-versus-recreate race, are declared out of scope**: neither is introduced or widened by this plan, both are today's exposure on `main`, and a fleet-wide binary floor is its own piece of work, filed as an amadan discussion (23, 26); restore evidence keeps today's value for every ordinary app object and applies the three-valued lifetime judgement to instance-record keys alone (27); the cancellation plumbing is declared (`record_retirements.cancels`, `Retirement.Cancels`, `BeginCancellingRetirement`) (28); the surviving statements about boxes purging before the cutover are removed. Rev 18's review (`selfserve-plan1-review18.md`: 5, 19, 22, 24 closed; 18, 20 declarations; 23, 25 P1) is answered: **claims-off boxes no longer auto-purge at all** from this release (a check-then-delete can always pause between the two; no fence is possible on a box), the console's expiry pass owns the store trash sweep, and the cutover door **refuses while any box of the fleet reports a version older than this release or no fresh status**, so by the time a marker can exist no box purge code is running (23); the materialisation worker takes `s.lockApp` per app **before** collecting evidence, re-reads the row and the marker under it and holds it through the SQLite transition, candidates being hints only (25); the lifetime the door read travels to the create helpers as an argument, the schema, the `Allocation` struct, the real `AppLife(accountID, app)` and `release.ReadAppTombstone` names, and the SQL audit's patterns are reconciled (18); the lock table no longer lists `retireSlotOrphanLocked` as assuming the app lock (20). Rev 17's review (`selfserve-plan1-review17.md`: 10, 17, 21 closed; 5, 18, 19, 20, 22 narrowed; 23, 24 new) is answered: a box purge is fenced at **every** destructive step (`purgeApp` gains a `before func(ctx) error` hook the box fills with the marker re-read and the 30 s clock, called before each `Delete`, the final marker delete included) (23); the app lifetime is minted by **one insertion helper** every app-row insert uses (`CreateAppInRegion`, `CreateTemporaryAppInRegion`, `BackfillOpsMemberships`, the migration's `apps_new` copy, the open-time backfill), pinned by a test that scans every `INSERT INTO apps`, and the declarations agree (`TEXT`, `Allocation.AppLifetime`, the recovery wording) (18); the create's step 0 also requires a successful read of the store's trash marker as absent (5); the cross-key stale-body cleanup is **best effort within the running create** and the promise is narrowed to that (19); the initial migration pass takes app → hostname and the cross-owner orphan retirement needs only the hostname lock and its captured slot ETag, stated with its reason (20); startup materialisation runs as an admitted worker iteration, never before the gate has a state (22); restore evidence has an explicit **unknown** outcome that defers the decision (24). Rev 16's review (`selfserve-plan1-review16.md`: 5, 10, 17, 18, 19 narrowed; 20, 21, 22 new) is answered: the create protocol checks the app's lifecycle **under the app lock** (present, not trashed, the same `lifetime` the door read) before allocating (5); a box purge re-reads the cutover marker **immediately before every conditional delete** and abandons the purge when more than 30 s passed since that check, so a sweep paused across the cutover deletes nothing numbered (10); cancellation writes the orphan retirement row and the allocation's `void` in **one SQLite transaction** and the retirement names the allocation it cancels, so no two pending rows block each other (17); the app lifetime is a non-repeating `apps.lifetime` id captured by `BeginAllocation` (18); the stale-body promise is narrowed to the same key and the create's step 5 also deletes a stale body under the retired slot's owner key (19); a **lock table** states what every wrapper acquires and every `Locked` helper assumes, and the attach door takes the app lock outside its hostname exclusion (20); the purge's absent-record branch acts only on a slot **owned by the purged app** (21); `settleApp` and `MaterializeApps` count only judged-valid instance records as restore evidence (22). Rev 15's review (`selfserve-plan1-review15.md`: 13, 14, 15, 16 closed; 5, 10, 17, 18, 19 open) is answered with **one lock order** instead of a snapshot: the create protocol, allocation recovery and the console's purge all take `s.lockApp(acct, app)` and then `withHostname(host)`, so a purge and a create or recovery of the same app never interleave, and the purge observes and retires each instance **inside** the same locked step (no delayed callback can see a successor; a legacy record is retired by **ETag**); an allocation is bound to the **app lifetime** that authorised it (`app_lifetime` = the apps row's `created_at`), so a reused name never replays an old allocation; **boxes stop auto-purging once the cutover marker exists** and the console's expiry pass runs the trash sweep under the app lock, so after the cutover the only purger is the one that can see pending allocations; a slot-bound orphan entry point (`retireSlotOrphanLocked`) exists and the void path's cleanup is stated honestly (a late stale body stays until the next create of the hostname removes it in step 5; a late slot PUT leaves a live orphan the orphan rule reclaims). Rev 14's review (`selfserve-plan1-review14.md`: 9, 11, 12 closed; 5, 10, 13, 14, 15, 16 the purge protocol) is answered by **removing the purge protocol**: four rounds of a lifetime-aware purge each found the next race (snapshot vs recovery, in-flight box sweeps, legacy incarnation 0, the app lock, guard ordering, the schema), so purge goes back to **today's sweep** with one conditional step (an instance record is deleted by `DeleteIfMatch` on an ETag a `Get` captured just before), on the console under the existing `s.lockApp` mutex its callers already hold, on the box as today; and the lifetime protocol **absorbs the consequences** with two rules it already nearly had: a live slot whose record is absent and for which this console holds no pending operation is an **orphan**, retired as kind `orphan` by the next create that meets it (so a purged app's slots never block a hostname); and allocation recovery **never replays a create for a trashed or purged app** (it retires the slot, and anything that landed at its incarnation, as `orphan`, and voids the row), so a late record or slot PUT of a purged app's allocation is cleaned by the same recovery. No generation table, no slot fence, no on-marker, no console trash sweep. Rev 13's review (`selfserve-plan1-review13.md`: findings 1 and 8 closed; 5, 9, 10, 11, 12 the purge) is answered by **taking boxes out of purging once lifetimes are on**: a box that has seen `lifetimes-on.json` never purges an app again (the console's expiry pass gains the store-driven trash sweep the box runs today), so no purger exists that cannot see the console's pending allocations; before the on-marker a box purge deletes an instance record only by `DeleteIfMatch` on an ETag it captured **before** its final marker check. The console's purge runs under the app mutation lock, records the **generation** it observed for every host (slot incarnation or record ETag) before any destructive step, and every retirement states that expectation (never `retireAny`), so a delayed callback cannot touch a successor; a pending allocation is terminalised by **fencing its slot** (a CAS write of a retired slot at its incarnation, against absence or the predecessor's ETag) so an outstanding allocation PUT can never land, then `void`; the purge calls the **locked** retirement under its own `withHostname`; the box has no retirement code at all; the witnesses are split into console and box expectations with their real terminal states. Rev 12's review (`selfserve-plan1-review12.md`: findings 2, 3, 4, 6, 7 closed; 1 a witness contradiction; 5, 8, 9 the purge) is answered: the restart witness no longer activates "at once"; the console writes a durable **`control/lifetimes-on.json`** as the first act of `on`, and that marker, not judge availability, is a box's authority for any slot mutation; purge **excludes instance-record keys from its sweep** whenever a retire hook is installed; the console's purge **voids the app's pending allocations durably** (`purged_at`) and retires their slots before touching records, and recovery treats a purged pending row as `void`; the box's purge needs **no migration, no journal and no cross-process lock**: it retires by CAS alone (slot CAS to `retired`, then `DeleteIfMatch` on the observed record ETag; a marked record is skipped; any CAS loss returns an error and the next pass retries idempotently), and before the on-marker it deletes only by `DeleteIfMatch` on the listed ETag after re-reading the marker. Rev 12'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. |
| @@ -18,7 +18,7 @@ Rev 19, 2026-10-22. Rev 18's review (`selfserve-plan1-review18.md`: 5, 19, 22, 2 |
| - Every claims-off consumer acts on an **observation** (Task 6): a record and the slot read after it, judged `valid`, `stale`, `corrupt` or `unreadable`, with `Present` saying whether a body was there. `stale` is absent; `corrupt` is today's unreadable body (reconcile protects; the delete rule may remove it conditionally); `unreadable` cannot be judged this pass (reconcile protects the row, a door answers 502, a listing reports the host unreadable). |
| - The slot is never deleted and never advanced to abandon a create (spec §6.4). It never moves backwards: every slot write names the exact prior state it replaces. A malformed slot body is `unreadable`, never `stale`. |
| - One per-hostname exclusion (`withHostname`, Task 3) around create, retirement, migration and their recovery in the console process. A persisted pending operation for a hostname refuses any create or retirement of that hostname by another operation (409 "another change to this instance is still settling. try again in a minute") until recovery resolves it; unreadable evidence leaves it pending and keeps refusing. After an allocation, retirement or migration row is durable, no failure returns an ordinary error to the caller: the outcome is `errLifetimePending` (the intent stays pending; recovery owns it), a persisted terminal state, or success. In every recovery table a re-read that fails is `unreadable` and leaves the row pending; it never selects a terminal branch. |
| -- Every record write stays on `provision.WriteRecord` with the exact bytes read and the sequence discipline of `record_sequence.go`; no new raw `Put` of a record key. Every claims-off record **update** runs with a judge on its context (`provision.WithJudge`, Task 6), so `updateRecordSeeding` refuses a stale record before it mutates anything. Every record removal on a claims-off console is `retireRecordWithLifetime` (Task 4) with an explicit expectation, **including app purge** on the console after the cutover (Task 4: each instance retired under the app and hostname locks with the expectation observed in that step); a box's pre-cutover purge keeps today's sweep with conditional instance deletes; `provision.DeleteRecord` gains no new caller and loses its other claims-off instance callers. |
| +- Every record write stays on `provision.WriteRecord` with the exact bytes read and the sequence discipline of `record_sequence.go`; no new raw `Put` of a record key. Every claims-off record **update** runs with a judge on its context (`provision.WithJudge`, Task 6), so `updateRecordSeeding` refuses a stale record before it mutates anything. Every record removal on a claims-off console is `retireRecordWithLifetime` (Task 4) with an explicit expectation, **including app purge** on the console after the cutover (Task 4: each instance retired under the app and hostname locks with the expectation observed in that step); a claims-off box purges nothing from this release on (Task 4); `provision.DeleteRecord` gains no new caller and loses its other claims-off instance callers. |
| - `release.DeleteIfMatch` never falls back to a plain delete; absence counts as success. |
| - No console write to a bucket outside the deployment bucket. On the box, `ClaimRootHTTP`'s claims-on readers are untouched; root's proof and restart record readers take the judge (Task 6). |
| - Census: `TestPlan14RootReadCensus` and `TestPlan9B8RecordWriteCensus` are the authority on what they cover. The root-read overlay is `internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv`, loaded last (before the comparison) by a new block at the end of the test, `rev3=PROPOSED-self-serve-moves-1`, with an `@fields` line naming the new record fields and registry columns, rows for every changed unit and envelope the test names, reviewed dispositions for changed frozen units, and the note `docs/superpowers/notes/census-proposal-self-serve-moves-1.md`. The record-write inventory is `plan14SelfServeMoves1WriteCensusInventory()`, wrapping `plan14ClaimRosterBindingWriteCensusInventory()` with every added, changed and removed site the scanner reports, each reviewed. Both tests green. |
| @@ -105,12 +105,12 @@ The verdict, exactly, with `owned := slot != nil && slot.Account == rec.Account |
| **Interfaces:** |
| - Tables (`account` is the sqid): |
| - `host_allocations (op_id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, incarnation INTEGER, state TEXT, body BLOB, predecessor_etag TEXT, stale_etag TEXT, app_lifetime TEXT, created_at INTEGER)` (`app_lifetime TEXT`: the apps row's **`lifetime`** at allocation time, a new column `apps.lifetime TEXT NOT NULL DEFAULT ''`: a random 16-hex id minted by **one helper `insertAppRow`** that every app-row insertion goes through (`CreateAppInRegion`, `CreateTemporaryAppInRegion`, `BackfillOpsMemberships`, the `apps_new` migration copy, which mints for rows without one), plus a one-time backfill at store open for rows still `''`; kept by trash, restore and keep; a purge removes the row and a recreate mints a new id, so equal values mean the same app lifetime; `created_at` is seconds and is not used; `TestEveryAppInsertMintsALifetime` scans the console package's SQL for `INSERT( OR IGNORE| OR REPLACE)? INTO apps(_new)?` and fails on any statement outside `insertAppRow` and the migration copy, and creates an app by every constructor asserting a non-empty, distinct lifetime); `Allocation.AppLifetime string` is the struct field |
| - - `record_retirements (op_id TEXT PRIMARY KEY, kind TEXT, account TEXT, app TEXT, host TEXT, incarnation INTEGER, record_etag TEXT, slot_etag TEXT, slot_intent TEXT, state TEXT, created_at INTEGER)` (`kind` is `normal`, `orphan` or `cleanup`) |
| + - `record_retirements (op_id TEXT PRIMARY KEY, kind TEXT, account TEXT, app TEXT, host TEXT, incarnation INTEGER, record_etag TEXT, slot_etag TEXT, slot_intent TEXT, cancels TEXT, state TEXT, created_at INTEGER)` (`cancels` names the allocation an orphan retirement cancels, `''` otherwise) (`kind` is `normal`, `orphan` or `cleanup`) |
| - `record_migrations (op_id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, expect_etag TEXT, marked_etag TEXT, state TEXT, created_at INTEGER)` |
| - Classification in `claimSQLTables`: `{Sqid: "account", App: "app"}` for all three. |
| - States: `pending`, `committed`, `superseded`, `void`, `refused`. |
| - On `*Store`: `BeginAllocation(a Allocation) error`, `SetAllocationState(opID, state string) error`, `PendingAllocations() ([]Allocation, error)`, `LastIncarnation(host string) (uint64, error)` (the highest `incarnation` over every state but `void`; 0 when none); `BeginRetirement(r Retirement) error`, `SetRetirementState(opID, state string) error`, `PendingRetirements() ([]Retirement, error)`; `BeginMigration(m Migration) error`, `SetMigrationMarkedETag(opID, etag string) error`, `SetMigrationState(opID, state string) error`, `PendingMigrations() ([]Migration, error)`; `PendingForHost(host, exceptOpID string) (int, error)`. |
| -- `type Allocation struct { OpID, Account, App, Host string; Incarnation uint64; State string; Body []byte; PredecessorETag, StaleETag, AppLifetime string }`, `type Retirement struct { OpID, Kind, Account, App, Host string; Incarnation uint64; RecordETag, SlotETag, SlotIntent, State string }`, `type Migration struct { OpID, Account, App, Host, ExpectETag, MarkedETag, State string }`. |
| +- `type Allocation struct { OpID, Account, App, Host string; Incarnation uint64; State string; Body []byte; PredecessorETag, StaleETag, AppLifetime string }`, `type Retirement struct { OpID, Kind, Account, App, Host string; Incarnation uint64; RecordETag, SlotETag, SlotIntent, Cancels, State string }`; `func (s *Store) BeginCancellingRetirement(r Retirement, voidAllocation string) error` (one transaction: inserts `r` with `Cancels = voidAllocation` and sets that allocation's state to `void`; the atomic handover Task 3's cancellation uses); `PendingRetirementsCancellingFirst()` orders recovery, `type Migration struct { OpID, Account, App, Host, ExpectETag, MarkedETag, State string }`. |
| - [ ] **Step 1: Write the failing tests**: `TestLifetimeTablesRoundTrip`; `TestLastIncarnationSkipsVoid` (pending 2 and void 3 → 2); `TestPendingForHostCountsAllThreeExceptOwn`; `TestClaimStoreOpensWithLifetimeTables`. |
| - [ ] **Step 2: Run to verify they fail.** |
| @@ -179,7 +179,7 @@ Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host, |
| **Files:** |
| - Create: `internal/console/lifetimes_retire.go` |
| -- **App purge.** Two regimes, split by the cutover marker. **A claims-off box auto-purges nothing from this release on**: `domains.Reconcile` 1d is removed for `!cfg.HostClaim` (the `HostClaim` branch's `RequestClaimAutoPurge` is unchanged); a box's check-then-delete can always pause between the check and the delete, so no marker or clock on a box can fence a sweep against a successor, and the only safe fence is for the code not to run. The console's expiry pass owns the store-driven trash sweep (below). Because boxes roll before the console (the roll order rule), the sweep pauses between the box roll and the console roll (a purge is a three-day-cooloff event; the pause is bounded by the roll); **the cutover door refuses** (409 "box <label> runs <version>; every box must run this release before the cutover") while any box in the deployment's fleet (`status/boxes/*.json`) reports a `Version` older than the console's release or has no report within 10 minutes, so by the time the cutover marker can exist no box purge code is running anywhere; a decommissioned box is excluded with `carlos system lifetimes cutover -ignore-box <label>` (recorded in the audit line). **The console** (`handlePurgeApp`, `purgeOne`, and a new hourly `release.ExpiredTrashApps(ctx, s, now)` sweep in `StartExpiryPass` that runs whether or not the pass has rows, so headless apps are purged once boxes stop; `HostClaim` deployments keep `RequestClaimAutoPurge` unchanged) purges through `s.purgeAppWithLifetimes(ctx, acct, app, want deletedAt, now)`, every caller already holding or taking **`s.lockApp(acct, app)`**: (1) **the guards first**: `release.PurgeAppGuards(ctx, s, pub, acct, app, want, now)` (the marker, its clock, the production flag and the store-estate guard exactly as `PurgeAppIfTrashedAt` applies them today, factored out so they run before any lifetime step; a refusal ends the purge with today's sentence); (2) **per instance key** under the app prefix, and per pending allocation of the app (`PendingLifetimesForApp(acct, app)`), under `withHostname(host)`: observe (`classifyLocked`); a pending allocation of this app → the lifetime check of Task 3 fails by construction (the app is trashed) → `retireSlotOrphanLocked` when the slot is the allocation's, `void`; a present record → `retireRecordWithLifetimeLocked(ctx, acct, app, host, normal, RetireExpectation{Incarnation: obs.Rec.Incarnation})` when numbered, `RetireExpectation{ETag: obs.ETag}` when legacy (never `retireAny`; the observation and the retirement are one locked step, so nothing can be observed stale); absent with a live slot **owned by this app** (`slot.Account == acct && slot.App == app`) → `retireSlotOrphanLocked`; absent with a live slot owned by **another** key → nothing (a foreign slot is never purge's; whether it is an orphan is the create rule's question, asked against the slot owner's own key); absent with no slot → nothing; any `errLifetimePending` → the purge stops here and re-runs later (every step idempotent); (3) `purgeApp`'s sweep with instance keys **excluded** (the retire hook is the no-op marker that the keys were handled), then the trash marker. Because create, recovery and purge all take the app lock before the hostname lock, no create or recovery of this app runs while the purge does, and the app name is released (the marker deleted, the apps row removed) only after every instance of this app lifetime is retired, so a successor under the same name starts with no slot or record of its predecessor to collide with. **Restore evidence**: `settleApp` and `MaterializeApps` today count any object under the app prefix as evidence the app lives; in lifetimes mode the evidence is **three-valued**: `ListRecordsComplete`'s judged sibling returns the valid records **and** whether any key was unreadable, corrupt or truncated; a valid record is evidence the app lives; an established stale or absent body contributes nothing; any unreadable, corrupt or truncated observation makes the outcome **unknown**, and `settleApp`/`MaterializeApps` then keep the row and defer (no `PurgeAppRow`, no `FinishRestoreRow`, logged once per hour); **as a worker, materialisation takes `s.lockApp(acct, app)` per candidate before collecting any evidence**, re-reads the row and the marker under the lock, and holds it through `PurgeAppRow`/`FinishRestoreRow` (its initial listing is a hint, as the expiry pass's rows are), so a settle, purge, recreate or re-trash by another operation never has its predecessor's evidence applied to it (`TestMaterializationHoldsTheAppLock`: a candidate settled and recreated as a new lifetime while materialisation waits for the lock → re-read, nothing applied; a re-trash → not restored), so a stale body never turns a finished purge into a restore and a failed read never releases a name whose records still exist (`TestRestoreEvidenceIsThreeValued`); **startup**: `NewServer` no longer calls `materializeQuietly` directly; materialisation is the first iteration of a worker that admits like every other (Task 0's lifecycle: `before` → legacy as today; `on` → judged; `unknown` → wait), so no nil-judge listing runs before the gate has a state, and claims-on keeps today's call; `TestStaleBodyIsNotRestoreEvidence` pins it (a purge whose `PurgeAppRow` write failed, a stale body under the prefix: `settleApp` releases the name instead of restoring). |
| +- **App purge.** Two regimes, split by the cutover marker. **A claims-off box auto-purges nothing from this release on**: `domains.Reconcile` 1d is removed for `!cfg.HostClaim` (the `HostClaim` branch's `RequestClaimAutoPurge` is unchanged); a box's check-then-delete can always pause between the check and the delete, so no marker or clock on a box can fence a sweep against a successor, and the only safe fence is for the code not to run. The console's expiry pass owns the store-driven trash sweep (below). Because boxes roll before the console (the roll order rule), the sweep pauses between the box roll and the console roll (a purge is a three-day-cooloff event; the pause is bounded by the roll); **the box floor is an edges-file capability**: a box running this release publishes `lifetimes-1` in its edges file's `capabilities` (`edgesFileCapabilities`, beside `offline-stage-1` and `region-ha-1`); the cutover door reads every edges file of the deployment (`readOfflineFleet`'s inventory, the same files every fleet decision uses; a box with no edges file is not a box of the fleet) and refuses (409 "box <label> does not publish lifetimes-1; every box must run this release before the cutover") while any file lacks the capability or is older than two edges-file refresh intervals; a decommissioned box's file is removed by the operator as today. After the cutover the console's edges-file reader pages once per hour "box <label> regressed below the lifetimes floor" when a file without the capability appears. **Declared out of scope, with the reason**: an older box binary that starts or returns after the cutover (a rollback, a cloned image) is not excluded by this plan: its legacy purge and retention deletes are exactly today's exposure on `main` (two uncoordinated deleters of app objects), this plan neither introduces nor widens it, and the enforced fleet-wide binary floor it would need (a joining gate for claims-off boxes) is its own piece of work, filed as an amadan discussion on the branch; the operator rule (c) already forbids reintroducing an older console, and the same rule is stated for boxes in the rollout note. **The console** (`handlePurgeApp`, `purgeOne`, and a new hourly `release.ExpiredTrashApps(ctx, s, now)` sweep in `StartExpiryPass` that runs whether or not the pass has rows, so headless apps are purged once boxes stop; `HostClaim` deployments keep `RequestClaimAutoPurge` unchanged) purges through `s.purgeAppWithLifetimes(ctx, acct, app, want deletedAt, now)`, every caller already holding or taking **`s.lockApp(acct, app)`**: (1) **the guards first**: `release.PurgeAppGuards(ctx, s, pub, acct, app, want, now)` (the marker, its clock, the production flag and the store-estate guard exactly as `PurgeAppIfTrashedAt` applies them today, factored out so they run before any lifetime step; a refusal ends the purge with today's sentence); (2) **per instance key** under the app prefix, and per pending allocation of the app (`PendingLifetimesForApp(acct, app)`), under `withHostname(host)`: observe (`classifyLocked`); a pending allocation of this app → the lifetime check of Task 3 fails by construction (the app is trashed) → `retireSlotOrphanLocked` when the slot is the allocation's, `void`; a present record → `retireRecordWithLifetimeLocked(ctx, acct, app, host, normal, RetireExpectation{Incarnation: obs.Rec.Incarnation})` when numbered, `RetireExpectation{ETag: obs.ETag}` when legacy (never `retireAny`; the observation and the retirement are one locked step, so nothing can be observed stale); absent with a live slot **owned by this app** (`slot.Account == acct && slot.App == app`) → `retireSlotOrphanLocked`; absent with a live slot owned by **another** key → nothing (a foreign slot is never purge's; whether it is an orphan is the create rule's question, asked against the slot owner's own key); absent with no slot → nothing; any `errLifetimePending` → the purge stops here and re-runs later (every step idempotent); (3) `purgeApp`'s sweep with instance keys **excluded** (the retire hook is the no-op marker that the keys were handled), then the trash marker. Because create, recovery and purge all take the app lock before the hostname lock, no create or recovery of this app runs while the purge does, and the app name is released (the marker deleted, the apps row removed) only after every instance of this app lifetime is retired, so a successor under the same name starts with no slot or record of its predecessor to collide with. **Restore evidence**: `settleApp` and `MaterializeApps` today count any object under the app prefix as evidence the app lives; in lifetimes mode **ordinary app objects (releases, channels, configuration, ledgers, promotion records) keep today's evidence value unchanged**, and the lifetime judgement applies to **instance-record keys alone**, where the evidence is **three-valued**: `ListRecordsComplete`'s judged sibling returns the valid records **and** whether any key was unreadable, corrupt or truncated; a valid record is evidence the app lives; an established stale or absent body contributes nothing; any unreadable, corrupt or truncated observation makes the outcome **unknown**, and `settleApp`/`MaterializeApps` then keep the row and defer (no `PurgeAppRow`, no `FinishRestoreRow`, logged once per hour); **as a worker, materialisation takes `s.lockApp(acct, app)` per candidate before collecting any evidence**, re-reads the row and the marker under the lock, and holds it through `PurgeAppRow`/`FinishRestoreRow` (its initial listing is a hint, as the expiry pass's rows are), so a settle, purge, recreate or re-trash by another operation never has its predecessor's evidence applied to it (`TestMaterializationHoldsTheAppLock`: a candidate settled and recreated as a new lifetime while materialisation waits for the lock → re-read, nothing applied; a re-trash → not restored), so a stale body never turns a finished purge into a restore and a failed read never releases a name whose records still exist (`TestRestoreEvidenceIsThreeValued`); **startup**: `NewServer` no longer calls `materializeQuietly` directly; materialisation is the first iteration of a worker that admits like every other (Task 0's lifecycle: `before` → legacy as today; `on` → judged; `unknown` → wait), so no nil-judge listing runs before the gate has a state, and claims-on keeps today's call; `TestStaleBodyIsNotRestoreEvidence` pins it (a purge whose `PurgeAppRow` write failed, a stale body under the prefix: `settleApp` releases the name instead of restoring). |
| - 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` |
| @@ -209,7 +209,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: `TestConsolePurgeRetiresEveryLifetime` (browser purge, the expiry pass's row purge and its hourly store sweep (an app with no console row), all holding `s.lockApp`: the guards run before any lifetime step and a refused purge retires nothing; every record ends absent with a retired slot (numbered by incarnation, legacy by ETag); a pending allocation with a live slot and no record is voided and its slot retired; one with no slot is voided; a create of the same app blocked on the app lock during the purge finds the app trashed and refuses; a recovery pass blocked on the app lock finds the lifetime mismatch and voids; the hostname is then created by another app at N+1; an `errLifetimePending` mid-purge leaves the marker and the re-run completes); `TestOrphanSlotReclaimedByTheNextCreate` (a live slot with no record and no pending op, from a lost allocation row, is retired as `orphan` and the create allocates N+1; with a pending op it is `*hostHeldError`; a stale body under the owner key is deleted in step 5 by a create under another key); `TestCreateChecksTheAppUnderTheLock` (a create that read a live app, blocked on the app lock across a trash → 409 trash, including a trash whose SQLite row update failed after the marker landed; across a purge → 404; across a purge and a recreate under the same name, by the permanent and by the temporary constructor → 409 changed, nothing allocated; an unreadable marker → 502, nothing allocated); `TestCancellationIsOneTransaction` (a crash anywhere between the void and O1 leaves exactly one pending row, the retirement, which completes); `TestBoxesNoLongerPurge` (a claims-off box with an expired trash marker purges nothing; a claims-on box still requests its auto-purge); `TestCutoverRefusesOldBoxes` (a fleet with one box on the previous release → 409 naming it; no fresh status → 409; `-ignore-box` for it → accepted and audited); |
| +- [ ] **Step 1: Write the failing tests** through the real doors and the function: `TestConsolePurgeRetiresEveryLifetime` (browser purge, the expiry pass's row purge and its hourly store sweep (an app with no console row), all holding `s.lockApp`: the guards run before any lifetime step and a refused purge retires nothing; every record ends absent with a retired slot (numbered by incarnation, legacy by ETag); a pending allocation with a live slot and no record is voided and its slot retired; one with no slot is voided; a create of the same app blocked on the app lock during the purge finds the app trashed and refuses; a recovery pass blocked on the app lock finds the lifetime mismatch and voids; the hostname is then created by another app at N+1; an `errLifetimePending` mid-purge leaves the marker and the re-run completes); `TestOrphanSlotReclaimedByTheNextCreate` (a live slot with no record and no pending op, from a lost allocation row, is retired as `orphan` and the create allocates N+1; with a pending op it is `*hostHeldError`; a stale body under the owner key is deleted in step 5 by a create under another key); `TestCreateChecksTheAppUnderTheLock` (a create that read a live app, blocked on the app lock across a trash → 409 trash, including a trash whose SQLite row update failed after the marker landed; across a purge → 404; across a purge and a recreate under the same name, by the permanent and by the temporary constructor → 409 changed, nothing allocated; an unreadable marker → 502, nothing allocated); `TestCancellationIsOneTransaction` (a crash anywhere between the void and O1 leaves exactly one pending row, the retirement, which completes); `TestBoxesNoLongerPurge` (a claims-off box with an expired trash marker purges nothing; a claims-on box still requests its auto-purge); `TestCutoverRefusesBoxesBelowTheFloor` (a fleet with one edges file lacking `lifetimes-1` → 409 naming it; a stale file → 409; all present → accepted; a file losing the capability after the cutover → the hourly page); `TestRestoreEvidenceKeepsOrdinaryObjects` (an app with releases and channels and no instance records, restore's SQLite update failed → settlement restores the row, never purges it; the same app with only a stale instance body → released); |
| - `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). |
| @@ -359,6 +359,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`, `AppLife`, `host_allocations.app_lifetime`, `retireSlotOrphanLocked`, `purgeAppWithLifetimes`, `release.PurgeAppGuards`, `release.ExpiredTrashApps`, `insertAppRow`, `-ignore-box`, the orphan rule, `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`. |
| +- 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`, `AppLife`, `host_allocations.app_lifetime`, `retireSlotOrphanLocked`, `purgeAppWithLifetimes`, `release.PurgeAppGuards`, `release.ExpiredTrashApps`, `insertAppRow`, `lifetimes-1`, `Retirement.Cancels`, `BeginCancellingRetirement`, `PendingRetirementsCancellingFirst`, the orphan rule, `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. |