hostclaim, release: signed loss records, additive claim state and bound lost-box transitions (claim Plan 8 Task 1)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+1995
−13
internal/hostclaim/claim.go+27 −4internal/hostclaim/create.go+3 −0internal/hostclaim/delete.go+3 −0internal/hostclaim/execution_ack.go+56 −0internal/hostclaim/execution_ack_test.go+43 −0internal/hostclaim/generation.go+23 −2internal/hostclaim/generation_test.go+2 −6internal/hostclaim/lossrecord.go+372 −0internal/hostclaim/lossrecord_test.go+434 −0internal/hostclaim/lost.go+488 −0internal/hostclaim/lost_test.go+407 −0internal/hostclaim/rev713_test.go+2 −1internal/release/rootenvelope.go+91 −0internal/release/rootenvelope_test.go+44 −0
diff --git a/internal/hostclaim/claim.go b/internal/hostclaim/claim.go| index 025f1d58b..c41259090 100644 |
| --- a/internal/hostclaim/claim.go |
| +++ b/internal/hostclaim/claim.go |
| @@ -71,6 +71,8 @@ type AdmissionRecord struct { |
| Covered bool `json:"covered"` |
| WritesLost *WritesLost `json:"writes_lost,omitempty"` |
| AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"` |
| + LossID string `json:"loss_id,omitempty"` |
| + LossArchivedClaimGen uint64 `json:"loss_archived_claim_gen,omitempty"` |
| } |
| type WritesLost struct { |
| @@ -133,6 +135,8 @@ type Claim struct { |
| Inventory map[string]InventoryEntry `json:"inventory,omitempty"` |
| Obligations []Obligation `json:"obligations,omitempty"` |
| Lost []string `json:"lost,omitempty"` |
| + LostRevisions map[string]uint64 `json:"lost_revisions,omitempty"` |
| + LossRefs []string `json:"loss_refs,omitempty"` |
| RetiredIncarnations []string `json:"retired_incarnations,omitempty"` |
| QuietAfter map[string]time.Time `json:"quiet_after,omitempty"` |
| CancelledCreates []CancelledCreate `json:"cancelled_creates,omitempty"` |
| @@ -226,10 +230,29 @@ func (c *Claim) Validate() error { |
| return fmt.Errorf("%w: holder without op", ErrMalformed) |
| } |
| for _, record := range h.Admissions { |
| - if record.Region == "" { |
| - return fmt.Errorf("%w: admission without region", ErrMalformed) |
| + if record.Region == "" || !validWriteLosses(record.WritesLost, record.AdditionalWritesLost) { |
| + return fmt.Errorf("%w: invalid admission", ErrMalformed) |
| } |
| + if record.LossID != "" && !safeLossID(record.LossID) { |
| + return fmt.Errorf("%w: invalid loss declaration id", ErrMalformed) |
| + } |
| + if record.LossArchivedClaimGen != 0 && (!admissionHasLoss(record) || record.LossArchivedClaimGen != record.Session) { |
| + return fmt.Errorf("%w: invalid archived admission loss", ErrMalformed) |
| + } |
| + } |
| + } |
| + lost := set(c.Lost) |
| + for box, revision := range c.LostRevisions { |
| + if box == "" || revision == 0 || !lost[box] { |
| + return fmt.Errorf("%w: invalid lost revision", ErrMalformed) |
| + } |
| + } |
| + seenLossRefs := map[string]bool{} |
| + for _, id := range c.LossRefs { |
| + if !safeLossID(id) || seenLossRefs[id] { |
| + return fmt.Errorf("%w: invalid loss references", ErrMalformed) |
| } |
| + seenLossRefs[id] = true |
| } |
| if len(c.RetiredIncarnations) > retiredCap { |
| return fmt.Errorf("%w: retired_incarnations over cap", ErrMalformed) |
| @@ -242,11 +265,11 @@ func (c *Claim) Validate() error { |
| seen[o.ID] = true |
| switch o.Kind { |
| case "park-generation": |
| - if o.Region == "" || o.Generation == "" || o.Box != "" || o.Owner != "region:"+o.Region { |
| + if o.Region == "" || o.Generation == "" || o.Box != "" || !validRegionalOwner(o.Owner, o.Region) { |
| return fmt.Errorf("%w: park-generation %q location/generation/owner", ErrMalformed, o.ID) |
| } |
| case "park-replica": |
| - if o.Region == "" || o.Box != "" || o.Generation != "" || o.Owner != "region:"+o.Region { |
| + if o.Region == "" || o.Box != "" || o.Generation != "" || !validRegionalOwner(o.Owner, o.Region) { |
| return fmt.Errorf("%w: park-replica %q location/owner", ErrMalformed, o.ID) |
| } |
| case "park-local": |
diff --git a/internal/hostclaim/create.go b/internal/hostclaim/create.go| index d42b76eca..4450413ba 100644 |
| --- a/internal/hostclaim/create.go |
| +++ b/internal/hostclaim/create.go |
| @@ -110,6 +110,9 @@ func CancelCreate(opID string, now time.Time) func(*Claim) error { |
| if err := requireNoCreateAuthority(c); err != nil { |
| return err |
| } |
| + if err := RequireLossEvidenceArchived(c); err != nil { |
| + return err |
| + } |
| inc, key := c.Holder.Incarnation, c.Holder.Key |
| retire(c, inc) |
| c.CancelledCreates = append(c.CancelledCreates, CancelledCreate{Key: key, Incarnation: inc, Until: now.UTC().Add(time.Hour)}) |
diff --git a/internal/hostclaim/delete.go b/internal/hostclaim/delete.go| index 22b1f996b..37af81c60 100644 |
| --- a/internal/hostclaim/delete.go |
| +++ b/internal/hostclaim/delete.go |
| @@ -109,6 +109,9 @@ func FreeAfterPark(opID string) func(*Claim) error { |
| if c.Op.Kind != KindParking || len(c.Obligations) > 0 { |
| return fmt.Errorf("%w: kind %q, %d obligations left", ErrWrongState, c.Op.Kind, len(c.Obligations)) |
| } |
| + if err := RequireLossEvidenceArchived(c); err != nil { |
| + return err |
| + } |
| c.Holder, c.Op, c.Touched, c.Inventory = nil, Op{}, Touched{}, nil |
| return nil |
| } |
diff --git a/internal/hostclaim/execution_ack.go b/internal/hostclaim/execution_ack.go| new file mode 100644 |
| index 000000000..57835e41c |
| --- /dev/null |
| +++ b/internal/hostclaim/execution_ack.go |
| @@ -0,0 +1,56 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| +) |
| + |
| +var ErrAckAlreadyApplied = errors.New("hostclaim: execution acknowledgement already applied") |
| + |
| +type ExecutionAcknowledgement struct { |
| + Op string `json:"op"` |
| + Enrollment string `json:"enrollment"` |
| + BootID string `json:"boot_id"` |
| + ControllerID string `json:"controller_id"` |
| + LeaseETag string `json:"lease_etag"` |
| + Expires time.Time `json:"expires"` |
| +} |
| + |
| +// AcknowledgeExecution records the first acknowledged lease for one required |
| +// participant. The lease write and durable local state happen before this |
| +// transition; a conflicting acknowledgement is never overwritten. |
| +func AcknowledgeExecution(box string, ack ExecutionAcknowledgement) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if !validBoxReference(box) || c.Op.Kind == "" || c.Op.Kind == KindServing || c.Op.ID == "" || ack.Op != c.Op.ID || |
| + ack.Enrollment == "" || ack.BootID == "" || ack.ControllerID == "" || ack.LeaseETag == "" || ack.Expires.IsZero() || |
| + !stringPresent(c.Op.Participants, box) { |
| + return fmt.Errorf("%w: execution acknowledgement is not for a required active participant", ErrWrongState) |
| + } |
| + raw, err := json.Marshal(ack) |
| + if err != nil { |
| + return err |
| + } |
| + if c.Op.Journal == nil { |
| + c.Op.Journal = map[string]string{} |
| + } |
| + key := "exec_ack." + box |
| + if previous, ok := c.Op.Journal[key]; ok { |
| + if previous == string(raw) { |
| + return ErrAckAlreadyApplied |
| + } |
| + return fmt.Errorf("%w: participant already acknowledged different lease", ErrWrongState) |
| + } |
| + c.Op.Journal[key] = string(raw) |
| + return nil |
| + } |
| +} |
| + |
| +func validBoxReference(box string) bool { |
| + if box == "" || len(box) > 128 || strings.ContainsAny(box, "/\\\x00\r\n\t ") { |
| + return false |
| + } |
| + return true |
| +} |
diff --git a/internal/hostclaim/execution_ack_test.go b/internal/hostclaim/execution_ack_test.go| new file mode 100644 |
| index 000000000..2879088e0 |
| --- /dev/null |
| +++ b/internal/hostclaim/execution_ack_test.go |
| @@ -0,0 +1,43 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestAcknowledgeExecutionWritesExactJournalValue(t *testing.T) { |
| + expires := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + ack := ExecutionAcknowledgement{Op: "op-move", Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1", LeaseETag: `"lease-etag"`, Expires: expires} |
| + c := &Claim{Op: Op{Kind: KindMoving, ID: "op-move", Participants: []string{"box-a"}}} |
| + if err := AcknowledgeExecution("box-a", ack)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + want, _ := json.Marshal(ack) |
| + if got := c.Op.Journal["exec_ack.box-a"]; got != string(want) { |
| + t.Fatalf("journal = %q, want %q", got, want) |
| + } |
| + if err := AcknowledgeExecution("box-a", ack)(c); !errors.Is(err, ErrAckAlreadyApplied) { |
| + t.Fatalf("idempotent retry = %v", err) |
| + } |
| + changed := ack |
| + changed.LeaseETag = `"other"` |
| + if err := AcknowledgeExecution("box-a", changed)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("conflicting ack = %v", err) |
| + } |
| +} |
| + |
| +func TestAcknowledgeExecutionRefusesServingAndNonparticipants(t *testing.T) { |
| + ack := ExecutionAcknowledgement{Op: "op", Enrollment: "e", BootID: "b", ControllerID: "c", LeaseETag: "tag", Expires: time.Now()} |
| + for name, c := range map[string]*Claim{ |
| + "serving": {Op: Op{Kind: KindServing, Participants: []string{"box"}}}, |
| + "nonparticipant": {Op: Op{Kind: KindMoving, ID: "op", Participants: []string{"other"}}}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if err := AcknowledgeExecution("box", ack)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("error = %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go| index 5491b62f0..9b63b4590 100644 |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| @@ -1,6 +1,9 @@ |
| package hostclaim |
| -import "fmt" |
| +import ( |
| + "fmt" |
| + "reflect" |
| +) |
| const RootGeneration = "@root" |
| @@ -18,6 +21,8 @@ type Admission struct { |
| Region string |
| PredecessorRegion string |
| AdditionalWritesLost []WritesLost |
| + LossID string |
| + lossVerified bool |
| } |
| // AdmitWriter is §6.4a's writer admission CAS within the current operation. |
| @@ -40,6 +45,21 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| return fmt.Errorf("%w: op kind %q cannot admit a writer", ErrWrongState, c.Op.Kind) |
| } |
| h := c.Holder |
| + if in.LossID != "" && !in.lossVerified { |
| + return fmt.Errorf("%w: declaration-bound admission requires verified durable declaration", ErrWrongState) |
| + } |
| + if in.LossID != "" { |
| + for _, prior := range h.Admissions { |
| + if prior.LossID != in.LossID { |
| + continue |
| + } |
| + if prior.Box == in.Box && prior.Mode == in.Mode && prior.Generation == in.NewGeneration && |
| + reflect.DeepEqual(prior.AdditionalWritesLost, in.AdditionalWritesLost) { |
| + return ErrLossAlreadyApplied |
| + } |
| + return fmt.Errorf("%w: declaration id already used by another admission", ErrWrongState) |
| + } |
| + } |
| if h.Session != in.ObservedSession || h.Generation != in.ObservedGeneration { |
| return fmt.Errorf("%w: writer session or generation moved since it was observed", ErrWrongState) |
| } |
| @@ -88,6 +108,7 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| } |
| entry := AdmissionRecord{ |
| Session: c.Gen + 1, Box: in.Box, Generation: h.Generation, |
| + LossID: in.LossID, |
| Predecessor: predecessor.Generation, Mode: in.Mode, Coverage: in.Coverage, |
| Region: in.Region, PredecessorRegion: predecessor.Region, |
| Covered: in.Mode == "warm", |
| @@ -109,7 +130,7 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| } |
| } |
| h.Admissions = append(h.Admissions, entry) |
| - for len(h.Admissions) > 8 && h.Admissions[0].Covered { |
| + for len(h.Admissions) > 8 && h.Admissions[0].Covered && admissionLossArchived(h.Admissions[0]) { |
| h.Admissions = h.Admissions[1:] |
| } |
| h.Session, h.Generation, h.WriterBox = entry.Session, entry.Generation, in.Box |
diff --git a/internal/hostclaim/generation_test.go b/internal/hostclaim/generation_test.go| index ae486d4a7..1c3383551 100644 |
| --- a/internal/hostclaim/generation_test.go |
| +++ b/internal/hostclaim/generation_test.go |
| @@ -705,12 +705,8 @@ func TestAdmitWriterTrimsCoveredPrefix(t *testing.T) { |
| before := o.Claim.Holder.Admissions |
| o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "G10", |
| Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G11", PredecessorCertified: true})) |
| - drop := 3 |
| - if name == "uncovered first" { |
| - drop = 0 |
| - } else if name == "uncovered middle" { |
| - drop = 1 |
| - } |
| + // Loss-bearing rows remain until archival, even when covered. |
| + drop := 0 |
| want := append(append([]AdmissionRecord(nil), before[drop:]...), AdmissionRecord{Region: "ie", PredecessorRegion: "ie", Session: o.Claim.Gen, Box: o.Claim.Op.Exec, Generation: "G11", Predecessor: "G10", Mode: "cold"}) |
| assertAdmissions(t, o.Claim, want) |
| }) |
diff --git a/internal/hostclaim/lossrecord.go b/internal/hostclaim/lossrecord.go| new file mode 100644 |
| index 000000000..59d2712fc |
| --- /dev/null |
| +++ b/internal/hostclaim/lossrecord.go |
| @@ -0,0 +1,372 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "reflect" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const lossRecordVersion = 1 |
| + |
| +var ( |
| + ErrLossRecord = errors.New("hostclaim: loss record refused") |
| + ErrLossRecordAbsent = errors.New("hostclaim: loss record absent") |
| +) |
| + |
| +type TimerKnowledge string |
| + |
| +const ( |
| + TimersComplete TimerKnowledge = "complete" |
| + TimersPartial TimerKnowledge = "partial" |
| + TimersUnknown TimerKnowledge = "unknown" |
| +) |
| + |
| +type TimerSource struct { |
| + Bucket string `json:"bucket"` |
| + Key string `json:"key"` |
| + VersionID string `json:"version_id"` |
| + SHA256 string `json:"sha256"` |
| + CapturedAt time.Time `json:"captured_at"` |
| +} |
| + |
| +type TimerLoss struct { |
| + Knowledge TimerKnowledge `json:"knowledge"` |
| + Source *TimerSource `json:"source,omitempty"` |
| + Entries []json.RawMessage `json:"entries,omitempty"` |
| +} |
| + |
| +type LossCloudObservation struct { |
| + ObservedAt time.Time `json:"observed_at"` |
| + Account string `json:"account"` |
| + Region string `json:"region"` |
| + InstanceID string `json:"instance_id"` |
| + State string `json:"state"` |
| + RequestID string `json:"request_id"` |
| +} |
| + |
| +type LossOperatorAttestation struct { |
| + MaintainComputeFence bool `json:"maintain_compute_fence"` |
| + ColdQuarantineStartOnly bool `json:"cold_quarantine_start_only"` |
| + DestroyedStorage []string `json:"destroyed_storage,omitempty"` |
| +} |
| + |
| +type ObligationRef struct { |
| + ID string `json:"id"` |
| + Op string `json:"op"` |
| + Retirement string `json:"retirement"` |
| +} |
| + |
| +type LossConsequences struct { |
| + RevokeExecution bool `json:"revoke_execution"` |
| + RemoveParticipation bool `json:"remove_participation"` |
| + DischargeLocal []ObligationRef `json:"discharge_local,omitempty"` |
| + ReassignRegional []ObligationRef `json:"reassign_regional,omitempty"` |
| + DestroyedStorage []string `json:"destroyed_storage,omitempty"` |
| + WritesPotentiallyLost bool `json:"writes_potentially_lost"` |
| + TimersPotentiallyLost bool `json:"timers_potentially_lost"` |
| + RecoveryIntent json.RawMessage `json:"recovery_intent"` |
| +} |
| + |
| +type WriteSource struct { |
| + Reason string `json:"reason"` // target | source-cut |
| + Box string `json:"box"` |
| + Generation string `json:"generation"` |
| + Session uint64 `json:"session"` |
| + Region string `json:"region"` |
| + Baseline string `json:"baseline"` |
| +} |
| + |
| +// ArchivedAdmissionLoss is the exact evidence copied out of a claim before a |
| +// loss-bearing admission can be pruned or its holder removed. |
| +type ArchivedAdmissionLoss struct { |
| + LossID string `json:"loss_id"` |
| + AdmissionClaimGen uint64 `json:"admission_claim_gen"` |
| + WritesLost *WritesLost `json:"writes_lost,omitempty"` |
| + AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"` |
| +} |
| + |
| +type LossRecord struct { |
| + V int `json:"v"` |
| + ID string `json:"id"` |
| + State string `json:"state"` // prepared | committed | conflicted |
| + Actor string `json:"actor"` |
| + AcceptedAt time.Time `json:"accepted_at"` |
| + Host string `json:"host"` |
| + HolderIncarnation string `json:"holder_incarnation"` |
| + HolderStamp uint64 `json:"holder_stamp"` |
| + HolderSession uint64 `json:"holder_session"` |
| + ExpectedClaimGen uint64 `json:"expected_claim_gen"` |
| + ExpectedClaimETag string `json:"expected_claim_etag"` |
| + OpID string `json:"op_id"` |
| + OpKind string `json:"op_kind"` |
| + OpPhase string `json:"op_phase"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + QuarantineRevision uint64 `json:"quarantine_revision"` |
| + CloudObservation LossCloudObservation `json:"cloud_observation"` |
| + OperatorAttestation LossOperatorAttestation `json:"operator_attestation"` |
| + FencedAt time.Time `json:"fenced_at"` |
| + ConfirmedConsequences LossConsequences `json:"confirmed_consequences"` |
| + Timers TimerLoss `json:"timers"` |
| + WriteSources []WriteSource `json:"write_sources,omitempty"` |
| + ResultClaimGen uint64 `json:"result_claim_gen,omitempty"` |
| + ArchivedAdmissionLosses []ArchivedAdmissionLoss `json:"archived_admission_losses,omitempty"` |
| +} |
| + |
| +type ObservedLossRecord struct { |
| + Record LossRecord |
| + ETag string |
| +} |
| + |
| +func LossRecordKey(id string) (string, error) { |
| + if !safeLossID(id) { |
| + return "", fmt.Errorf("%w: invalid declaration id", ErrLossRecord) |
| + } |
| + return "control/host-loss/" + id + ".json", nil |
| +} |
| + |
| +func safeLossID(id string) bool { |
| + if id == "" || len(id) > 128 { |
| + return false |
| + } |
| + for _, r := range id { |
| + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func (r LossRecord) Validate() error { |
| + if r.V != lossRecordVersion || !safeLossID(r.ID) || (r.State != "prepared" && r.State != "committed" && r.State != "conflicted") || |
| + r.Actor == "" || r.AcceptedAt.IsZero() || r.Host == "" || !isHex64(r.HolderIncarnation) || r.HolderStamp == 0 || r.ExpectedClaimGen == 0 || |
| + r.ExpectedClaimETag == "" || !knownKinds[r.OpKind] || r.Box == "" || r.Enrollment == "" || |
| + r.QuarantineRevision == 0 || r.FencedAt.IsZero() { |
| + return ErrLossRecord |
| + } |
| + if r.OpKind == KindServing { |
| + if r.OpID != "" || r.OpPhase != "" || !destroyedLocalOnly(r.ConfirmedConsequences) || |
| + len(r.WriteSources) != 0 || len(r.ArchivedAdmissionLosses) != 0 { |
| + return ErrLossRecord |
| + } |
| + } else if r.OpID == "" { |
| + return ErrLossRecord |
| + } |
| + if _, err := Key(r.Host); err != nil { |
| + return ErrLossRecord |
| + } |
| + if r.State == "committed" && r.ResultClaimGen == 0 || r.State == "prepared" && r.ResultClaimGen != 0 { |
| + return ErrLossRecord |
| + } |
| + o := r.CloudObservation |
| + if o.ObservedAt.IsZero() || o.Account == "" || o.Region == "" || o.InstanceID == "" || o.RequestID == "" || (o.State != "stopped" && o.State != "terminated") { |
| + return ErrLossRecord |
| + } |
| + if !r.OperatorAttestation.MaintainComputeFence || !r.OperatorAttestation.ColdQuarantineStartOnly || !validConsequences(r.ConfirmedConsequences) || |
| + !reflect.DeepEqual(r.OperatorAttestation.DestroyedStorage, r.ConfirmedConsequences.DestroyedStorage) || !validTimerLoss(r.Timers) { |
| + return ErrLossRecord |
| + } |
| + seenSources := map[string]bool{} |
| + for _, s := range r.WriteSources { |
| + if (s.Reason != "target" && s.Reason != "source-cut") || s.Box == "" || s.Session == 0 || s.Region == "" || s.Baseline == "" { |
| + return ErrLossRecord |
| + } |
| + key := fmt.Sprintf("%s\x00%d", s.Box, s.Session) |
| + if seenSources[key] { |
| + return ErrLossRecord |
| + } |
| + seenSources[key] = true |
| + } |
| + seenArchived := map[string]bool{} |
| + for _, a := range r.ArchivedAdmissionLosses { |
| + if a.LossID != r.ID || a.AdmissionClaimGen == 0 || (a.WritesLost == nil && len(a.AdditionalWritesLost) == 0) || !validWriteLosses(a.WritesLost, a.AdditionalWritesLost) { |
| + return ErrLossRecord |
| + } |
| + key := fmt.Sprintf("%s\x00%d", a.LossID, a.AdmissionClaimGen) |
| + if seenArchived[key] { |
| + return ErrLossRecord |
| + } |
| + seenArchived[key] = true |
| + } |
| + return nil |
| +} |
| + |
| +func destroyedLocalOnly(c LossConsequences) bool { |
| + if !validConsequences(c) || c.RevokeExecution || c.RemoveParticipation || len(c.ReassignRegional) != 0 || |
| + c.WritesPotentiallyLost || c.TimersPotentiallyLost || len(c.DischargeLocal) == 0 || |
| + len(c.DestroyedStorage) != len(c.DischargeLocal) { |
| + return false |
| + } |
| + intent, err := canonicalRaw(c.RecoveryIntent) |
| + if err != nil || intent != `{"kind":"destroyed-local-only"}` { |
| + return false |
| + } |
| + want := map[string]bool{} |
| + for _, ref := range c.DischargeLocal { |
| + want[ref.Op+"/"+ref.ID+"/"+ref.Retirement] = true |
| + } |
| + for _, assertion := range c.DestroyedStorage { |
| + if !want[assertion] { |
| + return false |
| + } |
| + delete(want, assertion) |
| + } |
| + return len(want) == 0 |
| +} |
| + |
| +func validConsequences(c LossConsequences) bool { |
| + if len(c.RecoveryIntent) == 0 || !json.Valid(c.RecoveryIntent) { |
| + return false |
| + } |
| + var object map[string]json.RawMessage |
| + if json.Unmarshal(c.RecoveryIntent, &object) != nil || object == nil { |
| + return false |
| + } |
| + for _, refs := range [][]ObligationRef{c.DischargeLocal, c.ReassignRegional} { |
| + seen := map[string]bool{} |
| + for _, ref := range refs { |
| + if ref.ID == "" || ref.Op == "" || ref.Retirement == "" || seen[ref.Op+"\x00"+ref.ID] { |
| + return false |
| + } |
| + seen[ref.Op+"\x00"+ref.ID] = true |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func validTimerLoss(t TimerLoss) bool { |
| + switch t.Knowledge { |
| + case TimersUnknown: |
| + return t.Source == nil && len(t.Entries) == 0 |
| + case TimersComplete, TimersPartial: |
| + if t.Source == nil || t.Source.Bucket == "" || t.Source.Key == "" || t.Source.VersionID == "" || !isHex64(t.Source.SHA256) || t.Source.CapturedAt.IsZero() { |
| + return false |
| + } |
| + default: |
| + return false |
| + } |
| + for _, entry := range t.Entries { |
| + if len(entry) == 0 || !json.Valid(entry) || strings.TrimSpace(string(entry)) == "null" { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func validWriteLosses(primary *WritesLost, additional []WritesLost) bool { |
| + seen := map[string]bool{} |
| + // writes_lost predates durable declarations and legitimately contains |
| + // legacy session zero / empty provenance. Decode it additively. New |
| + // additional losses are the tuples this contract validates strictly. |
| + if primary != nil && primary.Box != "" { |
| + seen[fmt.Sprintf("%s\x00%d", primary.Box, primary.Session)] = true |
| + } |
| + for _, w := range additional { |
| + if w.Box == "" || w.Session == 0 { |
| + return false |
| + } |
| + key := fmt.Sprintf("%s\x00%d", w.Box, w.Session) |
| + if seen[key] { |
| + return false |
| + } |
| + seen[key] = true |
| + } |
| + return true |
| +} |
| + |
| +func ReadLossRecord(ctx context.Context, s release.Store, pub ed25519.PublicKey, id string) (ObservedLossRecord, error) { |
| + key, err := LossRecordKey(id) |
| + if err != nil { |
| + return ObservedLossRecord{}, err |
| + } |
| + wire, etag, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return ObservedLossRecord{}, ErrLossRecordAbsent |
| + } |
| + if err != nil { |
| + return ObservedLossRecord{}, err |
| + } |
| + var record LossRecord |
| + if err := release.VerifyRootEnvelope(pub, wire, &record); err != nil || record.ID != id || record.Validate() != nil || etag == "" { |
| + return ObservedLossRecord{}, ErrLossRecord |
| + } |
| + return ObservedLossRecord{Record: record, ETag: etag}, nil |
| +} |
| + |
| +func CreateLossRecord(ctx context.Context, s release.Store, sg release.Signer, record LossRecord) (ObservedLossRecord, error) { |
| + if record.State != "prepared" || record.Validate() != nil { |
| + return ObservedLossRecord{}, ErrLossRecord |
| + } |
| + key, _ := LossRecordKey(record.ID) |
| + wire, err := release.SignRootEnvelope(sg, record) |
| + if err != nil { |
| + return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err) |
| + } |
| + etag, err := s.Put(ctx, key, wire, "") |
| + if errors.Is(err, release.ErrCASConflict) { |
| + existing, readErr := ReadLossRecord(ctx, s, sg.RootPublicKey(), record.ID) |
| + if readErr == nil && reflect.DeepEqual(existing.Record, record) { |
| + return existing, nil |
| + } |
| + return ObservedLossRecord{}, fmt.Errorf("%w: declaration id already names different evidence", ErrLossRecord) |
| + } |
| + if err != nil { |
| + return ObservedLossRecord{}, err |
| + } |
| + return ObservedLossRecord{Record: record, ETag: etag}, nil |
| +} |
| + |
| +// ReplaceLossRecord conditionally advances delivery/accounting fields while |
| +// proving that the accepted evidence and consequences prepared earlier are |
| +// byte-for-byte unchanged. |
| +func ReplaceLossRecord(ctx context.Context, s release.Store, sg release.Signer, before ObservedLossRecord, next LossRecord) (ObservedLossRecord, error) { |
| + if before.ETag == "" || before.Record.Validate() != nil || next.Validate() != nil || !immutableLossFieldsEqual(before.Record, next) || !validLossProgression(before.Record, next) { |
| + return ObservedLossRecord{}, ErrLossRecord |
| + } |
| + key, _ := LossRecordKey(next.ID) |
| + wire, err := release.SignRootEnvelope(sg, next) |
| + if err != nil { |
| + return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err) |
| + } |
| + etag, err := s.Put(ctx, key, wire, before.ETag) |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return ObservedLossRecord{}, ErrConflict |
| + } |
| + if err != nil { |
| + return ObservedLossRecord{}, err |
| + } |
| + return ObservedLossRecord{Record: next, ETag: etag}, nil |
| +} |
| + |
| +func immutableLossFieldsEqual(a, b LossRecord) bool { |
| + a.State, b.State = "", "" |
| + a.ResultClaimGen, b.ResultClaimGen = 0, 0 |
| + a.ArchivedAdmissionLosses, b.ArchivedAdmissionLosses = nil, nil |
| + return reflect.DeepEqual(a, b) |
| +} |
| + |
| +func validLossProgression(before, next LossRecord) bool { |
| + if next.ID != before.ID || len(next.ArchivedAdmissionLosses) < len(before.ArchivedAdmissionLosses) { |
| + return false |
| + } |
| + if len(before.ArchivedAdmissionLosses) > 0 && !reflect.DeepEqual(next.ArchivedAdmissionLosses[:len(before.ArchivedAdmissionLosses)], before.ArchivedAdmissionLosses) { |
| + return false |
| + } |
| + switch before.State { |
| + case "prepared": |
| + return next.State == "prepared" || next.State == "committed" || next.State == "conflicted" |
| + case "committed": |
| + return next.State == "committed" && next.ResultClaimGen == before.ResultClaimGen |
| + case "conflicted": |
| + return next.State == "conflicted" && next.ResultClaimGen == before.ResultClaimGen |
| + } |
| + return false |
| +} |
diff --git a/internal/hostclaim/lossrecord_test.go b/internal/hostclaim/lossrecord_test.go| new file mode 100644 |
| index 000000000..9bb59fa6d |
| --- /dev/null |
| +++ b/internal/hostclaim/lossrecord_test.go |
| @@ -0,0 +1,434 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Existing fixtures declare directly in the recovery phase. The end-to-end |
| +// test below supplies a separate owner for landing -> restoring. |
| +type testLossRecoveryOwner struct{} |
| + |
| +func (testLossRecoveryOwner) ValidateLossRecovery(c Claim, loss LossRecord, in Admission) error { |
| + if c.Op.Kind != loss.OpKind || c.Op.Phase != loss.OpPhase || c.Op.Exec != in.Box { |
| + return ErrWrongState |
| + } |
| + return nil |
| +} |
| + |
| +type lossRecoveryOwnerFunc func(Claim, LossRecord, Admission) error |
| + |
| +func (f lossRecoveryOwnerFunc) ValidateLossRecovery(c Claim, loss LossRecord, in Admission) error { |
| + return f(c, loss, in) |
| +} |
| + |
| +func validLossRecord(t *testing.T) LossRecord { |
| + t.Helper() |
| + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + inc := strings.Repeat("a", 64) |
| + return LossRecord{ |
| + V: 1, ID: "loss_1", State: "prepared", Actor: "operator", AcceptedAt: now, |
| + Host: testHost, HolderIncarnation: inc, HolderStamp: 3, HolderSession: 2, ExpectedClaimGen: 4, ExpectedClaimETag: `"claim"`, |
| + OpID: "op", OpKind: KindMoving, OpPhase: "precommit", Box: "box-a", Enrollment: "enroll-a", QuarantineRevision: 9, |
| + CloudObservation: LossCloudObservation{ObservedAt: now, Account: "123", Region: "eu-west-1", InstanceID: "i-1", State: "stopped", RequestID: "request-1"}, |
| + OperatorAttestation: LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true}, FencedAt: now, |
| + ConfirmedConsequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(`{"kind":"lost-target"}`), TimersPotentiallyLost: true}, |
| + Timers: TimerLoss{Knowledge: TimersUnknown}, |
| + WriteSources: []WriteSource{{Reason: "target", Box: "box-a", Generation: "g1", Session: 3, Region: "ie", Baseline: "snapshot:v1"}}, |
| + } |
| +} |
| + |
| +func TestLossRecordCreateOnlyConditionalAndImmutable(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sg := release.RootSigner(priv) |
| + record := validLossRecord(t) |
| + created, err := CreateLossRecord(ctx, s, sg, record) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if created.ETag == "" { |
| + t.Fatal("create returned no ETag") |
| + } |
| + retry, err := CreateLossRecord(ctx, s, sg, record) |
| + if err != nil || retry.ETag != created.ETag { |
| + t.Fatalf("idempotent create = %+v, %v", retry, err) |
| + } |
| + read, err := ReadLossRecord(ctx, s, pub, record.ID) |
| + if err != nil || !reflect.DeepEqual(read.Record, record) { |
| + t.Fatalf("read = %+v, %v", read, err) |
| + } |
| + |
| + committed := read.Record |
| + committed.State, committed.ResultClaimGen = "committed", 5 |
| + next, err := ReplaceLossRecord(ctx, s, sg, read, committed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mutated := next.Record |
| + mutated.FencedAt = mutated.FencedAt.Add(time.Second) |
| + if _, err := ReplaceLossRecord(ctx, s, sg, next, mutated); !errors.Is(err, ErrLossRecord) { |
| + t.Fatalf("immutable evidence mutation = %v", err) |
| + } |
| + if _, err := ReplaceLossRecord(ctx, s, sg, read, committed); !errors.Is(err, ErrConflict) { |
| + t.Fatalf("stale conditional replace = %v", err) |
| + } |
| +} |
| + |
| +func TestTimerKnowledgeNeverConflatesUnknownWithEmptyComplete(t *testing.T) { |
| + r := validLossRecord(t) |
| + if err := r.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Timers.Knowledge = TimersComplete |
| + if err := r.Validate(); !errors.Is(err, ErrLossRecord) { |
| + t.Fatalf("complete without source = %v", err) |
| + } |
| + r.Timers = TimerLoss{} |
| + if err := r.Validate(); !errors.Is(err, ErrLossRecord) { |
| + t.Fatalf("empty knowledge = %v", err) |
| + } |
| +} |
| + |
| +func TestAdmissionLossRetentionRequiresExactDurableCopy(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + lost := WritesLost{Box: "target", Generation: "g-target", Session: 4, RemoteMaxTXID: 12} |
| + source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10} |
| + obs := servingClaim(t, ctx, s) |
| + obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| + c.LossRefs = []string{"loss_1"} |
| + c.Holder.Admissions = []AdmissionRecord{{Region: "ie", Session: 8, LossID: "loss_1", WritesLost: &lost, AdditionalWritesLost: []WritesLost{source}}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RequireLossEvidenceArchived(obs.Claim); !errors.Is(err, ErrLossEvidencePending) { |
| + t.Fatalf("unarchived evidence = %v", err) |
| + } |
| + record := validLossRecord(t) |
| + record.Host = obs.Host |
| + created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + exact := []ArchivedAdmissionLoss{{LossID: "loss_1", AdmissionClaimGen: 8, WritesLost: &lost, AdditionalWritesLost: []WritesLost{source}}} |
| + committed := created.Record |
| + committed.State, committed.ResultClaimGen, committed.ArchivedAdmissionLosses = "committed", obs.Claim.Gen, exact |
| + if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil { |
| + t.Fatal(err) |
| + } |
| + archived, err := ArchiveAdmissionLosses(ctx, s, pub, obs, "loss_1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RequireLossEvidenceArchived(archived.Claim); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestLossBearingAdmissionRequiresEveryFrozenTargetAndSourceCut(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs := servingClaim(t, ctx, s) |
| + obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| + c.Op = Op{Kind: KindReverting, ID: "return", Phase: "recover", Exec: "recovery", Participants: []string{"recovery"}} |
| + c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4 |
| + c.LossRefs = []string{"loss_1"} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + earlier := WritesLost{Box: "target", Generation: "g-earlier", Session: 3, RemoteMaxTXID: 7} |
| + source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10} |
| + in := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12, AdditionalWritesLost: []WritesLost{earlier, source}} |
| + if _, err := Update(ctx, s, obs, AdmitWriter(Admission{ObservedOpID: in.ObservedOpID, ObservedPhase: in.ObservedPhase, ObservedSession: in.ObservedSession, ObservedGeneration: in.ObservedGeneration, Box: in.Box, Mode: in.Mode, NewGeneration: in.NewGeneration, Region: in.Region, PredecessorRegion: in.PredecessorRegion, LossID: "loss_1", AdditionalWritesLost: in.AdditionalWritesLost})); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("direct loss admission = %v", err) |
| + } |
| + record := validLossRecord(t) |
| + record.Host = obs.Host |
| + record.HolderIncarnation, record.HolderStamp, record.HolderSession = obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp, 4 |
| + record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", "target" |
| + record.WriteSources = []WriteSource{ |
| + {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"}, |
| + {Reason: "target", Box: "target", Generation: "g-earlier", Session: 3, Region: "ie", Baseline: "earlier-snapshot"}, |
| + {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal"}, |
| + } |
| + created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + committed := created.Record |
| + committed.State, committed.ResultClaimGen = "committed", obs.Claim.Gen |
| + if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil { |
| + t.Fatal(err) |
| + } |
| + validated := []ValidatedWriteSource{ |
| + {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: in.RemoteMaxTXID}, |
| + {Reason: "target", Box: "target", Generation: "g-earlier", Session: 3, Region: "ie", Baseline: "earlier-snapshot", RemoteMaxTXID: earlier.RemoteMaxTXID}, |
| + {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal", RemoteMaxTXID: source.RemoteMaxTXID}, |
| + } |
| + for name, mutate := range map[string]func(*Admission, *[]ValidatedWriteSource){ |
| + "omitted earlier target": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = a.AdditionalWritesLost[1:] }, |
| + "omitted earlier target with same txid": func(a *Admission, v *[]ValidatedWriteSource) { |
| + (*v)[1].RemoteMaxTXID = a.RemoteMaxTXID |
| + a.AdditionalWritesLost = a.AdditionalWritesLost[1:] |
| + }, |
| + "omitted source cut": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = a.AdditionalWritesLost[:1] }, |
| + "omitted all additional": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost = nil }, |
| + "earlier target uses predecessor txid": func(a *Admission, _ *[]ValidatedWriteSource) { |
| + a.AdditionalWritesLost[0].RemoteMaxTXID = a.RemoteMaxTXID |
| + }, |
| + "source cut uses predecessor txid": func(a *Admission, _ *[]ValidatedWriteSource) { |
| + a.AdditionalWritesLost[1].RemoteMaxTXID = a.RemoteMaxTXID |
| + }, |
| + "predecessor uses earlier txid": func(a *Admission, _ *[]ValidatedWriteSource) { a.RemoteMaxTXID = earlier.RemoteMaxTXID }, |
| + "duplicate box session with another generation": func(a *Admission, _ *[]ValidatedWriteSource) { |
| + duplicate := earlier |
| + duplicate.Generation = "another-generation" |
| + a.AdditionalWritesLost = append(a.AdditionalWritesLost, duplicate) |
| + }, |
| + "unfrozen tuple": func(a *Admission, _ *[]ValidatedWriteSource) { a.AdditionalWritesLost[0].Session++ }, |
| + "omitted baseline": func(_ *Admission, v *[]ValidatedWriteSource) { *v = (*v)[:2] }, |
| + "changed baseline": func(_ *Admission, v *[]ValidatedWriteSource) { (*v)[1].Baseline = "another-snapshot" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + candidate := in |
| + candidate.AdditionalWritesLost = append([]WritesLost(nil), in.AdditionalWritesLost...) |
| + evidence := append([]ValidatedWriteSource(nil), validated...) |
| + mutate(&candidate, &evidence) |
| + if _, err := AdmitWriterWithLoss(ctx, s, pub, obs, candidate, "loss_1", evidence, testLossRecoveryOwner{}); !errors.Is(err, ErrLossRecord) { |
| + t.Fatalf("invalid declaration admitted: %v", err) |
| + } |
| + fresh, err := Read(ctx, s, obs.Host) |
| + if err != nil || fresh.ETag != obs.ETag { |
| + t.Fatalf("refusal changed claim: %v", err) |
| + } |
| + }) |
| + } |
| + admitted, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, "loss_1", validated, testLossRecoveryOwner{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + last := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1] |
| + if last.LossID != "loss_1" || !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{earlier, source}) || last.WritesLost == nil || last.WritesLost.RemoteMaxTXID != 12 || last.WritesLost.Session != 4 { |
| + t.Fatalf("loss admission = %+v", last) |
| + } |
| +} |
| + |
| +func TestTargetOnlyLossAdmissionRequiresColdUncoveredRecovery(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs := servingClaim(t, ctx, s) |
| + obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| + c.Op = Op{Kind: KindReverting, ID: "return", Phase: "recover", Exec: "recovery", Participants: []string{"recovery"}} |
| + c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4 |
| + c.LossRefs = []string{"loss_1"} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + record := validLossRecord(t) |
| + record.Host, record.HolderIncarnation = obs.Host, obs.Claim.Holder.Incarnation |
| + record.HolderStamp, record.HolderSession = obs.Claim.Holder.Stamp, 4 |
| + record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", "target" |
| + record.WriteSources = []WriteSource{{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"}} |
| + created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + committed := created.Record |
| + committed.State, committed.ResultClaimGen = "committed", obs.Claim.Gen |
| + if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil { |
| + t.Fatal(err) |
| + } |
| + base := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12} |
| + validated := []ValidatedWriteSource{{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: 12}} |
| + for name, mutate := range map[string]func(*Admission){ |
| + "warm": func(a *Admission) { a.Mode, a.NewGeneration = "warm", "" }, |
| + "certified": func(a *Admission) { a.PredecessorCertified = true }, |
| + "sealed": func(a *Admission) { a.Coverage = "sealed" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + in := base |
| + mutate(&in) |
| + if _, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, "loss_1", validated, testLossRecoveryOwner{}); !errors.Is(err, ErrLossRecord) { |
| + t.Fatalf("unsafe target-loss mode = %v", err) |
| + } |
| + }) |
| + } |
| + fresh, err := Read(ctx, s, obs.Host) |
| + if err != nil || fresh.Claim.Gen != obs.Claim.Gen { |
| + t.Fatalf("refusals mutated claim: gen %d, err %v", fresh.Claim.Gen, err) |
| + } |
| +} |
| + |
| +func TestLossDeclarationHandoverAdmission(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sg := release.RootSigner(priv) |
| + obs := servingClaim(t, ctx, s) |
| + obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| + c.Op = Op{Kind: KindLanding, ID: "move", Phase: "probe", Exec: "target", Participants: []string{"target", "recovery"}} |
| + c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4 |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + record := validLossRecord(t) |
| + record.Host, record.HolderIncarnation = obs.Host, obs.Claim.Holder.Incarnation |
| + record.HolderStamp, record.HolderSession = obs.Claim.Holder.Stamp, obs.Claim.Holder.Session |
| + record.ExpectedClaimGen, record.ExpectedClaimETag = obs.Claim.Gen, obs.ETag |
| + record.OpID, record.OpKind, record.OpPhase, record.Box = "move", KindLanding, "probe", "target" |
| + record.WriteSources = []WriteSource{ |
| + {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"}, |
| + {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal"}, |
| + } |
| + prepared, err := CreateLossRecord(ctx, s, sg, record) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + declared, err := DeclareLost(ctx, s, pub, obs, record.ID, testLossOwner{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if declared.Claim.Op.Kind != KindLanding || declared.Claim.Op.Phase != "probe" || declared.Claim.Op.Exec != "none" || |
| + declared.Claim.Holder.Stamp != record.HolderStamp || declared.Claim.Holder.Session != record.HolderSession { |
| + t.Fatalf("declaration changed recovery authority: %+v", declared.Claim) |
| + } |
| + committed := prepared.Record |
| + committed.State, committed.ResultClaimGen = "committed", declared.Claim.Gen |
| + if _, err := ReplaceLossRecord(ctx, s, sg, prepared, committed); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The operation owner hands execution to a new installation in the same |
| + // operation, preserving the original declaration and writer provenance. |
| + recoveryStamp := declared.Claim.Gen + 1 |
| + handover, err := Update(ctx, s, declared, func(c *Claim) error { |
| + if c.Op.Kind != KindLanding || c.Op.Phase != "probe" || c.Op.Exec != "none" || !lossRefPresent(c, record.ID) { |
| + return ErrWrongState |
| + } |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = KindRestoring, "restore", "recovery" |
| + c.Op.Journal["recovery.loss"] = record.ID |
| + c.Holder.Stamp = recoveryStamp |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10} |
| + in := Admission{ObservedOpID: "move", ObservedPhase: "restore", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12, AdditionalWritesLost: []WritesLost{source}} |
| + validated := []ValidatedWriteSource{ |
| + {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: 12}, |
| + {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal", RemoteMaxTXID: 10}, |
| + } |
| + owner := lossRecoveryOwnerFunc(func(c Claim, loss LossRecord, a Admission) error { |
| + if loss.OpKind != KindLanding || loss.OpPhase != "probe" || c.Op.Kind != KindRestoring || c.Op.Phase != "restore" || |
| + c.Op.Exec != "recovery" || a.Box != c.Op.Exec || c.Holder.Stamp != recoveryStamp || |
| + c.Op.Journal["recovery.loss"] != loss.ID || c.Op.Journal["loss."+loss.ID] != `{"kind":"lost-target"}` { |
| + return ErrWrongState |
| + } |
| + // A policy callback cannot rewrite any input used by the admission. |
| + c.Holder.Incarnation = "changed" |
| + c.Op.Journal["recovery.loss"] = "changed" |
| + loss.WriteSources[0].Baseline = "changed" |
| + a.AdditionalWritesLost[0].RemoteMaxTXID = 999 |
| + return nil |
| + }) |
| + if _, err := AdmitWriterWithLoss(ctx, s, pub, declared, in, record.ID, validated, owner); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("admitted before handover: %v", err) |
| + } |
| + if _, err := AdmitWriterWithLoss(ctx, s, pub, handover, in, record.ID, validated, nil); !errors.Is(err, ErrLossOwnerRequired) { |
| + t.Fatalf("admitted without owner: %v", err) |
| + } |
| + for name, mutate := range map[string]func(*Claim){ |
| + "kind": func(c *Claim) { c.Op.Kind = KindReverting }, |
| + "phase": func(c *Claim) { c.Op.Phase = "unauthorized" }, |
| + "executor": func(c *Claim) { c.Op.Exec = "other" }, |
| + "stamp": func(c *Claim) { c.Holder.Stamp++ }, |
| + "handover": func(c *Claim) { delete(c.Op.Journal, "recovery.loss") }, |
| + "intent": func(c *Claim) { delete(c.Op.Journal, "loss."+record.ID) }, |
| + "operation": func(c *Claim) { c.Op.ID = "other" }, |
| + "incarnation": func(c *Claim) { c.Holder.Incarnation = "other" }, |
| + "loss ref": func(c *Claim) { c.LossRefs = nil }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + candidate := handover |
| + candidate.Claim, err = cloneClaim(handover.Claim) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mutate(candidate.Claim) |
| + if _, err := AdmitWriterWithLoss(ctx, s, pub, candidate, in, record.ID, validated, owner); err == nil { |
| + t.Fatal("admitted with changed binding") |
| + } |
| + }) |
| + } |
| + stale := in |
| + stale.ObservedPhase = "probe" |
| + if _, err := AdmitWriterWithLoss(ctx, s, pub, handover, stale, record.ID, validated, owner); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("stale admission phase = %v", err) |
| + } |
| + fresh, err := Read(ctx, s, obs.Host) |
| + if err != nil || fresh.ETag != handover.ETag { |
| + t.Fatalf("refusal changed claim: %v", err) |
| + } |
| + admitted, err := AdmitWriterWithLoss(ctx, s, pub, handover, in, record.ID, validated, owner) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + last := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1] |
| + if last.LossID != record.ID || last.WritesLost == nil || *last.WritesLost != (WritesLost{Box: "target", Generation: "g-target", Session: 4, RemoteMaxTXID: 12}) || |
| + !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{source}) || admitted.Claim.Holder.WriterBox != "recovery" || |
| + admitted.Claim.Op.ID != record.OpID || admitted.Claim.Op.Kind != KindRestoring || admitted.Claim.Op.Phase != "restore" || |
| + admitted.Claim.Holder.Incarnation != record.HolderIncarnation || admitted.Claim.Op.Journal["recovery.loss"] != record.ID { |
| + t.Fatalf("recovery admission = %+v", admitted.Claim) |
| + } |
| + frozen, err := ReadLossRecord(ctx, s, pub, record.ID) |
| + if err != nil || !reflect.DeepEqual(frozen.Record, committed) { |
| + t.Fatalf("recovery rewrote frozen declaration: %+v, %v", frozen.Record, err) |
| + } |
| +} |
| + |
| +func TestLegacyLossWithoutDeclarationRetainsEvidence(t *testing.T) { |
| + a := AdmissionRecord{Region: "ie", Session: 3, WritesLost: &WritesLost{Box: "legacy", Session: 0}} |
| + if !validWriteLosses(a.WritesLost, nil) { |
| + t.Fatal("legacy zero-session loss no longer parses") |
| + } |
| + c := &Claim{Holder: &Holder{Admissions: []AdmissionRecord{a}}} |
| + if err := RequireLossEvidenceArchived(c); !errors.Is(err, ErrLossEvidencePending) { |
| + t.Fatalf("unarchived legacy loss discarded: %v", err) |
| + } |
| +} |
diff --git a/internal/hostclaim/lost.go b/internal/hostclaim/lost.go| new file mode 100644 |
| index 000000000..6b361a02a |
| --- /dev/null |
| +++ b/internal/hostclaim/lost.go |
| @@ -0,0 +1,488 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "reflect" |
| + "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 |
| + } |
| + for _, prior := range observed.Claim.Holder.Admissions { |
| + if prior.LossID == lossID { |
| + if prior.Box == in.Box && prior.Mode == in.Mode && prior.Generation == in.NewGeneration && reflect.DeepEqual(prior.AdditionalWritesLost, in.AdditionalWritesLost) { |
| + return observed, nil |
| + } |
| + return Observed{}, fmt.Errorf("%w: declaration id already used by another admission", ErrWrongState) |
| + } |
| + } |
| + 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] |
| + } |
| + 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 want[targetKey].Reason != "target" { |
| + 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 !validWriteLosses(&primary, in.AdditionalWritesLost) { |
| + return Observed{}, ErrLossRecord |
| + } |
| + declared := map[string]WritesLost{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 |
| + return Update(ctx, s, observed, AdmitWriter(in)) |
| +} |
| + |
| +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 |
| + } |
| + 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 { |
| + if 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 |
| + |
| + 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 |
| + if err := json.Unmarshal(raw, &value); err != nil { |
| + return "", err |
| + } |
| + 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 |
| + } |
| +} |
diff --git a/internal/hostclaim/lost_test.go b/internal/hostclaim/lost_test.go| new file mode 100644 |
| index 000000000..ad3a148b3 |
| --- /dev/null |
| +++ b/internal/hostclaim/lost_test.go |
| @@ -0,0 +1,407 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type testLossOwner struct { |
| + err error |
| + mutate bool |
| +} |
| + |
| +func (o testLossOwner) ValidateDeclareLost(c Claim, in LostDeclaration) error { |
| + if o.mutate { |
| + c.Holder.Stamp++ |
| + in.Consequences.RecoveryIntent[2] = 'X' |
| + } |
| + return o.err |
| +} |
| + |
| +func TestDeclareLostExactScopeAndReadmitRevision(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + obs := servingClaim(t, ctx, s) |
| + obs, err := Update(ctx, s, obs, func(c *Claim) error { |
| + c.Op = Op{Kind: KindMoving, ID: "op-move", Phase: "precommit", Exec: "box-a", Participants: []string{"box-a", "box-b"}} |
| + c.Obligations = []Obligation{ |
| + {ID: "local", Op: "discover", Kind: "park-local", Box: "box-a", Retirement: "ret-local", Owner: "box:box-a"}, |
| + {ID: "remote", Op: "op-move", Kind: "park-replica", Region: "ie", Retirement: "ret-remote", Owner: "box:box-a"}, |
| + } |
| + c.Touched.Complete, c.Touched.DiscoveryOp = false, "discover" |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stamp, session := obs.Claim.Holder.Stamp, obs.Claim.Holder.Session |
| + in := LostDeclaration{ |
| + LossID: "loss_1", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag, |
| + HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: stamp, HolderSession: session, |
| + OpID: "op-move", OpKind: KindMoving, OpPhase: "precommit", Box: "box-a", Enrollment: "enroll-a", |
| + QuarantineRevision: 7, FencedAt: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), |
| + Consequences: LossConsequences{ |
| + RevokeExecution: true, RemoveParticipation: true, |
| + DischargeLocal: []ObligationRef{{ID: "local", Op: "discover", Retirement: "ret-local"}}, |
| + ReassignRegional: []ObligationRef{{ID: "remote", Op: "op-move", Retirement: "ret-remote"}}, |
| + DestroyedStorage: []string{"discover/local/ret-local"}, |
| + RecoveryIntent: []byte(`{"kind":"lost-target","destination":"owner-decides"}`), |
| + }, |
| + } |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + record := validLossRecord(t) |
| + record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation |
| + record.HolderStamp, record.HolderSession = stamp, session |
| + record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag |
| + record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase |
| + record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt |
| + record.ConfirmedConsequences = in.Consequences |
| + record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...) |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{mutate: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Claim.Op.Exec != "none" || len(got.Claim.Op.Participants) != 1 || got.Claim.Op.Participants[0] != "box-b" { |
| + t.Fatalf("authority = exec %q participants %v", got.Claim.Op.Exec, got.Claim.Op.Participants) |
| + } |
| + if got.Claim.Holder.Stamp != stamp || got.Claim.Holder.Session != session { |
| + t.Fatalf("installation authority changed: stamp/session %d/%d", got.Claim.Holder.Stamp, got.Claim.Holder.Session) |
| + } |
| + if len(got.Claim.Obligations) != 1 || got.Claim.Obligations[0].ID != "remote" || got.Claim.Obligations[0].Owner != "region:ie" || |
| + got.Claim.Obligations[0].Retirement != "ret-remote" || got.Claim.Obligations[0].Op != "op-move" { |
| + t.Fatalf("obligations = %+v", got.Claim.Obligations) |
| + } |
| + if got.Claim.Touched.DiscoveryOp != "" || !got.Claim.Touched.Complete { |
| + t.Fatalf("discovery completion stranded: %+v", got.Claim.Touched) |
| + } |
| + if got.Claim.LostRevisions["box-a"] != 7 || !stringPresent(got.Claim.Lost, "box-a") || !lossRefPresent(got.Claim, "loss_1") { |
| + t.Fatalf("loss state = lost %v revisions %v refs %v", got.Claim.Lost, got.Claim.LostRevisions, got.Claim.LossRefs) |
| + } |
| + if got.Claim.Op.Journal["loss.loss_1"] != `{"destination":"owner-decides","kind":"lost-target"}` { |
| + t.Fatalf("recovery intent = %q", got.Claim.Op.Journal["loss.loss_1"]) |
| + } |
| + |
| + if retry, err := DeclareLost(ctx, s, pub, got, in.LossID, testLossOwner{}); err != nil || retry.ETag != got.ETag { |
| + t.Fatalf("lost-response retry = %+v, %v", retry, err) |
| + } |
| + if _, err := Update(ctx, s, got, Readmit("box-a", 6)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("old readmit revision = %v", err) |
| + } |
| + readmitted, err := Update(ctx, s, got, Readmit("box-a", 7)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if stringPresent(readmitted.Claim.Lost, "box-a") || readmitted.Claim.LostRevisions["box-a"] != 0 || !lossRefPresent(readmitted.Claim, "loss_1") { |
| + t.Fatalf("readmission state = lost %v revisions %v refs %v", readmitted.Claim.Lost, readmitted.Claim.LostRevisions, readmitted.Claim.LossRefs) |
| + } |
| +} |
| + |
| +func TestDeclareLostFailsClosedWithoutOperationOwnerOrExactDiskProof(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + obs := servingClaim(t, ctx, s) |
| + obs, err := Update(ctx, s, obs, func(c *Claim) error { |
| + c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}} |
| + c.Obligations = []Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box-a", Retirement: "ret", Owner: "box:box-a"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in := LostDeclaration{LossID: "loss", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag, |
| + HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: obs.Claim.Holder.Stamp, HolderSession: obs.Claim.Holder.Session, |
| + OpID: "delete", OpKind: KindDeleting, OpPhase: "parking", Box: "box-a", Enrollment: "e", QuarantineRevision: 1, FencedAt: time.Now(), |
| + Consequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, DischargeLocal: []ObligationRef{{ID: "local", Op: "delete", Retirement: "ret"}}, RecoveryIntent: []byte(`{"kind":"local-only"}`)}} |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + record := validLossRecord(t) |
| + record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation |
| + record.HolderStamp, record.HolderSession = in.HolderStamp, in.HolderSession |
| + record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag |
| + record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase |
| + record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt |
| + record.ConfirmedConsequences = in.Consequences |
| + record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...) |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, nil); !errors.Is(err, ErrLossOwnerRequired) { |
| + t.Fatalf("nil owner = %v", err) |
| + } |
| + if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{}); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("missing disk assertion = %v", err) |
| + } |
| + denied := errors.New("move record already committed") |
| + if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{err: denied}); !errors.Is(err, denied) { |
| + t.Fatalf("owner denial = %v", err) |
| + } |
| + fresh, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fresh.Claim.Gen != obs.Claim.Gen || len(fresh.Claim.Lost) != 0 || len(fresh.Claim.LossRefs) != 0 { |
| + t.Fatalf("refusal mutated claim: %+v", fresh.Claim) |
| + } |
| +} |
| + |
| +func lossForObservation(t *testing.T, obs Observed, id string, revision uint64, box string, effects LossConsequences) LossRecord { |
| + t.Helper() |
| + r := validLossRecord(t) |
| + r.ID, r.Host = id, obs.Host |
| + r.HolderIncarnation, r.HolderStamp, r.HolderSession = obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp, obs.Claim.Holder.Session |
| + r.ExpectedClaimGen, r.ExpectedClaimETag = obs.Claim.Gen, obs.ETag |
| + r.OpID, r.OpKind, r.OpPhase = obs.Claim.Op.ID, obs.Claim.Op.Kind, obs.Claim.Op.Phase |
| + r.Box, r.QuarantineRevision = box, revision |
| + r.ConfirmedConsequences = effects |
| + r.WriteSources = nil |
| + r.OperatorAttestation.DestroyedStorage = append([]string(nil), effects.DestroyedStorage...) |
| + return r |
| +} |
| + |
| +func TestDeclareLostNewerRevisionAppliesSecondConsequences(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs := servingClaim(t, ctx, s) |
| + obs, err = Update(ctx, s, obs, func(c *Claim) error { |
| + c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}} |
| + c.Obligations = []Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box-a", Retirement: "ret", Owner: "box:box-a"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first := lossForObservation(t, obs, "stopped", 9, "box-a", LossConsequences{ |
| + RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: []byte(`{"kind":"wait-for-storage"}`), |
| + }) |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), first); err != nil { |
| + t.Fatal(err) |
| + } |
| + stopped, err := DeclareLost(ctx, s, pub, obs, first.ID, testLossOwner{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(stopped.Claim.Obligations) != 1 { |
| + t.Fatal("compute stop discharged local storage") |
| + } |
| + second := lossForObservation(t, stopped, "destroyed", 10, "box-a", LossConsequences{ |
| + DischargeLocal: []ObligationRef{{ID: "local", Op: "delete", Retirement: "ret"}}, |
| + DestroyedStorage: []string{"delete/local/ret"}, RecoveryIntent: []byte(`{"kind":"destroyed-storage"}`), |
| + }) |
| + // A new revision does not excuse stale claim generation or ETag bindings. |
| + stale := second |
| + stale.ID, stale.ExpectedClaimGen, stale.ExpectedClaimETag = "stale", obs.Claim.Gen, obs.ETag |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), stale); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := DeclareLost(ctx, s, pub, stopped, stale.ID, testLossOwner{}); !errors.Is(err, ErrConflict) { |
| + t.Fatalf("stale second binding = %v", err) |
| + } |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), second); err != nil { |
| + t.Fatal(err) |
| + } |
| + destroyed, err := DeclareLost(ctx, s, pub, stopped, second.ID, testLossOwner{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(destroyed.Claim.Obligations) != 0 || destroyed.Claim.LostRevisions["box-a"] != 10 || |
| + !reflect.DeepEqual(destroyed.Claim.LossRefs, []string{"stopped", "destroyed"}) { |
| + t.Fatalf("second declaration not applied: %+v", destroyed.Claim) |
| + } |
| + for _, rev := range []uint64{8, 9, 10} { |
| + r := lossForObservation(t, destroyed, fmt.Sprintf("older_%d", rev), rev, "box-a", LossConsequences{RecoveryIntent: []byte(`{}`)}) |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := DeclareLost(ctx, s, pub, destroyed, r.ID, testLossOwner{}); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("revision %d accepted: %v", rev, err) |
| + } |
| + } |
| + if _, err := Update(ctx, s, destroyed, Readmit("box-a", 9)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("old readmission cleared newer quarantine: %v", err) |
| + } |
| + for _, id := range []string{first.ID, second.ID} { |
| + retry, err := DeclareLost(ctx, s, pub, destroyed, id, testLossOwner{}) |
| + if err != nil || retry.ETag != destroyed.ETag { |
| + t.Fatalf("replay %s = %v", id, err) |
| + } |
| + } |
| + unchanged, err := ReadLossRecord(ctx, s, pub, first.ID) |
| + if err != nil || !reflect.DeepEqual(unchanged.Record, first) { |
| + t.Fatal("first declaration was rewritten", err) |
| + } |
| + fresh, err := Read(ctx, s, obs.Host) |
| + if err != nil || fresh.ETag != destroyed.ETag { |
| + t.Fatal("refusal or replay mutated claim", err) |
| + } |
| + readmitted, err := Update(ctx, s, fresh, Readmit("box-a", 10)) |
| + if err != nil || len(readmitted.Claim.Lost) != 0 || len(readmitted.Claim.LossRefs) != 2 { |
| + t.Fatal("matching readmission", err) |
| + } |
| +} |
| + |
| +func TestServingDestroyedLocalOnlyPreservesAuthorityAndOtherChores(t *testing.T) { |
| + for _, otherChores := range []bool{false, true} { |
| + t.Run(fmt.Sprint(otherChores), func(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var regions []string |
| + boxes := []string{"box-a"} |
| + if otherChores { |
| + regions = []string{"uk"} |
| + boxes = append(boxes, "other-box") |
| + } |
| + obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", regions, boxes, "discovery", counter())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if obs.Claim.Op.Kind != KindServing || obs.Claim.Op.ID != "" { |
| + t.Fatal("fixture is not an adopted serving claim") |
| + } |
| + var ref ObligationRef |
| + for _, o := range obs.Claim.Obligations { |
| + if o.Kind == "park-local" && o.Box == "box-a" { |
| + ref = ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement} |
| + } |
| + } |
| + effects := LossConsequences{DischargeLocal: []ObligationRef{ref}, DestroyedStorage: []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement}, RecoveryIntent: []byte(`{"kind":"destroyed-local-only"}`)} |
| + r := lossForObservation(t, obs, "local_only", 11, "box-a", effects) |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{mutate: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want, err := cloneClaim(obs.Claim) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + remaining := make([]Obligation, 0, len(want.Obligations)-1) |
| + for _, o := range want.Obligations { |
| + if o.ID != ref.ID { |
| + remaining = append(remaining, o) |
| + } |
| + } |
| + want.Obligations = remaining |
| + if !otherChores { |
| + want.Touched.Complete, want.Touched.DiscoveryOp = true, "" |
| + } |
| + want.Gen = got.Claim.Gen |
| + want.Lost, want.LostRevisions, want.LossRefs = []string{"box-a"}, map[string]uint64{"box-a": 11}, []string{r.ID} |
| + // Compare serialized claims so nil/empty omitted arrays are equivalent. |
| + wantBytes, _ := json.Marshal(want) |
| + gotBytes, _ := json.Marshal(got.Claim) |
| + if !bytes.Equal(wantBytes, gotBytes) { |
| + t.Fatalf("local-only changed unrelated state:\nwant %s\ngot %s", wantBytes, gotBytes) |
| + } |
| + if retry, err := DeclareLost(ctx, s, pub, got, r.ID, testLossOwner{}); err != nil || retry.ETag != got.ETag { |
| + t.Fatal("local-only replay", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingDestroyedLocalOnlyRefusesWideningAndStaleBindings(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = Update(ctx, s, obs, Adopt(newHolder(), "serving-box", nil, []string{"box-a"}, "discovery", counter())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o := obs.Claim.Obligations[0] |
| + ref := ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement} |
| + 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"}`)}) |
| + for name, mutate := range map[string]func(*LossRecord){ |
| + "execution": func(r *LossRecord) { r.ConfirmedConsequences.RevokeExecution = true }, |
| + "participation": func(r *LossRecord) { r.ConfirmedConsequences.RemoveParticipation = true }, |
| + "regional": func(r *LossRecord) { r.ConfirmedConsequences.ReassignRegional = []ObligationRef{ref} }, |
| + "writes": func(r *LossRecord) { r.ConfirmedConsequences.WritesPotentiallyLost = true }, |
| + "timers": func(r *LossRecord) { r.ConfirmedConsequences.TimersPotentiallyLost = true }, |
| + "recovery": func(r *LossRecord) { |
| + r.ConfirmedConsequences.RecoveryIntent = []byte(`{"kind":"destroyed-local-only","destination":"other"}`) |
| + }, |
| + "empty": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal = nil }, |
| + "missing-proof": func(r *LossRecord) { |
| + r.ConfirmedConsequences.DestroyedStorage = nil |
| + r.OperatorAttestation.DestroyedStorage = nil |
| + }, |
| + "op": func(r *LossRecord) { r.OpID = "discovery" }, |
| + "phase": func(r *LossRecord) { r.OpPhase = "recover" }, |
| + "non-serving-empty-op": func(r *LossRecord) { r.OpKind = KindDeleting }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + r := base |
| + r.ID = name |
| + mutate(&r) |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); !errors.Is(err, ErrLossRecord) { |
| + t.Fatalf("widened serving declaration = %v", err) |
| + } |
| + }) |
| + } |
| + for name, mutate := range map[string]func(*LossRecord){ |
| + "claim-gen": func(r *LossRecord) { r.ExpectedClaimGen++ }, |
| + "claim-etag": func(r *LossRecord) { r.ExpectedClaimETag = "stale" }, |
| + "stamp": func(r *LossRecord) { r.HolderStamp++ }, |
| + "session": func(r *LossRecord) { r.HolderSession++ }, |
| + "box": func(r *LossRecord) { r.Box = "other-box" }, |
| + "original-op": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal[0].Op = "other-op" }, |
| + "retirement": func(r *LossRecord) { r.ConfirmedConsequences.DischargeLocal[0].Retirement = "other-retirement" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + r := base |
| + r.ID = name |
| + r.ConfirmedConsequences.DischargeLocal = append([]ObligationRef(nil), base.ConfirmedConsequences.DischargeLocal...) |
| + mutate(&r) |
| + ref := r.ConfirmedConsequences.DischargeLocal[0] |
| + r.ConfirmedConsequences.DestroyedStorage = []string{ref.Op + "/" + ref.ID + "/" + ref.Retirement} |
| + r.OperatorAttestation.DestroyedStorage = r.ConfirmedConsequences.DestroyedStorage |
| + if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := DeclareLost(ctx, s, pub, obs, r.ID, testLossOwner{}); err == nil { |
| + t.Fatal("stale or wrong scope was accepted") |
| + } |
| + }) |
| + } |
| + fresh, err := Read(ctx, s, obs.Host) |
| + if err != nil || fresh.ETag != obs.ETag { |
| + t.Fatal("refusal changed serving claim", err) |
| + } |
| +} |
diff --git a/internal/hostclaim/rev713_test.go b/internal/hostclaim/rev713_test.go| index b14aa245f..03f5263e6 100644 |
| --- a/internal/hostclaim/rev713_test.go |
| +++ b/internal/hostclaim/rev713_test.go |
| @@ -101,7 +101,8 @@ func TestPublishGenerationServingCyclesBoundAdmissions(t *testing.T) { |
| generation := fmt.Sprintf("G%d", cycle) |
| o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{ |
| Region: "ie", PredecessorRegion: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, |
| - Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: generation, |
| + // Exercise trimming without unarchived loss evidence. |
| + Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: generation, PredecessorCertified: true, |
| })) |
| o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, generation, "ie", newID)) |
| for _, record := range o.Claim.Holder.Admissions { |
diff --git a/internal/release/rootenvelope.go b/internal/release/rootenvelope.go| new file mode 100644 |
| index 000000000..fe5a2208e |
| --- /dev/null |
| +++ b/internal/release/rootenvelope.go |
| @@ -0,0 +1,91 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| +) |
| + |
| +// MaxRootEnvelopeBytes bounds both the signed wire object and its decoded |
| +// payload. Loss declarations can contain timer snapshots, so the worker |
| +// control record's smaller bound is not suitable here. |
| +const MaxRootEnvelopeBytes = 1 << 20 |
| + |
| +var ErrRootEnvelope = errors.New("release: root envelope refused") |
| + |
| +// RootPublicKey returns a copy of the verification anchor carried by a |
| +// signer. It lets create-only callers resolve an ambiguous retry by reading |
| +// and verifying the object they may already have created. |
| +func (s Signer) RootPublicKey() ed25519.PublicKey { |
| + return append(ed25519.PublicKey(nil), s.root...) |
| +} |
| + |
| +// SignRootEnvelope writes the existing root-signed {payload,sig} envelope. |
| +// Scoped release keys are deliberately refused: these records are deployment |
| +// control authority, not channel publication. |
| +func SignRootEnvelope(sg Signer, payload any) ([]byte, error) { |
| + if sg.usable() != nil || sg.scope != nil || sg.grant != nil { |
| + return nil, ErrRootEnvelope |
| + } |
| + raw, err := json.Marshal(payload) |
| + if err != nil || len(raw) == 0 || len(raw) > MaxRootEnvelopeBytes/2 { |
| + return nil, ErrRootEnvelope |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(raw) |
| + sig := ed25519.Sign(sg.key, []byte(b64)) |
| + wire, err := json.Marshal(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)}) |
| + if err != nil || len(wire) > MaxRootEnvelopeBytes { |
| + return nil, ErrRootEnvelope |
| + } |
| + return wire, nil |
| +} |
| + |
| +// VerifyRootEnvelope verifies an envelope against the deployment root and |
| +// requires canonical JSON for both wrapper and typed payload. dst must be a |
| +// non-nil pointer to the exact expected payload type. |
| +func VerifyRootEnvelope(pub ed25519.PublicKey, wire []byte, dst any) error { |
| + if len(pub) != ed25519.PublicKeySize || len(wire) == 0 || len(wire) > MaxRootEnvelopeBytes || dst == nil { |
| + return ErrRootEnvelope |
| + } |
| + var wrapper signedWrapper |
| + if strictCanonicalJSON(wire, &wrapper) != nil || wrapper.Grant != nil { |
| + return ErrRootEnvelope |
| + } |
| + sig, err := base64.StdEncoding.Strict().DecodeString(wrapper.Sig) |
| + if err != nil || base64.StdEncoding.EncodeToString(sig) != wrapper.Sig || len(sig) != ed25519.SignatureSize || |
| + !ed25519.Verify(pub, []byte(wrapper.Payload), sig) { |
| + return ErrRootEnvelope |
| + } |
| + raw, err := base64.StdEncoding.Strict().DecodeString(wrapper.Payload) |
| + if err != nil || base64.StdEncoding.EncodeToString(raw) != wrapper.Payload || len(raw) == 0 || len(raw) > MaxRootEnvelopeBytes/2 { |
| + return ErrRootEnvelope |
| + } |
| + if strictCanonicalJSON(raw, dst) != nil { |
| + return ErrRootEnvelope |
| + } |
| + return nil |
| +} |
| + |
| +func strictCanonicalJSON(raw []byte, dst any) error { |
| + dec := json.NewDecoder(bytes.NewReader(raw)) |
| + dec.DisallowUnknownFields() |
| + if err := dec.Decode(dst); err != nil { |
| + return err |
| + } |
| + var trailing any |
| + if err := dec.Decode(&trailing); err != io.EOF { |
| + return ErrRootEnvelope |
| + } |
| + canonical, err := json.Marshal(dst) |
| + if err != nil { |
| + return err |
| + } |
| + var compact bytes.Buffer |
| + if err := json.Compact(&compact, raw); err != nil || !bytes.Equal(compact.Bytes(), canonical) { |
| + return ErrRootEnvelope |
| + } |
| + return nil |
| +} |
diff --git a/internal/release/rootenvelope_test.go b/internal/release/rootenvelope_test.go| new file mode 100644 |
| index 000000000..c1017c970 |
| --- /dev/null |
| +++ b/internal/release/rootenvelope_test.go |
| @@ -0,0 +1,44 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestRootEnvelopeRoundTripAndRefusals(t *testing.T) { |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + type payload struct { |
| + V int `json:"v"` |
| + ID string `json:"id"` |
| + } |
| + wire, err := SignRootEnvelope(RootSigner(priv), payload{V: 1, ID: "loss-1"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got payload |
| + if err := VerifyRootEnvelope(pub, wire, &got); err != nil || got != (payload{V: 1, ID: "loss-1"}) { |
| + t.Fatalf("verify = %+v, %v", got, err) |
| + } |
| + |
| + var wrapper map[string]any |
| + if err := json.Unmarshal(wire, &wrapper); err != nil { |
| + t.Fatal(err) |
| + } |
| + wrapper["extra"] = true |
| + bad, _ := json.Marshal(wrapper) |
| + if err := VerifyRootEnvelope(pub, bad, &got); !errors.Is(err, ErrRootEnvelope) { |
| + t.Fatalf("unknown wrapper field = %v", err) |
| + } |
| + wire[len(wire)-2] ^= 1 |
| + if err := VerifyRootEnvelope(pub, wire, &got); !errors.Is(err, ErrRootEnvelope) { |
| + t.Fatalf("tampered envelope = %v", err) |
| + } |
| + if _, err := SignRootEnvelope(Signer{}, got); !errors.Is(err, ErrRootEnvelope) { |
| + t.Fatalf("zero signer = %v", err) |
| + } |
| +} |