hostclaim: a created incarnation's first admission declares nothing (moves 7.17 note 2)
A predecessor with no writer (writer_box "") acknowledged nothing, so no writes_lost is recorded; every declaration names a box (claim spec §6.4a). The old JSON test encoded the boxless declaration; it now asserts none, and a separate test keeps writes_lost's wire shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+44
−3
internal/hostclaim/generation.go+4 −1internal/hostclaim/generation_test.go+1 −2internal/hostclaim/rev713_test.go+39 −0
CI passed — run details
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go| index 9b4ade0ff..973e71dac 100644 |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| @@ -108,7 +108,10 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| if !in.KeepStamp { |
| h.Stamp = entry.Session |
| } |
| - if !in.PredecessorCertified && in.Coverage == "" { |
| + // A predecessor with no writer (a created incarnation's first |
| + // admission: writer_box "") acknowledged nothing, so there is |
| + // nothing to declare; every declaration names a box (§6.4a). |
| + if !in.PredecessorCertified && in.Coverage == "" && predecessor.Box != "" { |
| entry.WritesLost = &WritesLost{Box: predecessor.Box, Generation: predecessor.Generation, Session: predecessor.Session, RemoteMaxTXID: in.RemoteMaxTXID} |
| } |
| } |
diff --git a/internal/hostclaim/generation_test.go b/internal/hostclaim/generation_test.go| index ae486d4a7..d041868e9 100644 |
| --- a/internal/hostclaim/generation_test.go |
| +++ b/internal/hostclaim/generation_test.go |
| @@ -737,8 +737,7 @@ func TestAdmissionRecordJSON(t *testing.T) { |
| t.Fatal(err) |
| } |
| want := []map[string]any{ |
| - {"region": "ie", "predecessor_region": "ie", "session": float64(o.Claim.Holder.Admissions[0].Session), "box": "adi-knitting", "generation": "G1", "mode": "cold", "covered": true, |
| - "writes_lost": map[string]any{"box": "", "generation": "", "session": float64(0), "remote_max_txid": float64(42)}}, |
| + {"region": "ie", "predecessor_region": "ie", "session": float64(o.Claim.Holder.Admissions[0].Session), "box": "adi-knitting", "generation": "G1", "mode": "cold", "covered": true}, |
| {"region": "ie", "predecessor_region": "ie", "session": float64(o.Claim.Holder.Session), "box": "adi-knitting", "generation": "G2", "predecessor": "G1", "mode": "cold", "coverage": "sealed", "covered": true}, |
| } |
| if !reflect.DeepEqual(got, want) { |
diff --git a/internal/hostclaim/rev713_test.go b/internal/hostclaim/rev713_test.go| index 49e705fbb..a37ea0130 100644 |
| --- a/internal/hostclaim/rev713_test.go |
| +++ b/internal/hostclaim/rev713_test.go |
| @@ -537,3 +537,42 @@ func TestCompleteObligationDoesNotCoverAdmissions(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// A created incarnation's first admission declares nothing: its predecessor |
| +// has no writer, so no write was ever acknowledged (moves 7.17 note 2). |
| +func TestAdmitWriterFirstAdmissionAfterCreateDeclaresNothing(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, ctx, s) |
| + if o.Claim.Holder.WriterBox != "" || len(o.Claim.Holder.Admissions) != 0 { |
| + t.Fatalf("fixture is not a fresh create: %+v", o.Claim.Holder) |
| + } |
| + o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G1"})) |
| + first := o.Claim.Holder.Admissions[0] |
| + if first.WritesLost != nil || len(first.AdditionalWritesLost) != 0 { |
| + t.Fatalf("first admission declared a loss: %+v", first) |
| + } |
| + // The next uncertified cold admission does declare its predecessor. |
| + o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "G1", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G2"})) |
| + second := o.Claim.Holder.Admissions[1] |
| + if second.WritesLost == nil || second.WritesLost.Box == "" || second.WritesLost.Generation != "G1" { |
| + t.Fatalf("second admission = %+v", second) |
| + } |
| +} |
| + |
| +// writes_lost keeps its wire shape when a real predecessor is declared. |
| +func TestWritesLostJSONShape(t *testing.T) { |
| + b, err := json.Marshal(AdmissionRecord{Region: "ie", Session: 4, Box: "boxB", Generation: "G2", Mode: "cold", |
| + WritesLost: &WritesLost{Box: "boxA", Generation: "G1", Session: 3, RemoteMaxTXID: 42}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got map[string]any |
| + if err := json.Unmarshal(b, &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := map[string]any{"box": "boxA", "generation": "G1", "session": float64(3), "remote_max_txid": float64(42)} |
| + if !reflect.DeepEqual(got["writes_lost"], want) { |
| + t.Fatalf("writes_lost = %v, want %v", got["writes_lost"], want) |
| + } |
| +} |