package hostclaim import ( "crypto/sha256" "encoding/hex" "fmt" "time" ) type LocalDischarge struct { Qualified bool `json:"qualified"` Records []string `json:"records,omitempty"` Reason string `json:"reason,omitempty"` } // AutomaticLoss is claim ยง7.3 D4's "Automatic loss persistence": what the // transition that consumed a termination fence retains on the claim itself, // outside the replaceable operation, until the console materializes the // D4 host loss record. It survives CompleteRelocation, free and holder // replacement because it lives beside loss_refs, not in op.journal. type AutomaticLoss struct { ID string `json:"id"` // the loss identity, also in loss_refs Box string `json:"box"` Enrollment string `json:"enrollment"` Fence FenceRef `json:"fence"` ObservedAt string `json:"observed_at"` Op string `json:"op"` Kind string `json:"kind"` Phase string `json:"phase"` Role string `json:"role"` // "source" | "target" // Incarnation and Stamp bind the loss to the holder it was taken // against; Generation and Session are the box's last writer session, // whether it is still the writer or was superseded, and LastAdmission // is that session's admission record when the claim holds one (the // consequences the console reports: its coverage and declarations). Incarnation string `json:"incarnation"` Stamp uint64 `json:"stamp"` Generation string `json:"generation,omitempty"` Session uint64 `json:"session,omitempty"` LastAdmission *AdmissionRecord `json:"last_admission,omitempty"` Discharge LocalDischarge `json:"discharge"` // Reassigned is the regional work (park-generation, park-replica) // the box owned that the same CAS passed to its region (L3, as // DeclareLost's reassign_regional): the console's D4 record names it. Reassigned []ObligationRef `json:"reassigned,omitempty"` Timers TimerKnowledge `json:"timers"` // Reported is set by the console once the D4 record holds this loss // and its alert work exists. Until then reporting is pending. Reported bool `json:"reported,omitempty"` } // automaticLossID is deterministic for one fence consumed by one // operation for one box, so a replayed transition names the same loss and // the console's materialization deduplicates on it (D4: "Retries // deduplicate by the retained loss identity"). func automaticLossID(opID, box string, ref FenceRef) string { sum := sha256.Sum256([]byte(opID + "\x00" + box + "\x00" + ref.Enrollment + "\x00" + ref.SHA256)) return "auto-" + hex.EncodeToString(sum[:16]) } func validateAutomaticLosses(c *Claim, refs map[string]bool) error { seen := map[string]bool{} for _, l := range c.EvidenceRefs { 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) { return fmt.Errorf("%w: automatic loss evidence", ErrMalformed) } at, err := time.Parse(time.RFC3339Nano, l.ObservedAt) if err != nil || at.IsZero() { return fmt.Errorf("%w: automatic loss observation time", ErrMalformed) } 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)) { return fmt.Errorf("%w: automatic loss admission", ErrMalformed) } if l.Discharge.Qualified && len(l.Discharge.Records) == 0 { return fmt.Errorf("%w: automatic loss discharge", ErrMalformed) } for _, ref := range l.Reassigned { if ref.ID == "" || ref.Op == "" || ref.Retirement == "" { return fmt.Errorf("%w: automatic loss regional work", ErrMalformed) } } seen[l.ID] = true } return nil }