carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 6cfc178f16dff8f490e566c7b87275335c763799 parent 9fa604f
14 files changed, +1995 −13
  • internal/hostclaim/claim.go +27 −4
  • internal/hostclaim/create.go +3 −0
  • internal/hostclaim/delete.go +3 −0
  • internal/hostclaim/execution_ack.go +56 −0
  • internal/hostclaim/execution_ack_test.go +43 −0
  • internal/hostclaim/generation.go +23 −2
  • internal/hostclaim/generation_test.go +2 −6
  • internal/hostclaim/lossrecord.go +372 −0
  • internal/hostclaim/lossrecord_test.go +434 −0
  • internal/hostclaim/lost.go +488 −0
  • internal/hostclaim/lost_test.go +407 −0
  • internal/hostclaim/rev713_test.go +2 −1
  • internal/release/rootenvelope.go +91 −0
  • internal/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)
+ }
+}