| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | type LocalDischarge struct { |
| 11 | Qualified bool `json:"qualified"` |
| 12 | Records []string `json:"records,omitempty"` |
| 13 | Reason string `json:"reason,omitempty"` |
| 14 | } |
| 15 | |
| 16 | // AutomaticLoss is claim §7.3 D4's "Automatic loss persistence": what the |
| 17 | // transition that consumed a termination fence retains on the claim itself, |
| 18 | // outside the replaceable operation, until the console materializes the |
| 19 | // D4 host loss record. It survives CompleteRelocation, free and holder |
| 20 | // replacement because it lives beside loss_refs, not in op.journal. |
| 21 | type AutomaticLoss struct { |
| 22 | ID string `json:"id"` // the loss identity, also in loss_refs |
| 23 | Box string `json:"box"` |
| 24 | Enrollment string `json:"enrollment"` |
| 25 | Fence FenceRef `json:"fence"` |
| 26 | ObservedAt string `json:"observed_at"` |
| 27 | Op string `json:"op"` |
| 28 | Kind string `json:"kind"` |
| 29 | Phase string `json:"phase"` |
| 30 | Role string `json:"role"` // "source" | "target" |
| 31 | // Incarnation and Stamp bind the loss to the holder it was taken |
| 32 | // against; Generation and Session are the box's last writer session, |
| 33 | // whether it is still the writer or was superseded, and LastAdmission |
| 34 | // is that session's admission record when the claim holds one (the |
| 35 | // consequences the console reports: its coverage and declarations). |
| 36 | Incarnation string `json:"incarnation"` |
| 37 | Stamp uint64 `json:"stamp"` |
| 38 | Generation string `json:"generation,omitempty"` |
| 39 | Session uint64 `json:"session,omitempty"` |
| 40 | LastAdmission *AdmissionRecord `json:"last_admission,omitempty"` |
| 41 | Discharge LocalDischarge `json:"discharge"` |
| 42 | // Reassigned is the regional work (park-generation, park-replica) |
| 43 | // the box owned that the same CAS passed to its region (L3, as |
| 44 | // DeclareLost's reassign_regional): the console's D4 record names it. |
| 45 | Reassigned []ObligationRef `json:"reassigned,omitempty"` |
| 46 | Timers TimerKnowledge `json:"timers"` |
| 47 | // Reported is set by the console once the D4 record holds this loss |
| 48 | // and its alert work exists. Until then reporting is pending. |
| 49 | Reported bool `json:"reported,omitempty"` |
| 50 | } |
| 51 | |
| 52 | // automaticLossID is deterministic for one fence consumed by one |
| 53 | // operation for one box, so a replayed transition names the same loss and |
| 54 | // the console's materialization deduplicates on it (D4: "Retries |
| 55 | // deduplicate by the retained loss identity"). |
| 56 | func automaticLossID(opID, box string, ref FenceRef) string { |
| 57 | sum := sha256.Sum256([]byte(opID + "\x00" + box + "\x00" + ref.Enrollment + "\x00" + ref.SHA256)) |
| 58 | return "auto-" + hex.EncodeToString(sum[:16]) |
| 59 | } |
| 60 | |
| 61 | func validateAutomaticLosses(c *Claim, refs map[string]bool) error { |
| 62 | seen := map[string]bool{} |
| 63 | for _, l := range c.EvidenceRefs { |
| 64 | if !refs[l.ID] || seen[l.ID] || l.ID != automaticLossID(l.Op, l.Box, l.Fence) || !validBoxReference(l.Box) || !validFenceRef(l.Fence) || l.Enrollment != l.Fence.Enrollment || l.Op == "" || l.Kind == "" || l.Phase == "" || !isHex64(l.Incarnation) || (l.Role != "source" && l.Role != "target") || (l.Timers != TimersComplete && l.Timers != TimersPartial && l.Timers != TimersUnknown) { |
| 65 | return fmt.Errorf("%w: automatic loss evidence", ErrMalformed) |
| 66 | } |
| 67 | at, err := time.Parse(time.RFC3339Nano, l.ObservedAt) |
| 68 | if err != nil || at.IsZero() { |
| 69 | return fmt.Errorf("%w: automatic loss observation time", ErrMalformed) |
| 70 | } |
| 71 | if l.LastAdmission != nil && (l.LastAdmission.Box != l.Box || l.LastAdmission.Session != l.Session || l.LastAdmission.Generation != l.Generation || !validWriteLosses(l.LastAdmission.WritesLost, l.LastAdmission.AdditionalWritesLost)) { |
| 72 | return fmt.Errorf("%w: automatic loss admission", ErrMalformed) |
| 73 | } |
| 74 | if l.Discharge.Qualified && len(l.Discharge.Records) == 0 { |
| 75 | return fmt.Errorf("%w: automatic loss discharge", ErrMalformed) |
| 76 | } |
| 77 | for _, ref := range l.Reassigned { |
| 78 | if ref.ID == "" || ref.Op == "" || ref.Retirement == "" { |
| 79 | return fmt.Errorf("%w: automatic loss regional work", ErrMalformed) |
| 80 | } |
| 81 | } |
| 82 | seen[l.ID] = true |
| 83 | } |
| 84 | return nil |
| 85 | } |
| 86 | |