carlos / platform Public

plan: claim Plan 8 rev 4, answers review round 3 (one P1, one P2)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev a3da699d603eeb183880329537c43d01b886d162 parent c3ea8fb
1 file changed, +149 −89
  • docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md +149 −89

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md
index f0cab8138..36bf3e214 100644
--- a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md
+++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md
@@ -1,8 +1,8 @@
# Host Lifecycle Claim, Plan 8: inert lease and lost-box contracts
-Rev 3 answers review round 2 (one P1, two P2).
+Rev 4 answers review round 3 (one P1, one P2).
-This plan follows claim spec **rev 6.20**, §7.3 D2–D6. These contracts remain **PROPOSED**. Paul has not ruled on P1–P3 in §10.1 and amadan discussion **#236**. Landing these libraries does not authorize activation.
+This plan follows claim spec **rev 6.22**, §7.3 D2–D6. These contracts remain **PROPOSED**. Paul has not ruled on P1–P3 in §10.1 and amadan discussion **#236**. Landing these libraries does not authorize activation.
> **For agentic workers:** implement the checked tasks in order. This document contains complete new files and exact patches for existing files. The branch and gate commands belong to the later implementation session. Authoring this plan changes only this Markdown file: no branch change, staging, commit or production action.
@@ -10,20 +10,22 @@ This plan follows claim spec **rev 6.20**, §7.3 D2–D6. These contracts remain
**Architecture:** reuse `release.Store` conditional writes and `DeleteIfMatch`, with the existing lease package's S3 configuration. Keep the specified two-field execution record separate from the replica lease. `hostexec` owns local controller exclusion and deadlines. `hostclaim` owns conditional journal and loss changes. `hostquarantine` consumes an `ActorDirectory` interface; only a test fake exists here. `awsinv` supplies a read-only exact-instance check, and `hostfence` supplies a challenge signed with the console's existing root key. No new live caller can mutate a claim through the CLI or console routes in this plan.
-**Depends on:** Plans 1 and 3. In a clean implementation checkout:
+**Depends on:** Plans 1 and 3, plus E’s follow-up branch `feat/hostclaim-r713` for the native multiple-declaration API in operative §6.4a item 2. Once r713 is pushed, use a clean implementation checkout:
```bash
+git fetch origin
git switch -c feat/hostclaim-leases feat/hostclaim-boxguard
git merge origin/main
+git merge origin/feat/hostclaim-r713
```
Use the repository's reviewed-branch workflow when the implementation is ready. Do not run these commands while editing this plan.
## Sources and boundaries
-- `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev 6.20: §2, C1–C6, §6.1, §6.3, operative §6.4a, §7.1–§7.3 and §10–§10.1. §7.3 supplies the proposed claim contracts; §10.1 records the still-open approval boundaries.
+- `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev 6.22: §2, C1–C6, §6.1, §6.3, operative §6.4a, §7.1–§7.3 and §10–§10.1. §7.3 supplies the proposed claim contracts; §10.1 records the still-open approval boundaries.
- Plans 2, 3, 4, 7 and 7b in this directory supply the storage, local guard, retirement and consumer conventions. Plan 3's R9 applies to every local lease file and lock. Plan 4 owns immutable parking scopes and the late-completion horizon; this plan preserves those journals and retirement identities.
-- Compile baseline: `feat/hostclaim-boxguard` at `8c40aca61d6b25ea5e936ffab171d237f1b13483`, plus the disjoint `origin/main` changes at `562f3dfcb4934c0dec027f730e34bbff08bbbd15`. Existing `hostclaim.Update` clones the observation, checks its ETag and assigns the next claim generation. `CompleteObligation` handles discovery completion using the chore's original operation ID.
+- Compile baseline: `feat/hostclaim-boxguard` at `8c40aca61d6b25ea5e936ffab171d237f1b13483`, plus the disjoint `origin/main` changes at `562f3dfcb4934c0dec027f730e34bbff08bbbd15`. The Task 1 patch additionally requires r713’s native `Admission.AdditionalWritesLost []WritesLost` and `AdmissionRecord.AdditionalWritesLost`, its verbatim admission copy and `(box, session)` uniqueness check. The authoring check used the local r713 snapshot described below; the pushed branch was unavailable in this checkout. Existing `hostclaim.Update` clones the observation, checks its ETag and assigns the next claim generation. `CompleteObligation` handles discovery completion using the chore's original operation ID.
- `internal/lease/{lease,health,endpoint}.go`: replica leases use a different object and 15-minute TTL. Reuse their configured S3 client, not their wire encoding or TTL. `internal/release/{store,conditional_delete}.go` supplies create-only PUT, conditional replacement and conditional deletion.
- `internal/awsinv/{clients,ec2}.go` supplies `DescribeInstances`. There is no existing stop/terminate primitive to call. The operator fences compute through their authorized infrastructure workflow; this plan verifies a positive cloud result and never changes compute.
- `main.go`, `apimode.go`, `internal/console/{authcli,server}.go` supply CLI dispatch, person sessions and the console signer. `Store.IsOperator` is the required authority. Tenant ownership, system-roller permission and service credentials are insufficient.
@@ -36,7 +38,7 @@ Use the repository's reviewed-branch workflow when the implementation is ready.
4. Lease network requests and claim acknowledgements never hold the host guard. Denial and cleanup run independently of renewal and persistence. Cleanup must terminate the captured host processes without waiting for that guard. Ambiguity revokes the live lock before signalling loss, so `CheckLocal` refuses even when an older `active` file still has a future deadline. Both an uncertain active-file write and a failed lost-state write retain the guard and retry until denial is durable; a timed-out `Close` retains controller exclusion. Independent writers use `CheckLocal` **inside** `Guard.With`, alongside fresh claim/quarantine checks and their separate cleanup-denial state. A file left by a dead controller cannot authorize work.
5. CC9 clears only the matching local lease record after a successful fresh claim read proves serving, free or exclusion of that box. It preserves quarantine, retired stamps and cleanup failures. `Ready` returns a coherent durable acknowledgement tuple. The `exec_ack.<box>` journal entry is historical evidence; it is not permission to keep working after lease loss.
6. **D4/D5:** a loss record is prepared durably before a claim CAS. A declaration binds holder, operation, phase, enrolled box, claim revision and confirmed effects. The transition revokes only that box, sets its executor to `none` while preserving `holder.stamp`, retains remote obligations, and uses `CompleteObligation` only for exact destroyed-local scopes. Stopped or terminated compute alone never proves that disks were destroyed. A freshly bound second declaration must have a strictly newer quarantine revision; its consequences do not change the first declaration. Lost revisions and declaration references survive free/reuse; readmission removes only the matching revision and grants no writer. A serving claim with empty `op.id` accepts only destroyed-local-only consequences bound to the current claim generation/ETag and each chore’s original operation and retirement identity. This path preserves the entire serving operation and holder, including executor, stamp and sessions. Its only claim effects are exact local-chore discharge, normal discovery completion and required loss/quarantine bookkeeping; it never writes a serving-operation recovery journal or reassigns regional work.
-7. Missing or unreadable evidence refuses. Timers explicitly distinguish `complete`, `partial` and `unknown`; unknown never becomes an empty complete inventory. Additional write loss belongs to the same admission, with the original source-cut session and validated baseline position. Durable archival precedes removal of loss-bearing admission evidence.
+7. Missing or unreadable evidence refuses. Timers explicitly distinguish `complete`, `partial` and `unknown`; unknown never becomes an empty complete inventory. Every frozen target-session and source-cut tuple belongs to the same admission, across `writes_lost` and native `additional_writes_lost`. Tuples are unique by `(box, session)`; each carries its own validated baseline TXID, and any omission refuses. Durable archival precedes removal of loss-bearing admission evidence.
8. **D2/D3:** admission checks freshly consult the directory interface on every call. The real root-signed directory, startup retirement execution, global quarantine CAS and readmission coordinator are outside this scope. Cloud verification accepts only one exact enrolled instance, a matching reservation owner, and `stopped` or `terminated`. Missing, paginated/partial, ambiguous and failed responses refuse. The challenge expires in 120 seconds; observations start after issuance, are at most 60 seconds old and at most five seconds in the console's future. The signed challenge authenticates console intent, not AWS evidence.
The claim spec’s proposed **§7.3 D3/D5** text must state the same rules: strictly newer, freshly bound successive declarations; matching-revision readmission; the serving destroyed-local-only exception; and durable lease denial before releasing the guard, with independent cleanup. Rev 6.20 already contains the review’s additions. This plan does not edit the spec or turn the proposals into rulings.
@@ -45,41 +47,20 @@ Each task below includes its implementation and tests. Save each existing-file p
## Task 1: Signed loss records, additive claim state and bound transitions
-**Produces:** root-only envelope helpers; D4 timer/write-loss records and archival checks; D5 `DeclareLost` and matching-revision `Readmit`; the D6 `exec_ack.<box>` journal helper. **Consumes:** existing `release.Store`, `hostclaim.Update`, `CompleteObligation` and operation-owner validation. Apply this task as a unit: the schema, validator and transition changes refer to one another. No CLI or console mutation caller is added.
+**Produces:** root-only envelope helpers; D4 timer/write-loss records and archival checks; D5 `DeclareLost` and matching-revision `Readmit`; the D6 `exec_ack.<box>` journal helper. **Consumes:** existing `release.Store`, `hostclaim.Update`, `CompleteObligation`, r713’s native additional-loss fields and operation-owner validation. Apply this task as a unit: the schema, validator and transition changes refer to one another. No CLI or console mutation caller is added. r713 owns additional-loss representation and copying; this task adds durable declaration binding and archival metadata. Ordinary native additional losses remain accepted without `LossID` and retained until archival. The signed wrapper requires an uncovered cold admission and checks every frozen tuple, including older target sessions, against its own baseline. The r713 trimming fixture uses certified predecessors so it continues to test trimming of loss-free rows.
- [ ] **Apply the exact existing-file changes.**
```diff
--- a/internal/hostclaim/claim.go
+++ b/internal/hostclaim/claim.go
-@@ -63,10 +63,13 @@
-- Region string `json:"region"`
-- PredecessorRegion string `json:"predecessor_region,omitempty"`
-- Session uint64 `json:"session"`
-- Box string `json:"box"`
-- Generation string `json:"generation"`
-- Predecessor string `json:"predecessor,omitempty"`
-- Mode string `json:"mode"`
-- Coverage string `json:"coverage,omitempty"`
-- Covered bool `json:"covered"`
-- WritesLost *WritesLost `json:"writes_lost,omitempty"`
-+ Region string `json:"region"`
-+ PredecessorRegion string `json:"predecessor_region,omitempty"`
-+ Session uint64 `json:"session"`
-+ Box string `json:"box"`
-+ Generation string `json:"generation"`
-+ Predecessor string `json:"predecessor,omitempty"`
-+ Mode string `json:"mode"`
-+ Coverage string `json:"coverage,omitempty"`
-+ Covered bool `json:"covered"`
-+ WritesLost *WritesLost `json:"writes_lost,omitempty"`
-+ AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"`
+@@ -73,0 +74,2 @@
+ LossID string `json:"loss_id,omitempty"`
+ LossArchivedClaimGen uint64 `json:"loss_archived_claim_gen,omitempty"`
-@@ -134,0 +138,2 @@
+@@ -135,0 +138,2 @@
+ LostRevisions map[string]uint64 `json:"lost_revisions,omitempty"`
+ LossRefs []string `json:"loss_refs,omitempty"`
-@@ -228,4 +233,23 @@
+@@ -229,4 +233,23 @@
- if record.Region == "" {
- return fmt.Errorf("%w: admission without region", ErrMalformed)
- }
@@ -87,8 +68,8 @@ Each task below includes its implementation and tests. Save each existing-file p
+ if record.Region == "" || !validWriteLosses(record.WritesLost, record.AdditionalWritesLost) {
+ return fmt.Errorf("%w: invalid admission", ErrMalformed)
+ }
-+ if len(record.AdditionalWritesLost) > 0 && !safeLossID(record.LossID) {
-+ return fmt.Errorf("%w: additional loss without declaration", ErrMalformed)
++ if record.LossID != "" && !safeLossID(record.LossID) {
++ return fmt.Errorf("%w: invalid loss declaration id", ErrMalformed)
+ }
+ if record.LossArchivedClaimGen != 0 && (!admissionHasLoss(record) || record.LossArchivedClaimGen != record.Session) {
+ return fmt.Errorf("%w: invalid archived admission loss", ErrMalformed)
@@ -107,10 +88,10 @@ Each task below includes its implementation and tests. Save each existing-file p
+ return fmt.Errorf("%w: invalid loss references", ErrMalformed)
+ }
+ seenLossRefs[id] = true
-@@ -244 +268 @@
+@@ -245 +268 @@
- if o.Region == "" || o.Generation == "" || o.Box != "" || o.Owner != "region:"+o.Region {
+ if o.Region == "" || o.Generation == "" || o.Box != "" || !validRegionalOwner(o.Owner, o.Region) {
-@@ -248 +272 @@
+@@ -249 +272 @@
- if o.Region == "" || o.Box != "" || o.Generation != "" || o.Owner != "region:"+o.Region {
+ if o.Region == "" || o.Box != "" || o.Generation != "" || !validRegionalOwner(o.Owner, o.Region) {
--- a/internal/hostclaim/generation.go
@@ -121,13 +102,12 @@ Each task below includes its implementation and tests. Save each existing-file p
+ "fmt"
+ "reflect"
+)
-@@ -19,0 +23,3 @@
-+ AdditionalWritesLost []WritesLost
+@@ -20,0 +24,2 @@
+ LossID string
+ lossVerified bool
-@@ -41,0 +48,15 @@
-+ if (in.LossID != "" || len(in.AdditionalWritesLost) != 0) && !in.lossVerified {
-+ return fmt.Errorf("%w: loss-bearing admission requires verified durable declaration", ErrWrongState)
+@@ -42,0 +48,15 @@
++ if in.LossID != "" && !in.lossVerified {
++ return fmt.Errorf("%w: declaration-bound admission requires verified durable declaration", ErrWrongState)
+ }
+ if in.LossID != "" {
+ for _, prior := range h.Admissions {
@@ -141,15 +121,9 @@ Each task below includes its implementation and tests. Save each existing-file p
+ return fmt.Errorf("%w: declaration id already used by another admission", ErrWrongState)
+ }
+ }
-@@ -60,0 +82,5 @@
-+ if len(in.AdditionalWritesLost) > 0 {
-+ if in.Mode != "cold" || in.PredecessorCertified || in.Coverage != "" || !safeLossID(in.LossID) || !validWriteLosses(nil, in.AdditionalWritesLost) {
-+ return fmt.Errorf("%w: invalid additional write-loss declaration", ErrWrongState)
-+ }
-+ }
-@@ -76,0 +103 @@
-+ AdditionalWritesLost: append([]WritesLost(nil), in.AdditionalWritesLost...), LossID: in.LossID,
-@@ -88 +115 @@
+@@ -90,0 +111 @@
++ LossID: in.LossID,
+@@ -108 +129 @@
- for len(h.Admissions) > 8 && h.Admissions[0].Covered {
+ for len(h.Admissions) > 8 && h.Admissions[0].Covered && admissionLossArchived(h.Admissions[0]) {
--- a/internal/hostclaim/delete.go
@@ -166,17 +140,21 @@ Each task below includes its implementation and tests. Save each existing-file p
+ }
--- a/internal/hostclaim/generation_test.go
+++ b/internal/hostclaim/generation_test.go
-@@ -700,6 +700,4 @@
+@@ -700,6 +700,2 @@
- drop := 3
- if name == "uncovered first" {
- drop = 0
- } else if name == "uncovered middle" {
- drop = 1
- }
-+ // Every row carries ordinary writes_lost evidence. D4 now
-+ // retains it until a durable archival contract exists; coverage
-+ // alone no longer makes any of these rows discardable.
++ // Loss-bearing rows remain until archival, even when covered.
+ drop := 0
+--- a/internal/hostclaim/rev713_test.go
++++ b/internal/hostclaim/rev713_test.go
+@@ -99 +99,2 @@
+- Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: generation,
++ // Exercise trimming without unarchived loss evidence.
++ Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: generation, PredecessorCertified: true,
```
- [ ] **Create `internal/release/rootenvelope.go` with this complete content.**
@@ -622,7 +600,7 @@ func (r LossRecord) Validate() error {
if (s.Reason != "target" && s.Reason != "source-cut") || s.Box == "" || s.Session == 0 || s.Region == "" || s.Baseline == "" {
return ErrLossRecord
}
- key := fmt.Sprintf("%s\x00%s\x00%d", s.Box, s.Generation, s.Session)
+ key := fmt.Sprintf("%s\x00%d", s.Box, s.Session)
if seenSources[key] {
return ErrLossRecord
}
@@ -710,13 +688,13 @@ func validWriteLosses(primary *WritesLost, additional []WritesLost) bool {
// legacy session zero / empty provenance. Decode it additively. New
// additional losses are the tuples this contract validates strictly.
if primary != nil && primary.Box != "" {
- seen[fmt.Sprintf("%s\x00%s\x00%d", primary.Box, primary.Generation, primary.Session)] = true
+ seen[fmt.Sprintf("%s\x00%d", primary.Box, primary.Session)] = true
}
for _, w := range additional {
if w.Box == "" || w.Session == 0 {
return false
}
- key := fmt.Sprintf("%s\x00%s\x00%d", w.Box, w.Generation, w.Session)
+ key := fmt.Sprintf("%s\x00%d", w.Box, w.Session)
if seen[key] {
return false
}
@@ -948,7 +926,7 @@ func TestAdmissionLossRetentionRequiresExactDurableCopy(t *testing.T) {
}
}
-func TestLossBearingAdmissionRequiresSignedSourceTuples(t *testing.T) {
+func TestLossBearingAdmissionRequiresEveryFrozenTargetAndSourceCut(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
pub, priv, err := ed25519.GenerateKey(nil)
@@ -965,8 +943,9 @@ func TestLossBearingAdmissionRequiresSignedSourceTuples(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ earlier := WritesLost{Box: "target", Generation: "g-earlier", Session: 3, RemoteMaxTXID: 7}
source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10}
- in := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", AdditionalWritesLost: []WritesLost{source}}
+ in := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12, AdditionalWritesLost: []WritesLost{earlier, source}}
if _, err := Update(ctx, s, obs, AdmitWriter(Admission{ObservedOpID: in.ObservedOpID, ObservedPhase: in.ObservedPhase, ObservedSession: in.ObservedSession, ObservedGeneration: in.ObservedGeneration, Box: in.Box, Mode: in.Mode, NewGeneration: in.NewGeneration, Region: in.Region, PredecessorRegion: in.PredecessorRegion, LossID: "loss_1", AdditionalWritesLost: in.AdditionalWritesLost})); !errors.Is(err, ErrWrongState) {
t.Fatalf("direct loss admission = %v", err)
}
@@ -976,6 +955,7 @@ func TestLossBearingAdmissionRequiresSignedSourceTuples(t *testing.T) {
record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", "target"
record.WriteSources = []WriteSource{
{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"},
+ {Reason: "target", Box: "target", Generation: "g-earlier", Session: 3, Region: "ie", Baseline: "earlier-snapshot"},
{Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal"},
}
created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record)
@@ -989,14 +969,53 @@ func TestLossBearingAdmissionRequiresSignedSourceTuples(t *testing.T) {
}
validated := []ValidatedWriteSource{
{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: in.RemoteMaxTXID},
+ {Reason: "target", Box: "target", Generation: "g-earlier", Session: 3, Region: "ie", Baseline: "earlier-snapshot", RemoteMaxTXID: earlier.RemoteMaxTXID},
{Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal", RemoteMaxTXID: source.RemoteMaxTXID},
}
+ for name, mutate := range map[string]func(*Admission, *[]ValidatedWriteSource){
+ "omitted earlier target": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = a.AdditionalWritesLost[1:] },
+ "omitted earlier target with same txid": func(a *Admission, v *[]ValidatedWriteSource) {
+ (*v)[1].RemoteMaxTXID = a.RemoteMaxTXID
+ a.AdditionalWritesLost = a.AdditionalWritesLost[1:]
+ },
+ "omitted source cut": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = a.AdditionalWritesLost[:1] },
+ "omitted all additional": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = nil },
+ "earlier target uses predecessor txid": func(a *Admission, _ *[]ValidatedWriteSource) {
+ a.AdditionalWritesLost[0].RemoteMaxTXID = a.RemoteMaxTXID
+ },
+ "source cut uses predecessor txid": func(a *Admission, _ *[]ValidatedWriteSource) {
+ a.AdditionalWritesLost[1].RemoteMaxTXID = a.RemoteMaxTXID
+ },
+ "predecessor uses earlier txid": func(a *Admission, _ *[]ValidatedWriteSource) { a.RemoteMaxTXID = earlier.RemoteMaxTXID },
+ "duplicate box session with another generation": func(a *Admission, _ *[]ValidatedWriteSource) {
+ duplicate := earlier
+ duplicate.Generation = "another-generation"
+ a.AdditionalWritesLost = append(a.AdditionalWritesLost, duplicate)
+ },
+ "unfrozen tuple": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost[0].Session++ },
+ "omitted baseline": func(_ *Admission, v *[]ValidatedWriteSource) { *v = (*v)[:2] },
+ "changed baseline": func(_ *Admission, v *[]ValidatedWriteSource) { (*v)[1].Baseline = "another-snapshot" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ candidate := in
+ candidate.AdditionalWritesLost = append([]WritesLost(nil), in.AdditionalWritesLost...)
+ evidence := append([]ValidatedWriteSource(nil), validated...)
+ mutate(&candidate, &evidence)
+ if _, err := AdmitWriterWithLoss(ctx, s, pub, obs, candidate, "loss_1", evidence); !errors.Is(err, ErrLossRecord) {
+ t.Fatalf("invalid declaration admitted: %v", err)
+ }
+ fresh, err := Read(ctx, s, obs.Host)
+ if err != nil || fresh.ETag != obs.ETag {
+ t.Fatalf("refusal changed claim: %v", err)
+ }
+ })
+ }
admitted, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, "loss_1", validated)
if err != nil {
t.Fatal(err)
}
last := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1]
- if last.LossID != "loss_1" || !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{source}) {
+ if last.LossID != "loss_1" || !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{earlier, source}) || last.WritesLost == nil || last.WritesLost.RemoteMaxTXID != 12 || last.WritesLost.Session != 4 {
t.Fatalf("loss admission = %+v", last)
}
}
@@ -1152,9 +1171,9 @@ func declarationFromRecord(r LossRecord) LostDeclaration {
}
}
-// AdmitWriterWithLoss is the only production entry for a loss-bearing
-// admission. It binds target and source-cut tuples to the root-signed durable
-// declaration before the claim CAS. Direct AdmitWriter refuses these fields.
+// AdmitWriterWithLoss binds every target and source-cut tuple to the
+// root-signed durable declaration before the claim CAS. Direct AdmitWriter
+// accepts native additional losses but refuses an unverified LossID.
type ValidatedWriteSource struct {
Reason string
Box string
@@ -1202,11 +1221,20 @@ func AdmitWriterWithLoss(ctx context.Context, s release.Store, pub ed25519.Publi
if want[targetKey].Reason != "target" {
return Observed{}, fmt.Errorf("%w: predecessor is not the declared lost target", ErrLossRecord)
}
+ primary := WritesLost{Box: predecessor.Box, Generation: predecessor.Generation, Session: predecessor.Session, RemoteMaxTXID: in.RemoteMaxTXID}
+ if !validWriteLosses(&primary, in.AdditionalWritesLost) {
+ return Observed{}, ErrLossRecord
+ }
+ declared := map[string]WritesLost{targetKey: primary}
for _, additional := range in.AdditionalWritesLost {
key := fmt.Sprintf("%s\x00%s\x00%d", additional.Box, additional.Generation, additional.Session)
- if want[key].Reason != "source-cut" {
- return Observed{}, fmt.Errorf("%w: additional source tuple was not frozen by the declaration", ErrLossRecord)
+ if _, ok := want[key]; !ok {
+ return Observed{}, fmt.Errorf("%w: additional tuple was not frozen by the declaration", ErrLossRecord)
}
+ declared[key] = additional
+ }
+ if len(declared) != len(want) {
+ return Observed{}, fmt.Errorf("%w: admission omitted a frozen write-loss tuple", ErrLossRecord)
}
provided := map[string]ValidatedWriteSource{}
for _, source := range validated {
@@ -1224,20 +1252,9 @@ func AdmitWriterWithLoss(ctx context.Context, s release.Store, pub ed25519.Publi
if !ok || actual.Reason != frozen.Reason || actual.Region != frozen.Region || actual.Baseline != frozen.Baseline {
return Observed{}, fmt.Errorf("%w: validated baseline differs from frozen source", ErrLossRecord)
}
- if frozen.Reason == "target" {
- if actual.RemoteMaxTXID != in.RemoteMaxTXID {
- return Observed{}, fmt.Errorf("%w: target remote maximum differs from validated baseline", ErrLossRecord)
- }
- continue
- }
- matched := false
- for _, additional := range in.AdditionalWritesLost {
- if additional.Box == actual.Box && additional.Generation == actual.Generation && additional.Session == actual.Session && additional.RemoteMaxTXID == actual.RemoteMaxTXID {
- matched = true
- }
- }
- if !matched {
- return Observed{}, fmt.Errorf("%w: source-cut remote maximum differs from validated baseline", ErrLossRecord)
+ declaration, ok := declared[key]
+ if !ok || declaration.RemoteMaxTXID != actual.RemoteMaxTXID {
+ return Observed{}, fmt.Errorf("%w: declared remote maximum differs from its validated baseline", ErrLossRecord)
}
}
in.LossID, in.lossVerified = lossID, true
@@ -3195,6 +3212,7 @@ func Open(ctx context.Context, cfg Config) (*Controller, error) {
// write. Acquire the live lock only after pending state is durable, so
// orphaned active bytes cannot regain admission. Never wait for the
// lifetime lock while holding the guard.
+ var cleaned <-chan error
for {
err = cfg.Guard.WithLocal(ctx, cfg.Binding.Host, func() error {
var lockErr error
@@ -3202,6 +3220,11 @@ func Open(ctx context.Context, cfg Config) (*Controller, error) {
if lockErr != nil {
return lockErr
}
+ // Start cleanup before any persistence can block. Exclusion stays
+ // held until this independent cleanup has joined on every exit.
+ result := make(chan error, 1)
+ cleaned = result
+ go func() { result <- cfg.Cleanup(context.Background()) }()
cfg.CloseAdmission()
cfg.CancelWork()
d, openErr := openLocal(cfg.Guard.DataDir)
@@ -3226,15 +3249,6 @@ func Open(ctx context.Context, cfg Config) (*Controller, error) {
}
break
}
- // Once exclusion is ours, orphaned processes must be cleaned even when
- // the pending write fails. Cleanup must survive caller cancellation and
- // run independently of lost-state persistence, which can retry forever.
- var cleaned <-chan error
- if c.unlock != nil {
- result := make(chan error, 1)
- cleaned = result
- go func() { result <- cfg.Cleanup(context.Background()) }()
- }
if err == nil {
err = <-cleaned
cleaned = nil
@@ -4831,6 +4845,14 @@ func TestCheckLocalRefusesLostLiveLock(t *testing.T) {
}
func TestOpenOrphanControllerPendingWriteFailureCleansBeforeReleasingExclusion(t *testing.T) {
+ testOpenOrphanControllerPersistence(t, false)
+}
+
+func TestOpenBlockedPendingPersistenceStillCleansBeforeReleasingExclusion(t *testing.T) {
+ testOpenOrphanControllerPersistence(t, true)
+}
+
+func testOpenOrphanControllerPersistence(t *testing.T, blocked bool) {
cfg, _, clock, store, _ := fixture(t)
wall, boot, _ := clock.Now()
orphan := State{V: 1, Binding: cfg.Binding, State: "active", LeaseETag: "orphan-etag", Expires: wall.Add(TTL), SafeUntil: boot + int64(TTL-Margin)}
@@ -4869,6 +4891,10 @@ func TestOpenOrphanControllerPendingWriteFailureCleansBeforeReleasingExclusion(t
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
+ writeEntered := make(chan struct{})
+ allowWrite := make(chan struct{})
+ var writeOnce sync.Once
+ defer writeOnce.Do(func() { close(allowWrite) })
var noSpace atomic.Bool
var failures atomic.Int32
noSpace.Store(true)
@@ -4879,7 +4905,13 @@ func TestOpenOrphanControllerPendingWriteFailureCleansBeforeReleasingExclusion(t
cancel() // cleanup must not inherit the failed opener's lifetime
}
original := d.sync
- d.sync = func(int) error { return unix.ENOSPC }
+ d.sync = func(int) error {
+ if blocked && s.State == "pending" {
+ close(writeEntered)
+ <-allowWrite // model an fsync that cannot return until released
+ }
+ return unix.ENOSPC
+ }
defer func() { d.sync = original }()
err := d.write(s)
if errors.Is(err, unix.ENOSPC) {
@@ -4899,7 +4931,7 @@ func TestOpenOrphanControllerPendingWriteFailureCleansBeforeReleasingExclusion(t
if err := <-cleanupResult; err != nil {
t.Fatalf("cleanup inherited caller cancellation: %v", err)
}
- until(t, func() bool { return failures.Load() >= 1 })
+
if old, err := d.read(orphan.Host); err != nil || old != orphan {
t.Fatalf("failed persistence did not retain orphan active bytes: %+v %v", old, err)
}
@@ -4917,6 +4949,19 @@ func TestOpenOrphanControllerPendingWriteFailureCleansBeforeReleasingExclusion(t
default:
}
}
+ if blocked {
+ select {
+ case <-writeEntered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("pending fsync was not reached")
+ }
+ if failures.Load() != 0 {
+ t.Fatal("pending fsync returned before cleanup was observed")
+ }
+ assertExcluded()
+ writeOnce.Do(func() { close(allowWrite) })
+ }
+ until(t, func() bool { return failures.Load() >= 1 })
assertExcluded()
if err := CheckLocal(cfg.Guard.DataDir, orphan.Binding, clock, cfg.LockDir); !errors.Is(err, ErrLost) {
t.Fatalf("orphan bytes admitted work: %v", err)
@@ -5462,6 +5507,11 @@ The proposed answers live in **§7.3/§10.1** and **amadan #236**. They are not
## Self-review
+Rev 4 answers review round 3:
+
+- **P1 — blocked startup persistence:** `Open` starts cleanup with an independent context immediately after acquiring controller exclusion, before opening or writing local state. It retains exclusion until cleanup joins, including failed and cancelled startup. `TestOpenBlockedPendingPersistenceStillCleansBeforeReleasingExclusion` blocks the pending file’s fsync indefinitely behind a test barrier, cancels the opener and verifies that the orphaned host process exits before that barrier is released. It checks exclusion while fsync is blocked and again after fsync returns while cleanup is still joining. The returned-`ENOSPC` regression remains covered.
+- **P2 — complete native write-loss declarations:** Task 1 now depends on r713’s `AdditionalWritesLost` fields and records the supplied list through the native admission CAS. `AdmitWriterWithLoss` requires the exact frozen tuple set across the predecessor’s `writes_lost` and `additional_writes_lost`, including every earlier target session and source cut. Each tuple is checked against its own validated baseline TXID. `TestLossBearingAdmissionRequiresEveryFrozenTargetAndSourceCut` uses distinct target, earlier-target and source-cut positions, checks verbatim persistence, and refuses omissions, substituted positions, duplicate `(box, session)` pairs, unfrozen tuples and changed or missing baseline evidence. An omission case gives both target sessions the same TXID so the test detects missing admission evidence independently of position mismatch.
+
Rev 3 answers review round 2:
- **P1 — failed startup persistence:** after acquiring controller exclusion, `Open` starts cleanup with an independent context even if the pending write fails. Before live-lock acquisition, failed startup joins cleanup without requiring another write to the failed destination; no surviving record can authorize work without that lock. Failures after live-lock acquisition still persist denial independently of cleanup, and retain exclusion until both finish. `TestOpenOrphanControllerPendingWriteFailureCleansBeforeReleasingExclusion` leaves an orphaned active record and surviving host process, injects `ENOSPC`, and cancels the opener. It proves the process is terminated while persistence fails, exclusion is retained while cleanup has not joined, and the original error returns only after cleanup joins. Admission stays denied after exclusion ends even though the old active bytes remain.
@@ -5478,6 +5528,16 @@ The live-lock probe must run inside `Guard.With`, as does the lifetime-lock prob
## Authoring verification
+Rev 4 was verified on 2026-10-06 in a disposable on-disk export of the pinned boxguard/main baseline plus the local r713 native-field snapshot. No remote `origin/feat/hostclaim-r713` ref was available locally, and network restrictions prevented checking the server. The local branch’s committed tip (`47426cc1`) still predated the new fields; snapshots of `claim.go`, `generation.go`, `generation_test.go` and `rev713_test.go` supplied the in-progress API. The `claim.go` snapshot’s SHA-256 was `8394a78337bf51d4ee7837e85515756857872f507d8c034c7014ab228d5933be`; `generation.go` was `03e5cbdb3446d20afbf9f108d82cdc06c14bd8589d0e55aef018c2ac5b0a66b2`. Recheck the exact patch against the pushed r713 tip before implementation.
+
+- The complete `hostclaim` and `hostexec` suites passed with `-race -count=1`, including r713’s native additional-loss tests, blocked startup persistence, multiple target sessions and omission refusal. The new regressions were watched to fail after restoring the old `Open` and `AdmitWriterWithLoss`, then the fixed files were restored and both complete suites passed again.
+- Whole-repository builds passed for Linux/amd64, Darwin/amd64, Windows/amd64 and Linux/arm64. `go test -buildvcs=false -exec=/bin/true ./...` compiled every test package; the full repository test suite was not executed.
+- Whole-repository `go vet -buildvcs=false ./...` passed and `gofmt -l .` was empty. All 33 embedded files matched the verified source. Both exact patches reversed and reapplied byte for byte, and Task 1’s four r713 inputs matched the snapshotted baseline. The plan’s `git diff --check` passed.
+- The default Go cache was read-only in the sandbox. Verification used the existing shared on-disk `/home/paulca/.cache/tmp/gocache`.
+- The Opus authority-review attempt timed out after 45 seconds without a verdict. That review remains outstanding before implementation lands.
+
+The rev 3 results below are historical.
+
Rev 3 was verified on 2026-10-06 in a disposable on-disk export of the same pinned merge baseline, with all 33 embedded Go files and both exact patches applied. The Task 4 patch now includes `internal/console/svcroutes_test.go`. Every embedded and patched file matched the verified source. The default Go cache was read-only in the sandbox, so these checks used the existing shared on-disk cache named below.
- The complete `hostexec`, `awsinv` and `hostfence` suites passed with `-race -count=1`, including the orphan-process regression, request-ID propagation and missing-ID refusals. Existing unsafe-path tests also passed: startup cleanup does not wait for a second write to a rejected destination.