carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package hostclaim
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "reflect"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/release"
15)
16
17type testLossOwner struct {
18 err error
19 mutate bool
20}
21
22func (o testLossOwner) ValidateDeclareLost(c Claim, in LostDeclaration) error {
23 if o.mutate {
24 c.Holder.Stamp++
25 in.Consequences.RecoveryIntent[2] = 'X'
26 }
27 return o.err
28}
29
30func TestDeclareLostExactScopeAndReadmitRevision(t *testing.T) {
31 ctx := context.Background()
32 s := release.NewDirStore(t.TempDir())
33 obs := servingClaim(t, ctx, s)
34 obs, err := Update(ctx, s, obs, func(c *Claim) error {
35 c.Op = Op{Kind: KindMoving, ID: "op-move", Phase: "precommit", Exec: "box-a", Participants: []string{"box-a", "box-b"}}
36 c.Obligations = []Obligation{
37 {ID: "local", Op: "discover", Kind: "park-local", Box: "box-a", Retirement: "ret-local", Owner: "box:box-a"},
38 {ID: "remote", Op: "op-move", Kind: "park-replica", Region: "ie", Retirement: "ret-remote", Owner: "box:box-a"},
39 }
40 c.Touched.Complete, c.Touched.DiscoveryOp = false, "discover"
41 return nil
42 })
43 if err != nil {
44 t.Fatal(err)
45 }
46 stamp, session := obs.Claim.Holder.Stamp, obs.Claim.Holder.Session
47 in := LostDeclaration{
48 LossID: "loss_1", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag,
49 HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: stamp, HolderSession: session,
50 OpID: "op-move", OpKind: KindMoving, OpPhase: "precommit", Box: "box-a", Enrollment: "enroll-a",
51 QuarantineRevision: 7, FencedAt: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC),
52 Consequences: LossConsequences{
53 RevokeExecution: true, RemoveParticipation: true,
54 DischargeLocal: []ObligationRef{{ID: "local", Op: "discover", Retirement: "ret-local"}},
55 ReassignRegional: []ObligationRef{{ID: "remote", Op: "op-move", Retirement: "ret-remote"}},
56 DestroyedStorage: []string{"discover/local/ret-local"},
57 RecoveryIntent: []byte(`{"kind":"lost-target","destination":"owner-decides"}`),
58 },
59 }
60 pub, priv, err := ed25519.GenerateKey(nil)
61 if err != nil {
62 t.Fatal(err)
63 }
64 record := validLossRecord(t)
65 record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation
66 record.HolderStamp, record.HolderSession = stamp, session
67 record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag
68 record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase
69 record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt
70 record.ConfirmedConsequences = in.Consequences
71 record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...)
72 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil {
73 t.Fatal(err)
74 }
75 got, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{mutate: true})
76 if err != nil {
77 t.Fatal(err)
78 }
79 if got.Claim.Op.Exec != "none" || len(got.Claim.Op.Participants) != 1 || got.Claim.Op.Participants[0] != "box-b" {
80 t.Fatalf("authority = exec %q participants %v", got.Claim.Op.Exec, got.Claim.Op.Participants)
81 }
82 if got.Claim.Holder.Stamp != stamp || got.Claim.Holder.Session != session {
83 t.Fatalf("installation authority changed: stamp/session %d/%d", got.Claim.Holder.Stamp, got.Claim.Holder.Session)
84 }
85 if len(got.Claim.Obligations) != 1 || got.Claim.Obligations[0].ID != "remote" || got.Claim.Obligations[0].Owner != "region:ie" ||
86 got.Claim.Obligations[0].Retirement != "ret-remote" || got.Claim.Obligations[0].Op != "op-move" {
87 t.Fatalf("obligations = %+v", got.Claim.Obligations)
88 }
89 if got.Claim.Touched.DiscoveryOp != "" || !got.Claim.Touched.Complete {
90 t.Fatalf("discovery completion stranded: %+v", got.Claim.Touched)
91 }
92 if got.Claim.LostRevisions["box-a"] != 7 || !stringPresent(got.Claim.Lost, "box-a") || !lossRefPresent(got.Claim, "loss_1") {
93 t.Fatalf("loss state = lost %v revisions %v refs %v", got.Claim.Lost, got.Claim.LostRevisions, got.Claim.LossRefs)
94 }
95 if got.Claim.Op.Journal["loss.loss_1"] != `{"destination":"owner-decides","kind":"lost-target"}` {
96 t.Fatalf("recovery intent = %q", got.Claim.Op.Journal["loss.loss_1"])
97 }
98
99 if retry, err := DeclareLost(ctx, s, pub, got, in.LossID, testLossOwner{}); err != nil || retry.ETag != got.ETag {
100 t.Fatalf("lost-response retry = %+v, %v", retry, err)
101 }
102 if _, err := Update(ctx, s, got, Readmit("box-a", 6)); !errors.Is(err, ErrWrongState) {
103 t.Fatalf("old readmit revision = %v", err)
104 }
105 readmitted, err := Update(ctx, s, got, Readmit("box-a", 7))
106 if err != nil {
107 t.Fatal(err)
108 }
109 if stringPresent(readmitted.Claim.Lost, "box-a") || readmitted.Claim.LostRevisions["box-a"] != 0 || !lossRefPresent(readmitted.Claim, "loss_1") {
110 t.Fatalf("readmission state = lost %v revisions %v refs %v", readmitted.Claim.Lost, readmitted.Claim.LostRevisions, readmitted.Claim.LossRefs)
111 }
112}
113
114func TestDeclareLostFailsClosedWithoutOperationOwnerOrExactDiskProof(t *testing.T) {
115 ctx := context.Background()
116 s := release.NewDirStore(t.TempDir())
117 obs := servingClaim(t, ctx, s)
118 obs, err := Update(ctx, s, obs, func(c *Claim) error {
119 c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}}
120 c.Obligations = []Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box-a", Retirement: "ret", Owner: "box:box-a"}}
121 return nil
122 })
123 if err != nil {
124 t.Fatal(err)
125 }
126 in := LostDeclaration{LossID: "loss", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag,
127 HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: obs.Claim.Holder.Stamp, HolderSession: obs.Claim.Holder.Session,
128 OpID: "delete", OpKind: KindDeleting, OpPhase: "parking", Box: "box-a", Enrollment: "e", QuarantineRevision: 1, FencedAt: time.Now(),
129 Consequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, DischargeLocal: []ObligationRef{{ID: "local", Op: "delete", Retirement: "ret"}}, RecoveryIntent: []byte(`{"kind":"local-only"}`)}}
130 pub, priv, err := ed25519.GenerateKey(nil)
131 if err != nil {
132 t.Fatal(err)
133 }
134 record := validLossRecord(t)
135 record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation
136 record.HolderStamp, record.HolderSession = in.HolderStamp, in.HolderSession
137 record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag
138 record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase
139 record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt
140 record.ConfirmedConsequences = in.Consequences
141 record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...)
142 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil {
143 t.Fatal(err)
144 }
145 if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, nil); !errors.Is(err, ErrLossOwnerRequired) {
146 t.Fatalf("nil owner = %v", err)
147 }
148 if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{}); !errors.Is(err, ErrWrongState) {
149 t.Fatalf("missing disk assertion = %v", err)
150 }
151 denied := errors.New("move record already committed")
152 if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{err: denied}); !errors.Is(err, denied) {
153 t.Fatalf("owner denial = %v", err)
154 }
155 fresh, err := Read(ctx, s, testHost)
156 if err != nil {
157 t.Fatal(err)
158 }
159 if fresh.Claim.Gen != obs.Claim.Gen || len(fresh.Claim.Lost) != 0 || len(fresh.Claim.LossRefs) != 0 {
160 t.Fatalf("refusal mutated claim: %+v", fresh.Claim)
161 }
162}
163
164func lossForObservation(t *testing.T, obs Observed, id string, revision uint64, box string, effects LossConsequences) LossRecord {
165 t.Helper()
166 r := validLossRecord(t)
167 r.ID, r.Host = id, obs.Host
168 r.HolderIncarnation, r.HolderStamp, r.HolderSession = obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp, obs.Claim.Holder.Session
169 r.ExpectedClaimGen, r.ExpectedClaimETag = obs.Claim.Gen, obs.ETag
170 r.OpID, r.OpKind, r.OpPhase = obs.Claim.Op.ID, obs.Claim.Op.Kind, obs.Claim.Op.Phase
171 r.Box, r.QuarantineRevision = box, revision
172 r.ConfirmedConsequences = effects
173 r.WriteSources = nil
174 r.OperatorAttestation.DestroyedStorage = append([]string(nil), effects.DestroyedStorage...)
175 return r
176}
177
178func TestDeclareLostPreservesLargeRecoveryIntentIntegers(t *testing.T) {
179 ctx := t.Context()
180 s := release.NewDirStore(t.TempDir())
181 pub, priv, err := ed25519.GenerateKey(nil)
182 if err != nil {
183 t.Fatal(err)
184 }
185 obs := servingClaim(t, ctx, s)
186 obs, err = Update(ctx, s, obs, func(c *Claim) error {
187 c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}}
188 return nil
189 })
190 if err != nil {
191 t.Fatal(err)
192 }
193 intent := `{"kind":"recover","nested":{"txid":9007199254740993},"positions":[18446744073709551615,-9007199254740993]}`
194 record := lossForObservation(t, obs, "large_integers", 1, "box-a", LossConsequences{
195 RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(intent),
196 })
197 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil {
198 t.Fatal(err)
199 }
200 if _, err := DeclareLost(ctx, s, pub, obs, record.ID, testLossOwner{}); err != nil {
201 t.Fatal(err)
202 }
203 fresh, err := Read(ctx, s, obs.Host)
204 if err != nil {
205 t.Fatal(err)
206 }
207 if got := fresh.Claim.Op.Journal["loss."+record.ID]; got != intent {
208 t.Fatalf("persisted recovery intent = %s, want %s", got, intent)
209 }
210}
211
212func TestDeclareLostNewerRevisionAppliesSecondConsequences(t *testing.T) {
213 ctx := t.Context()
214 s := release.NewDirStore(t.TempDir())
215 pub, priv, err := ed25519.GenerateKey(nil)
216 if err != nil {
217 t.Fatal(err)
218 }
219 obs := servingClaim(t, ctx, s)
220 obs, err = Update(ctx, s, obs, func(c *Claim) error {
221 c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}}
222 c.Obligations = []Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box-a", Retirement: "ret", Owner: "box:box-a"}}
223 return nil
224 })
225 if err != nil {
226 t.Fatal(err)
227 }
228 first := lossForObservation(t, obs, "stopped", 9, "box-a", LossConsequences{
229 RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: []byte(`{"kind":"wait-for-storage"}`),
230 })
231 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), first); err != nil {
232 t.Fatal(err)
233 }
234 stopped, err := DeclareLost(ctx, s, pub, obs, first.ID, testLossOwner{})
235 if err != nil {
236 t.Fatal(err)
237 }
238 if len(stopped.Claim.Obligations) != 1 {
239 t.Fatal("compute stop discharged local storage")
240 }
241 stopped, err = Update(ctx, s, stopped, func(c *Claim) error { c.Op.ID = "delete-second"; c.Op.Journal = nil; return nil })
242 if err != nil {
243 t.Fatal(err)
244 }
245 second := lossForObservation(t, stopped, "destroyed", 10, "box-a", LossConsequences{
246 DischargeLocal: []ObligationRef{{ID: "local", Op: "delete", Retirement: "ret"}},
247 DestroyedStorage: []string{"delete/local/ret"}, RecoveryIntent: []byte(`{"kind":"destroyed-storage"}`),
248 })
249 // A new revision does not excuse stale claim generation or ETag bindings.
250 stale := second
251 stale.ID, stale.ExpectedClaimGen, stale.ExpectedClaimETag = "stale", obs.Claim.Gen, obs.ETag
252 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), stale); err != nil {
253 t.Fatal(err)
254 }
255 if _, err := DeclareLost(ctx, s, pub, stopped, stale.ID, testLossOwner{}); !errors.Is(err, ErrConflict) {
256 t.Fatalf("stale second binding = %v", err)
257 }
258 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), second); err != nil {
259 t.Fatal(err)
260 }
261 destroyed, err := DeclareLost(ctx, s, pub, stopped, second.ID, testLossOwner{})
262 if err != nil {
263 t.Fatal(err)
264 }
265 if len(destroyed.Claim.Obligations) != 0 || destroyed.Claim.LostRevisions["box-a"] != 10 ||
266 !reflect.DeepEqual(destroyed.Claim.LossRefs, []string{"stopped", "destroyed"}) {
267 t.Fatalf("second declaration not applied: %+v", destroyed.Claim)
268 }
269 for _, rev := range []uint64{8, 9, 10} {
270 r := lossForObservation(t, destroyed, fmt.Sprintf("older_%d", rev), rev, "box-a", LossConsequences{RecoveryIntent: []byte(`{}`)})
271 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil {
272 t.Fatal(err)
273 }
274 if _, err := DeclareLost(ctx, s, pub, destroyed, r.ID, testLossOwner{}); !errors.Is(err, ErrWrongState) {
275 t.Fatalf("revision %d accepted: %v", rev, err)
276 }
277 }
278 if _, err := Update(ctx, s, destroyed, Readmit("box-a", 9)); !errors.Is(err, ErrWrongState) {
279 t.Fatalf("old readmission cleared newer quarantine: %v", err)
280 }
281 for _, id := range []string{first.ID, second.ID} {
282 retry, err := DeclareLost(ctx, s, pub, destroyed, id, testLossOwner{})
283 if err != nil || retry.ETag != destroyed.ETag {
284 t.Fatalf("replay %s = %v", id, err)
285 }
286 }
287 unchanged, err := ReadLossRecord(ctx, s, pub, first.ID)
288 if err != nil || !reflect.DeepEqual(unchanged.Record, first) {
289 t.Fatal("first declaration was rewritten", err)
290 }
291 fresh, err := Read(ctx, s, obs.Host)
292 if err != nil || fresh.ETag != destroyed.ETag {
293 t.Fatal("refusal or replay mutated claim", err)
294 }
295 readmitted, err := Update(ctx, s, fresh, Readmit("box-a", 10))
296 if err != nil || len(readmitted.Claim.Lost) != 0 || len(readmitted.Claim.LossRefs) != 2 {
297 t.Fatal("matching readmission", err)
298 }
299}
300
301func TestServingDestroyedLocalOnlyPreservesAuthorityAndOtherChores(t *testing.T) {
302 for _, otherChores := range []bool{false, true} {
303 t.Run(fmt.Sprint(otherChores), func(t *testing.T) {
304 ctx := t.Context()
305 s := release.NewDirStore(t.TempDir())
306 pub, priv, err := ed25519.GenerateKey(nil)
307 if err != nil {
308 t.Fatal(err)
309 }
310 obs, err := Read(ctx, s, testHost)
311 if err != nil {
312 t.Fatal(err)
313 }
314 var regions []string
315 boxes := []string{"box-a"}
316 if otherChores {
317 regions = []string{"uk"}
318 boxes = append(boxes, "other-box")
319 }
320 obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", regions, boxes, "discovery", counter()))
321 if err != nil {
322 t.Fatal(err)
323 }
324 if obs.Claim.Op.Kind != KindServing || obs.Claim.Op.ID != "" {
325 t.Fatal("fixture is not an adopted serving claim")
326 }
327 var ref ObligationRef
328 for _, o := range obs.Claim.Obligations {
329 if o.Kind == "park-local" && o.Box == "box-a" {
330 ref = ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement}
331 }
332 }
333 effects := LossConsequences{DischargeLocal: []ObligationRef{ref}, DestroyedStorage: []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)}
334 r := lossForObservation(t, obs, "local_only", 11, "box-a", effects)
335 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil {
336 t.Fatal(err)
337 }
338 got, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{mutate: true})
339 if err != nil {
340 t.Fatal(err)
341 }
342 want, err := cloneClaim(obs.Claim)
343 if err != nil {
344 t.Fatal(err)
345 }
346 remaining := make([]Obligation, 0, len(want.Obligations)-1)
347 for _, o := range want.Obligations {
348 if o.ID != ref.ID {
349 remaining = append(remaining, o)
350 }
351 }
352 want.Obligations = remaining
353 if !otherChores {
354 want.Touched.Complete, want.Touched.DiscoveryOp = true, ""
355 }
356 want.Gen = got.Claim.Gen
357 want.Lost, want.LostRevisions, want.LossRefs = []string{"box-a"}, map[string]uint64{"box-a": 11}, []string{r.ID}
358 // Compare serialized claims so nil/empty omitted arrays are equivalent.
359 wantBytes, _ := json.Marshal(want)
360 gotBytes, _ := json.Marshal(got.Claim)
361 if !bytes.Equal(wantBytes, gotBytes) {
362 t.Fatalf("local-only changed unrelated state:\nwant %s\ngot %s", wantBytes, gotBytes)
363 }
364 if retry, err := DeclareLost(ctx, s, pub, got, r.ID, testLossOwner{}); err != nil || retry.ETag != got.ETag {
365 t.Fatal("local-only replay", err)
366 }
367 })
368 }
369}
370
371func TestServingDestroyedLocalOnlyRefusesWideningAndStaleBindings(t *testing.T) {
372 ctx := t.Context()
373 s := release.NewDirStore(t.TempDir())
374 pub, priv, err := ed25519.GenerateKey(nil)
375 if err != nil {
376 t.Fatal(err)
377 }
378 obs, err := Read(ctx, s, testHost)
379 if err != nil {
380 t.Fatal(err)
381 }
382 obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", nil, []string{"box-a"}, "discovery", counter()))
383 if err != nil {
384 t.Fatal(err)
385 }
386 o := obs.Claim.Obligations[0]
387 ref := ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement}
388 base := lossForObservation(t, obs, "local", 1, "box-a", LossConsequences{DischargeLocal: []ObligationRef{ref}, DestroyedStorage: []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)})
389 for name, mutate := range map[string]func(*LossRecord){
390 "execution": func(r *LossRecord) { r.ConfirmedConsequences.RevokeExecution = true },
391 "participation": func(r *LossRecord) { r.ConfirmedConsequences.RemoveParticipation = true },
392 "regional": func(r *LossRecord) { r.ConfirmedConsequences.ReassignRegional = []ObligationRef{ref} },
393 "writes": func(r *LossRecord) { r.ConfirmedConsequences.WritesPotentiallyLost = true },
394 "timers": func(r *LossRecord) { r.ConfirmedConsequences.TimersPotentiallyLost = true },
395 "recovery": func(r *LossRecord) {
396 r.ConfirmedConsequences.RecoveryIntent = []byte(`{"kind":"destroyed-local-only","destination":"other"}`)
397 },
398 "empty": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal = nil },
399 "missing-proof": func(r *LossRecord) {
400 r.ConfirmedConsequences.DestroyedStorage = nil
401 r.OperatorAttestation.DestroyedStorage = nil
402 },
403 "op": func(r *LossRecord) { r.OpID = "discovery" },
404 "phase": func(r *LossRecord) { r.OpPhase = "recover" },
405 "non-serving-empty-op": func(r *LossRecord) { r.OpKind = KindDeleting },
406 } {
407 t.Run(name, func(t *testing.T) {
408 r := base
409 r.ID = name
410 mutate(&r)
411 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); !errors.Is(err, ErrLossRecord) {
412 t.Fatalf("widened serving declaration = %v", err)
413 }
414 })
415 }
416 for name, mutate := range map[string]func(*LossRecord){
417 "claim-gen": func(r *LossRecord) { r.ExpectedClaimGen++ },
418 "claim-etag": func(r *LossRecord) { r.ExpectedClaimETag = "stale" },
419 "stamp": func(r *LossRecord) { r.HolderStamp++ },
420 "session": func(r *LossRecord) { r.HolderSession++ },
421 "box": func(r *LossRecord) { r.Box = "other-box" },
422 "original-op": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal[0].Op = "other-op" },
423 "retirement": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal[0].Retirement = "other-retirement" },
424 } {
425 t.Run(name, func(t *testing.T) {
426 r := base
427 r.ID = name
428 r.ConfirmedConsequences.DischargeLocal = append([]ObligationRef(nil), base.ConfirmedConsequences.DischargeLocal...)
429 mutate(&r)
430 ref := r.ConfirmedConsequences.DischargeLocal[0]
431 r.ConfirmedConsequences.DestroyedStorage = []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}
432 r.OperatorAttestation.DestroyedStorage = r.ConfirmedConsequences.DestroyedStorage
433 if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil {
434 t.Fatal(err)
435 }
436 if _, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{}); err == nil {
437 t.Fatal("stale or wrong scope was accepted")
438 }
439 })
440 }
441 fresh, err := Read(ctx, s, obs.Host)
442 if err != nil || fresh.ETag != obs.ETag {
443 t.Fatal("refusal changed serving claim", err)
444 }
445}
446