package hostclaim import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "io" "reflect" "strings" "time" "github.com/carlosframework/platform/internal/release" ) var ( ErrLossOwnerRequired = errors.New("hostclaim: operation owner must authorize lost-box transition") ErrLossAlreadyApplied = errors.New("hostclaim: loss transition already applied") ErrLossEvidencePending = errors.New("hostclaim: admission loss evidence is not archived") ) // LostDeclaration is the exact claim observation and approved effect set from // one prepared durable loss record. ExpectedClaimETag is checked before Update // and the generation is checked again inside its transition. type LostDeclaration struct { LossID string ExpectedClaimGen uint64 ExpectedClaimETag string HolderIncarnation string HolderStamp uint64 HolderSession uint64 OpID string OpKind string OpPhase string Box string QuarantineRevision uint64 Enrollment string FencedAt time.Time Consequences LossConsequences } // LossOperationOwner owns the legal transition for its operation kind. A // generic declare-lost path cannot infer moves' source/target or record-commit // state, so a missing owner always refuses. type LossOperationOwner interface { ValidateDeclareLost(Claim, LostDeclaration) error } // DeclareLost reads and verifies the root-signed prepared declaration, then // conditionally commits its exact effects. Owning both reads and Update here // makes the durable record, claim ETag and claim generation enforceable API // inputs rather than caller conventions. func DeclareLost(ctx context.Context, s release.Store, pub ed25519.PublicKey, observed Observed, lossID string, owner LossOperationOwner) (Observed, error) { if owner == nil { return Observed{}, ErrLossOwnerRequired } loss, err := ReadLossRecord(ctx, s, pub, lossID) if err != nil { return Observed{}, err } in := declarationFromRecord(loss.Record) if observed.Absent || observed.Claim == nil || observed.ETag == "" || observed.Host != observed.Claim.Host || observed.Host != loss.Record.Host { return Observed{}, ErrConflict } if lossRefPresent(observed.Claim, in.LossID) { // loss_refs is the permanent commit marker. Readmission intentionally // removes Lost/LostRevisions while preserving this evidence. return observed, nil } if loss.Record.State != "prepared" || observed.ETag != in.ExpectedClaimETag || observed.Claim.Gen != in.ExpectedClaimGen { return Observed{}, ErrConflict } return Update(ctx, s, observed, declareLostTransition(in, owner)) } func declarationFromRecord(r LossRecord) LostDeclaration { return LostDeclaration{ LossID: r.ID, ExpectedClaimGen: r.ExpectedClaimGen, ExpectedClaimETag: r.ExpectedClaimETag, HolderIncarnation: r.HolderIncarnation, HolderStamp: r.HolderStamp, HolderSession: r.HolderSession, OpID: r.OpID, OpKind: r.OpKind, OpPhase: r.OpPhase, Box: r.Box, Enrollment: r.Enrollment, QuarantineRevision: r.QuarantineRevision, FencedAt: r.FencedAt, Consequences: r.ConfirmedConsequences, } } // 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 Generation string Session uint64 Region string Baseline string RemoteMaxTXID uint64 } // LossRecoveryOwner validates the operation's persisted recovery handover // against the original declaration and the requested admission. It must check // the legal kind/phase transition, recovery intent, executor and installation // stamp. The library retains incarnation, operation and frozen-loss bindings; // it cannot infer the moves owner's legal recovery path. Inputs are copies. type LossRecoveryOwner interface { ValidateLossRecovery(Claim, LossRecord, Admission) error } func AdmitWriterWithLoss(ctx context.Context, s release.Store, pub ed25519.PublicKey, observed Observed, in Admission, lossID string, validated []ValidatedWriteSource, owner LossRecoveryOwner) (Observed, error) { if owner == nil { return Observed{}, ErrLossOwnerRequired } if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil || !lossRefPresent(observed.Claim, lossID) { return Observed{}, ErrLossRecord } loss, err := ReadLossRecord(ctx, s, pub, lossID) if err != nil { return Observed{}, err } if loss.Record.State != "committed" || loss.Record.Host != observed.Host || loss.Record.HolderIncarnation != observed.Claim.Holder.Incarnation || loss.Record.OpID == "" || loss.Record.OpID != observed.Claim.Op.ID { return Observed{}, ErrLossRecord } if in.Mode != "cold" || in.PredecessorCertified || in.Coverage != "" { return Observed{}, fmt.Errorf("%w: a declared target loss requires an uncovered cold admission", ErrLossRecord) } h := observed.Claim.Holder predecessor := AdmissionRecord{Session: h.Session, Box: h.WriterBox, Generation: h.Generation, Region: in.PredecessorRegion} if len(h.Admissions) > 0 { predecessor = h.Admissions[len(h.Admissions)-1] } for i, prior := range h.Admissions { if prior.LossID != lossID { continue } predecessorRegion := in.PredecessorRegion if i > 0 { predecessorRegion = h.Admissions[i-1].Region } if prior.Box != in.Box || prior.Mode != in.Mode || prior.Generation != in.NewGeneration || prior.Region != in.Region || prior.PredecessorRegion != predecessorRegion || prior.Coverage != in.Coverage || prior.Predecessor != in.ObservedGeneration || prior.WritesLost == nil || prior.WritesLost.Generation != in.ObservedGeneration || prior.WritesLost.Session != in.ObservedSession || prior.WritesLost.RemoteMaxTXID != in.RemoteMaxTXID || !reflect.DeepEqual(prior.AdditionalWritesLost, in.AdditionalWritesLost) { return Observed{}, fmt.Errorf("%w: declaration id already used by another admission", ErrWrongState) } predecessor = AdmissionRecord{Box: prior.WritesLost.Box, Generation: prior.WritesLost.Generation, Session: prior.WritesLost.Session, Region: prior.PredecessorRegion} break } want := map[string]WriteSource{} for _, source := range loss.Record.WriteSources { key := fmt.Sprintf("%s\x00%s\x00%d", source.Box, source.Generation, source.Session) want[key] = source } targetKey := fmt.Sprintf("%s\x00%s\x00%d", predecessor.Box, predecessor.Generation, predecessor.Session) if len(want) != 0 && want[targetKey].Reason != "target" && want[targetKey].Reason != "source-cut" { 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 len(want) != 0 && !validWriteLosses(&primary, in.AdditionalWritesLost) { return Observed{}, ErrLossRecord } declared := map[string]WritesLost{} if len(want) != 0 { declared[targetKey] = primary } for _, additional := range in.AdditionalWritesLost { key := fmt.Sprintf("%s\x00%s\x00%d", additional.Box, additional.Generation, additional.Session) 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 { key := fmt.Sprintf("%s\x00%s\x00%d", source.Box, source.Generation, source.Session) if _, duplicate := provided[key]; duplicate { return Observed{}, ErrLossRecord } provided[key] = source } if len(provided) != len(want) { return Observed{}, fmt.Errorf("%w: admission omitted validated baseline evidence", ErrLossRecord) } for key, frozen := range want { actual, ok := provided[key] 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) } declaration, ok := declared[key] if !ok || declaration.RemoteMaxTXID != actual.RemoteMaxTXID { return Observed{}, fmt.Errorf("%w: declared remote maximum differs from its validated baseline", ErrLossRecord) } } // Owner policy may inspect detached inputs but cannot alter the claim, // frozen declaration or the admission that passed the evidence checks. policy := struct { Claim Claim Loss LossRecord Admission Admission }{*observed.Claim, loss.Record, in} wire, err := json.Marshal(policy) if err != nil { return Observed{}, err } // Clear shared maps, slices and pointers before decoding the copies. policy.Claim, policy.Loss, policy.Admission = Claim{}, LossRecord{}, Admission{} if err := json.Unmarshal(wire, &policy); err != nil { return Observed{}, err } if err := owner.ValidateLossRecovery(policy.Claim, policy.Loss, policy.Admission); err != nil { return Observed{}, err } in.LossID, in.lossVerified = lossID, true next, err := Update(ctx, s, observed, AdmitWriter(in)) if errors.Is(err, ErrLossAlreadyApplied) { return observed, nil } return next, err } func declareLostTransition(in LostDeclaration, owner LossOperationOwner) func(*Claim) error { return func(c *Claim) error { if owner == nil { return ErrLossOwnerRequired } if !safeLossID(in.LossID) || in.ExpectedClaimGen == 0 || in.ExpectedClaimETag == "" || in.QuarantineRevision == 0 || in.Box == "" || in.Enrollment == "" || in.FencedAt.IsZero() || !validConsequences(in.Consequences) { return ErrLossRecord } if c.Gen != in.ExpectedClaimGen || c.Holder == nil || c.Holder.Incarnation != in.HolderIncarnation || c.Holder.Stamp != in.HolderStamp || c.Holder.Session != in.HolderSession || c.Op.ID != in.OpID || c.Op.Kind != in.OpKind || c.Op.Phase != in.OpPhase { return fmt.Errorf("%w: loss declaration no longer matches the holder and operation", ErrStaleOp) } if old := c.LostRevisions[in.Box]; old != 0 && in.QuarantineRevision <= old { return fmt.Errorf("%w: declaration requires a strictly newer quarantine revision", ErrWrongState) } if lossRefPresent(c, in.LossID) { return ErrLossAlreadyApplied } for key := range c.Op.Journal { if strings.HasPrefix(key, "loss.") && !(destroyedLocalOnly(in.Consequences) && stringPresent(c.Lost, in.Box)) { return ErrWrongState } } copy, err := cloneClaim(c) if err != nil { return err } // The policy callback may inspect copies, never alter the signed effects. ownerBytes, err := json.Marshal(in) if err != nil { return err } var ownerInput LostDeclaration if err := json.Unmarshal(ownerBytes, &ownerInput); err != nil { return err } if err := owner.ValidateDeclareLost(*copy, ownerInput); err != nil { return err } if c.Op.Kind == KindServing || destroyedLocalOnly(in.Consequences) { if c.Op.Kind != KindServing && c.Op.Exec == in.Box { return ErrWrongState } if (c.Op.Kind == KindServing && (in.OpID != "" || in.OpPhase != "")) || !destroyedLocalOnly(in.Consequences) { return ErrLossRecord } // No operation journal, authority, regional reassignment or holder // mutation is permitted in this branch. Original chore identities // still drive discovery completion after adoption cleared op.id. if err := dischargeDestroyedLocal(c, in); err != nil { return err } recordLost(c, in) return nil } if in.OpID == "" { return ErrLossRecord } isExec := c.Op.Exec == in.Box isParticipant := stringPresent(c.Op.Participants, in.Box) if isExec != in.Consequences.RevokeExecution || isParticipant != in.Consequences.RemoveParticipation { return fmt.Errorf("%w: confirmed authority consequences do not match the claim", ErrWrongState) } stamp, session := c.Holder.Stamp, c.Holder.Session intent, err := canonicalRaw(in.Consequences.RecoveryIntent) if err != nil { return ErrLossRecord } if c.Op.Journal == nil { c.Op.Journal = map[string]string{} } journalKey := "loss." + in.LossID if prior, exists := c.Op.Journal[journalKey]; exists && prior != intent { return fmt.Errorf("%w: declaration recovery intent changed", ErrWrongState) } // Recovery intent is recorded before any authority or chore effects in // this transition. Update makes the entire sequence one claim CAS. c.Op.Journal[journalKey] = intent gate, err := json.Marshal(operationLossGate{Box: in.Box, RevokeExecution: in.Consequences.RevokeExecution}) if err != nil { return err } c.Op.Journal["loss_gate."+in.LossID] = string(gate) if isExec { c.Op.Exec = "none" } if isParticipant { c.Op.Participants = removeString(c.Op.Participants, in.Box) } ownedRegional := map[string]bool{} for _, o := range c.Obligations { if (o.Kind == "park-replica" || o.Kind == "park-generation") && o.Owner == "box:"+in.Box { ownedRegional[o.Op+"\x00"+o.ID+"\x00"+o.Retirement] = true } } confirmedRegional := map[string]bool{} for _, ref := range in.Consequences.ReassignRegional { confirmedRegional[ref.Op+"\x00"+ref.ID+"\x00"+ref.Retirement] = true } if !reflect.DeepEqual(ownedRegional, confirmedRegional) { return fmt.Errorf("%w: confirmed regional reassignments do not exactly cover lost-box work", ErrWrongState) } for _, ref := range in.Consequences.ReassignRegional { o, ok := exactObligation(c, ref) if !ok || (o.Kind != "park-replica" && o.Kind != "park-generation") || o.Region == "" || o.Owner != "box:"+in.Box { return fmt.Errorf("%w: regional obligation %q changed", ErrWrongState, ref.ID) } o.Owner = "region:" + o.Region } if err := dischargeDestroyedLocal(c, in); err != nil { return err } if c.Holder.Stamp != stamp || c.Holder.Session != session { return fmt.Errorf("%w: declare-lost changed installation authority", ErrMalformed) } recordLost(c, in) return nil } } func recordLost(c *Claim, in LostDeclaration) { if !stringPresent(c.Lost, in.Box) { c.Lost = append(c.Lost, in.Box) } if c.LostRevisions == nil { c.LostRevisions = map[string]uint64{} } c.LostRevisions[in.Box] = in.QuarantineRevision c.LossRefs = append(c.LossRefs, in.LossID) } func dischargeDestroyedLocal(c *Claim, in LostDeclaration) error { for _, ref := range in.Consequences.DischargeLocal { o, ok := exactObligation(c, ref) if !ok || o.Kind != "park-local" || o.Box != in.Box || !destroyedStorageCovers(in, ref) { return fmt.Errorf("%w: local obligation %q lacks exact destroyed-storage evidence", ErrWrongState, ref.ID) } if err := CompleteObligation(ref.Op, ref.ID)(c); err != nil { return err } } return nil } func destroyedStorageCovers(in LostDeclaration, ref ObligationRef) bool { needle := ref.Op + "/" + ref.ID + "/" + ref.Retirement for _, covered := range in.Consequences.DestroyedStorage { if covered == needle { return true } } return false } func exactObligation(c *Claim, ref ObligationRef) (*Obligation, bool) { for i := range c.Obligations { o := &c.Obligations[i] if o.ID == ref.ID && o.Op == ref.Op && o.Retirement == ref.Retirement { return o, true } } return nil, false } func cloneClaim(c *Claim) (*Claim, error) { raw, err := json.Marshal(c) if err != nil { return nil, err } return decodeStrict(raw) } func canonicalRaw(raw json.RawMessage) (string, error) { var value any decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&value); err != nil { return "", err } if err := decoder.Decode(new(any)); err != io.EOF { return "", ErrLossRecord } canonical, err := json.Marshal(value) return string(canonical), err } func lossRefPresent(c *Claim, id string) bool { return stringPresent(c.LossRefs, id) } func stringPresent(values []string, wanted string) bool { for _, value := range values { if value == wanted { return true } } return false } func removeString(values []string, unwanted string) []string { out := values[:0] for _, value := range values { if value != unwanted { out = append(out, value) } } return out } func validRegionalOwner(owner, region string) bool { if owner == "region:"+region { return true } return len(owner) > len("box:") && owner[:len("box:")] == "box:" && validBoxReference(owner[len("box:"):]) } // Readmit removes only the quarantine entry for the exact revision being // cleared. Durable declaration references intentionally survive. func Readmit(box string, quarantineRevision uint64) func(*Claim) error { return func(c *Claim) error { if box == "" || quarantineRevision == 0 || c.LostRevisions[box] != quarantineRevision || !stringPresent(c.Lost, box) { return fmt.Errorf("%w: quarantine revision does not match", ErrWrongState) } c.Lost = removeString(c.Lost, box) delete(c.LostRevisions, box) if len(c.LostRevisions) == 0 { c.LostRevisions = nil } return nil } } func admissionHasLoss(a AdmissionRecord) bool { return a.WritesLost != nil || len(a.AdditionalWritesLost) != 0 } func admissionLossArchived(a AdmissionRecord) bool { return !admissionHasLoss(a) || a.LossArchivedClaimGen == a.Session } // RequireLossEvidenceArchived is called by every holder-removal transition. func RequireLossEvidenceArchived(c *Claim) error { if c.Holder == nil { return nil } for _, a := range c.Holder.Admissions { if !admissionLossArchived(a) { return ErrLossEvidencePending } } return nil } // ArchiveAdmissionLosses reads the root-signed committed record and marks only // entries whose exact declarations and admission claim generation are already // durable there. The pure transition is private so callers cannot fabricate // archive proof. func ArchiveAdmissionLosses(ctx context.Context, s release.Store, pub ed25519.PublicKey, observed Observed, lossID string) (Observed, error) { loss, err := ReadLossRecord(ctx, s, pub, lossID) if err != nil { return Observed{}, err } if loss.Record.State != "committed" || loss.Record.Host != observed.Host { return Observed{}, ErrLossEvidencePending } return Update(ctx, s, observed, archiveAdmissionLossesTransition(lossID, loss.Record.ArchivedAdmissionLosses)) } func archiveAdmissionLossesTransition(lossID string, archived []ArchivedAdmissionLoss) func(*Claim) error { return func(c *Claim) error { if c.Holder == nil || !lossRefPresent(c, lossID) { return ErrLossEvidencePending } byGen := map[uint64]ArchivedAdmissionLoss{} for _, a := range archived { if a.LossID != lossID || a.AdmissionClaimGen == 0 || !validWriteLosses(a.WritesLost, a.AdditionalWritesLost) { return ErrLossEvidencePending } byGen[a.AdmissionClaimGen] = a } matched := false for i := range c.Holder.Admissions { a := &c.Holder.Admissions[i] if a.LossID != lossID || !admissionHasLoss(*a) { continue } copy, ok := byGen[a.Session] if !ok || !reflect.DeepEqual(copy.WritesLost, a.WritesLost) || !reflect.DeepEqual(copy.AdditionalWritesLost, a.AdditionalWritesLost) { return ErrLossEvidencePending } a.LossArchivedClaimGen = a.Session matched = true } if !matched { return ErrLossEvidencePending } return nil } }