carlos / platform Public

hostclaim: the host lifecycle claim library (Plan 1, E)

control/hosts/<host>.json updated by CAS: create, complete, cancel,
delete, parking, purge chores, adoption, orphan rule, writer admissions
(warm/cold, born covered, writes_lost and additional_writes_lost),
PublishGeneration over every entry, CoverRegionPredecessors and
CoverParkedPredecessors. Inert: nothing calls it yet. Claim spec rev
6.22; joint round 14: E MERGE-READY, merged on Paul's decision
2026-10-06.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/hostclaim-library into main.
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 99084d7a3aecb6cad47641e19c4642ef6cbae9c8 parent 55e3c1d
17 files changed, +3292 −0
  • internal/hostclaim/adopt.go +29 −0
  • internal/hostclaim/adopt_test.go +107 −0
  • internal/hostclaim/claim.go +261 −0
  • internal/hostclaim/claim_test.go +150 −0
  • internal/hostclaim/create.go +126 −0
  • internal/hostclaim/create_test.go +278 −0
  • internal/hostclaim/delete.go +115 −0
  • internal/hostclaim/delete_test.go +203 −0
  • internal/hostclaim/generation.go +229 −0
  • internal/hostclaim/generation_test.go +960 −0
  • internal/hostclaim/ops.go +44 −0
  • internal/hostclaim/ops_test.go +53 −0
  • internal/hostclaim/orphan.go +16 −0
  • internal/hostclaim/orphan_test.go +23 −0
  • internal/hostclaim/rev713_test.go +472 −0
  • internal/hostclaim/store.go +117 −0
  • internal/hostclaim/store_test.go +109 −0

CI passed — run details

diff --git a/internal/hostclaim/adopt.go b/internal/hostclaim/adopt.go
new file mode 100644
index 000000000..d4492f7a6
--- /dev/null
+++ b/internal/hostclaim/adopt.go
@@ -0,0 +1,29 @@
+package hostclaim
+
+import "fmt"
+
+// Adopt is the one adoption routine (CC8) for a host that has never had a
+// claim. It writes discovery chores for every other region and box (CC4).
+func Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, opID string, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if c.Gen != 0 || c.Holder != nil {
+ return fmt.Errorf("%w: adoption is only for a host that never had a claim", ErrWrongState)
+ }
+ if nh.Host != c.Host || !keyMatches(nh) || nh.Region == "" || servingBox == "" || opID == "" {
+ return fmt.Errorf("%w: adoption inputs are not this host's", ErrWrongState)
+ }
+ inc, err := Incarnation(nh.Account, nh.App, nh.Host, nh.CreatedAt)
+ if err != nil {
+ return err
+ }
+ c.Holder = &Holder{Family: nh.Family, Key: nh.Key, Account: nh.Account, App: nh.App, Name: nh.Name,
+ Incarnation: inc, CreatedAt: nh.CreatedAt, Location: "instances/" + nh.Host, Stamp: c.Gen + 1, WriterBox: servingBox}
+ c.Op = Op{Kind: KindServing, Exec: servingBox, Participants: []string{servingBox}}
+ c.Touched = Touched{Complete: true, Regions: []string{nh.Region}, Boxes: []string{servingBox}}
+ appendParkObligations(c, opID, otherRegions, otherBoxes, newID)
+ if len(c.Obligations) > 0 {
+ c.Touched.Complete, c.Touched.DiscoveryOp = false, opID
+ }
+ return nil
+ }
+}
diff --git a/internal/hostclaim/adopt_test.go b/internal/hostclaim/adopt_test.go
new file mode 100644
index 000000000..1fb81cfda
--- /dev/null
+++ b/internal/hostclaim/adopt_test.go
@@ -0,0 +1,107 @@
+package hostclaim
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAdoptAndDiscoveryCompletion(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ nh := newHolder()
+ obs, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := Update(ctx, s, obs, Adopt(nh, "adi-knitting", []string{"uk"}, []string{"b2"}, "op_adopt", counter()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := o.Claim
+ if !reflect.DeepEqual(c.Touched.Regions, []string{"ie"}) || !reflect.DeepEqual(c.Touched.Boxes, []string{"adi-knitting"}) {
+ t.Fatalf("adopted touched locations = %+v", c.Touched)
+ }
+ if c.Holder.Location != "instances/"+testHost || c.Touched.Complete || c.Touched.DiscoveryOp != "op_adopt" ||
+ c.Holder.Stamp != c.Gen || c.Op.Exec != "adi-knitting" || len(c.Obligations) != 2 {
+ t.Fatalf("adopt = %+v", c)
+ }
+ // A delete is refused while discovery chores are pending in its write set (CC11).
+ if _, err := Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"})); !errors.Is(err, ErrPendingChore) {
+ t.Fatalf("delete during discovery = %v", err)
+ }
+ for _, ob := range c.Obligations {
+ if o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if !o.Claim.Touched.Complete || o.Claim.Touched.DiscoveryOp != "" {
+ t.Fatalf("discovery not completed: %+v", o.Claim.Touched)
+ }
+ if _, err := Update(ctx, s, o, Adopt(nh, "adi-knitting", nil, nil, "op_adopt2", counter())); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("re-adopt = %v", err)
+ }
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, ToParking("op_d", []string{"ie", "uk"}, []string{"adi-knitting", "b2"}, counter()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertParkingTargets(t, o.Claim.Obligations, []Obligation{
+ {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"},
+ {Op: "op_d", Kind: "park-local", Box: "adi-knitting", Owner: "box:adi-knitting"},
+ })
+}
+
+func TestAdoptRefusesEmptyEffectiveRegion(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ nh := newHolder()
+ nh.Region = ""
+ obs, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, obs, Adopt(nh, "B0", nil, nil, "op_adopt", counter())); !errors.Is(err, ErrWrongState) {
+ t.Errorf("empty effective region = %v, want ErrWrongState", err)
+ }
+ after, err := Read(ctx, s, testHost)
+ if err != nil || !after.Absent {
+ t.Fatalf("read after refused adopt = %+v, %v; want absent", after, err)
+ }
+}
+
+func TestAdoptWithoutDiscoveryTargets(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := Update(ctx, s, obs, Adopt(newHolder(), "B0", nil, nil, "op_adopt", counter()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"B0"}}
+ if !reflect.DeepEqual(o.Claim.Touched, want) || len(o.Claim.Obligations) != 0 {
+ t.Fatalf("adopt without discovery targets = %+v", o.Claim)
+ }
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, ToParking("op_d", nil, nil, counter()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertParkingTargets(t, o.Claim.Obligations, []Obligation{
+ {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"},
+ {Op: "op_d", Kind: "park-local", Box: "B0", Owner: "box:B0"},
+ })
+}
diff --git a/internal/hostclaim/claim.go b/internal/hostclaim/claim.go
new file mode 100644
index 000000000..025f1d58b
--- /dev/null
+++ b/internal/hostclaim/claim.go
@@ -0,0 +1,261 @@
+// Package hostclaim is the host lifecycle claim (spec
+// 2026-10-02-host-lifecycle-claim-design.md): one object per host that
+// names the incarnation entitled to it, the operation running on it, where
+// its data lives and what is still owed. This package only reads,
+// validates and conditionally writes that object. It never deletes it
+// (C5a).
+package hostclaim
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/maintenanceguard"
+)
+
+const (
+ KindServing = "serving"
+ KindCreating = "creating"
+ KindDeleting = "deleting"
+ KindParking = "parking"
+ KindPurging = "purging"
+ KindPlacing = "placing"
+ KindMoving = "moving" // move-any-app
+ KindLanding = "landing" // move-any-app
+ KindReverting = "reverting" // move-any-app
+ KindRestoring = "restoring" // move-any-app
+ KindPushing = "pushing" // later push spec
+
+ schemaVersion = 2
+ retiredCap = 32
+)
+
+var knownKinds = map[string]bool{KindServing: true, KindCreating: true, KindDeleting: true, KindParking: true,
+ KindPurging: true, KindPlacing: true, KindMoving: true, KindLanding: true, KindReverting: true,
+ KindRestoring: true, KindPushing: true}
+
+var (
+ ErrMalformed = errors.New("hostclaim: malformed claim")
+ ErrInvalidHost = errors.New("hostclaim: invalid host")
+)
+
+type Holder struct {
+ Family string `json:"family"` // "instance" | "sidecar"
+ Key string `json:"key"` // exact record key
+ Account string `json:"account"`
+ App string `json:"app"`
+ Name string `json:"name,omitempty"` // sidecars only
+ Incarnation string `json:"incarnation"`
+ CreatedAt time.Time `json:"created_at"`
+ Location string `json:"location"`
+ Generation string `json:"generation,omitempty"`
+ Stamp uint64 `json:"stamp,omitempty"`
+ Session uint64 `json:"session,omitempty"`
+ WriterBox string `json:"writer_box,omitempty"`
+ Admissions []AdmissionRecord `json:"admissions,omitempty"`
+}
+
+type AdmissionRecord struct {
+ Region string `json:"region"`
+ PredecessorRegion string `json:"predecessor_region,omitempty"`
+ Session uint64 `json:"session"`
+ Box string `json:"box"`
+ Generation string `json:"generation"`
+ Predecessor string `json:"predecessor,omitempty"`
+ Mode string `json:"mode"`
+ Coverage string `json:"coverage,omitempty"`
+ Covered bool `json:"covered"`
+ WritesLost *WritesLost `json:"writes_lost,omitempty"`
+ AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"`
+}
+
+type WritesLost struct {
+ Box string `json:"box"`
+ Generation string `json:"generation"`
+ Session uint64 `json:"session"`
+ RemoteMaxTXID uint64 `json:"remote_max_txid"`
+}
+
+type Op struct {
+ Kind string `json:"kind,omitempty"`
+ ID string `json:"id,omitempty"`
+ Phase string `json:"phase,omitempty"`
+ Intent map[string]string `json:"intent,omitempty"`
+ Journal map[string]string `json:"journal,omitempty"`
+ By string `json:"by,omitempty"`
+ StartedAt time.Time `json:"started_at,omitempty"`
+ Exec string `json:"exec,omitempty"`
+ Participants []string `json:"participants,omitempty"`
+}
+
+type Touched struct {
+ Complete bool `json:"complete"`
+ Regions []string `json:"regions,omitempty"`
+ Boxes []string `json:"boxes,omitempty"`
+ DiscoveryOp string `json:"discovery_op,omitempty"`
+}
+
+// InventoryEntry is one box's local paths for the host (§6.3), each tagged
+// with the installation stamp that produced it (§6.4).
+type InventoryEntry struct {
+ Layout string `json:"layout"` // "flat" | "nested"
+ Paths map[string]uint64 `json:"paths"` // path -> stamp
+}
+
+type Obligation struct {
+ ID string `json:"id"`
+ Op string `json:"op"`
+ Kind string `json:"kind"` // "park-replica" | "park-local" | "park-generation"
+ Region string `json:"region,omitempty"`
+ Generation string `json:"generation,omitempty"`
+ Box string `json:"box,omitempty"`
+ Retirement string `json:"retirement"`
+ Owner string `json:"owner"` // "region:<r>" | "box:<b>"
+}
+
+type CancelledCreate struct {
+ Key string `json:"key"`
+ Incarnation string `json:"incarnation"`
+ Until time.Time `json:"until"`
+}
+
+type Claim struct {
+ V int `json:"v"`
+ Host string `json:"host"`
+ Gen uint64 `json:"gen"`
+ Holder *Holder `json:"holder,omitempty"`
+ Op Op `json:"op"`
+ Touched Touched `json:"touched"`
+ Inventory map[string]InventoryEntry `json:"inventory,omitempty"`
+ Obligations []Obligation `json:"obligations,omitempty"`
+ Lost []string `json:"lost,omitempty"`
+ RetiredIncarnations []string `json:"retired_incarnations,omitempty"`
+ QuietAfter map[string]time.Time `json:"quiet_after,omitempty"`
+ CancelledCreates []CancelledCreate `json:"cancelled_creates,omitempty"`
+}
+
+// Free reports whether the host has no holder and no operation.
+func (c *Claim) Free() bool { return c.Holder == nil && c.Op.Kind == "" }
+
+func Key(host string) (string, error) {
+ if !controlname.ValidHostShape(host) {
+ return "", ErrInvalidHost
+ }
+ return "control/hosts/" + host + ".json", nil
+}
+
+// Incarnation is §2's identity. A record with a zero CreatedAt (written
+// before CreatedAt existed) gets the domain-separated legacy identity.
+func Incarnation(account, app, host string, created time.Time) (string, error) {
+ if created.IsZero() {
+ if !controlname.ValidAccount(account) || !controlname.ValidApp(app) || !controlname.ValidHostShape(host) {
+ return "", ErrMalformed
+ }
+ sum := sha256.Sum256([]byte("carlos-legacy-incarnation-v1\x00" + account + "\x00" + app + "\x00" + host))
+ return hex.EncodeToString(sum[:]), nil
+ }
+ return maintenanceguard.RecordIdentity(account, app, host, created)
+}
+
+// sidecarSuffix restates provision.SidecarSuffix (no import: avoids a cycle).
+const sidecarSuffix = ".sidecar.internal"
+
+// holderKeyMatches is the one identity check for a holder's record key and
+// host, shared by Validate, BeginCreate and Adopt. An instance's key is
+// exactly apps/<acct>/<app>/instances/<host>.json. A sidecar's key is
+// apps/<acct>/<app>/sidecars/<name>.json, and its host is exactly
+// <name>.<app>.<acct>.sidecar.internal (provision.SidecarHost).
+func holderKeyMatches(family, key, account, app, name, host string) bool {
+ base := "apps/" + account + "/" + app + "/"
+ switch family {
+ case "instance":
+ return name == "" && key == base+"instances/"+host+".json"
+ case "sidecar":
+ return controlname.ValidLabel(name) && key == base+"sidecars/"+name+".json" &&
+ host == name+"."+app+"."+account+sidecarSuffix
+ }
+ return false
+}
+
+func isHex64(s string) bool {
+ if len(s) != 64 {
+ return false
+ }
+ _, err := hex.DecodeString(s)
+ return err == nil
+}
+
+func (c *Claim) Validate() error {
+ if c == nil || c.V != schemaVersion || !controlname.ValidHostShape(c.Host) || c.Gen == 0 {
+ return fmt.Errorf("%w: header", ErrMalformed)
+ }
+ if c.Op.Kind != "" && !knownKinds[c.Op.Kind] {
+ return fmt.Errorf("%w: unknown op kind %q", ErrMalformed, c.Op.Kind)
+ }
+ if c.Op.Kind != "" && c.Op.Kind != KindServing && c.Op.ID == "" {
+ return fmt.Errorf("%w: op %q without id", ErrMalformed, c.Op.Kind)
+ }
+ if c.Holder == nil {
+ if c.Op.Kind != "" && c.Op.Kind != KindPurging { // a purge fence has no holder (CC21)
+ return fmt.Errorf("%w: op %q without holder", ErrMalformed, c.Op.Kind)
+ }
+ if len(c.Obligations) > 0 {
+ return fmt.Errorf("%w: a free claim with outstanding obligations", ErrMalformed)
+ }
+ } else {
+ h := c.Holder
+ if (h.Family != "instance" && h.Family != "sidecar") || h.Key == "" || !isHex64(h.Incarnation) ||
+ !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.App) {
+ return fmt.Errorf("%w: holder identity", ErrMalformed)
+ }
+ if !holderKeyMatches(h.Family, h.Key, h.Account, h.App, h.Name, c.Host) {
+ return fmt.Errorf("%w: holder key %q is not this host's", ErrMalformed, h.Key)
+ }
+ if want, err := Incarnation(h.Account, h.App, c.Host, h.CreatedAt); err != nil || want != h.Incarnation {
+ return fmt.Errorf("%w: incarnation does not match the holder's creation fields", ErrMalformed)
+ }
+ legacy, scoped := "instances/"+c.Host, "instances/"+c.Host+"/"+h.Incarnation
+ if h.Location != legacy && h.Location != scoped {
+ return fmt.Errorf("%w: holder location %q", ErrMalformed, h.Location)
+ }
+ if c.Op.Kind == "" {
+ 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 len(c.RetiredIncarnations) > retiredCap {
+ return fmt.Errorf("%w: retired_incarnations over cap", ErrMalformed)
+ }
+ seen := map[string]bool{}
+ for _, o := range c.Obligations {
+ if o.ID == "" || o.Op == "" || o.Retirement == "" || seen[o.ID] {
+ return fmt.Errorf("%w: obligation ids", ErrMalformed)
+ }
+ seen[o.ID] = true
+ switch o.Kind {
+ case "park-generation":
+ if o.Region == "" || o.Generation == "" || o.Box != "" || o.Owner != "region:"+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 {
+ return fmt.Errorf("%w: park-replica %q location/owner", ErrMalformed, o.ID)
+ }
+ case "park-local":
+ if o.Box == "" || o.Region != "" || o.Generation != "" || o.Owner != "box:"+o.Box {
+ return fmt.Errorf("%w: park-local %q location/owner", ErrMalformed, o.ID)
+ }
+ default:
+ return fmt.Errorf("%w: obligation kind %q", ErrMalformed, o.Kind)
+ }
+ }
+ return nil
+}
diff --git a/internal/hostclaim/claim_test.go b/internal/hostclaim/claim_test.go
new file mode 100644
index 000000000..c5e7168f5
--- /dev/null
+++ b/internal/hostclaim/claim_test.go
@@ -0,0 +1,150 @@
+package hostclaim
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const testHost = "shop.bab.oncarlos.com"
+
+func TestKeyRefusesBadHosts(t *testing.T) {
+ if k, err := Key(testHost); err != nil || k != "control/hosts/"+testHost+".json" {
+ t.Fatalf("Key = %q, %v", k, err)
+ }
+ for _, bad := range []string{"", "../x", "a/b", "UPPER.example.com"} {
+ if _, err := Key(bad); err == nil {
+ t.Errorf("Key(%q) accepted", bad)
+ }
+ }
+}
+
+func TestIncarnationStableAndLegacyDistinct(t *testing.T) {
+ at := time.Date(2026, 10, 4, 1, 2, 3, 4, time.UTC)
+ a1, err := Incarnation("bab", "jam", testHost, at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a2, _ := Incarnation("bab", "jam", testHost, at)
+ if a1 != a2 || len(a1) != 64 {
+ t.Fatalf("not stable 64-hex: %q %q", a1, a2)
+ }
+ l1, err := Incarnation("bab", "jam", testHost, time.Time{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ l2, _ := Incarnation("bab", "other", testHost, time.Time{})
+ if l1 == a1 || l1 == l2 || len(l1) != 64 {
+ t.Fatalf("legacy identity not distinct: %q %q %q", l1, l2, a1)
+ }
+}
+
+func TestValidate(t *testing.T) {
+ created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)
+ inc, _ := Incarnation("bab", "jam", testHost, created)
+ ok := &Claim{V: 2, Host: testHost, Gen: 1,
+ Holder: &Holder{Family: "instance", Key: "apps/bab/jam/instances/" + testHost + ".json", Account: "bab", App: "jam",
+ Incarnation: inc, CreatedAt: created, Location: "instances/" + testHost + "/" + inc},
+ Op: Op{Kind: KindServing}}
+ if err := ok.Validate(); err != nil {
+ t.Fatalf("valid claim: %v", err)
+ }
+ bad := map[string]func(c *Claim){
+ "holderless serving": func(c *Claim) { c.Holder = nil },
+ "unknown kind": func(c *Claim) { c.Op = Op{Kind: "teleporting", ID: "x"} },
+ "foreign location": func(c *Claim) { c.Holder.Location = "instances/other.example.com" },
+ "short incarnation": func(c *Claim) { c.Holder.Incarnation = "abc" },
+ "wrong incarnation": func(c *Claim) { c.Holder.Incarnation = strings.Repeat("a", 64) },
+ "foreign key": func(c *Claim) { c.Holder.Key = "apps/zzz/jam/instances/" + testHost + ".json" },
+ "free with chores": func(c *Claim) {
+ c.Holder, c.Op = nil, Op{}
+ c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-local", Box: "b"}}
+ },
+ "unknown chore kind": func(c *Claim) {
+ c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "teleport", Region: "ie", Retirement: "r", Owner: "region:ie"}}
+ },
+ "locationless chore": func(c *Claim) {
+ c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-replica", Retirement: "r", Owner: "region:"}}
+ },
+ "owner mismatch": func(c *Claim) {
+ c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-local", Box: "b", Retirement: "r", Owner: "box:other"}}
+ },
+ "duplicate chore id": func(c *Claim) {
+ ob := Obligation{ID: "o", Op: "x", Kind: "park-local", Box: "b", Retirement: "r", Owner: "box:b"}
+ c.Obligations = []Obligation{ob, ob}
+ },
+ "old schema": func(c *Claim) { c.V = 1 },
+ }
+ for name, mut := range bad {
+ raw, _ := json.Marshal(ok)
+ var c Claim
+ _ = json.Unmarshal(raw, &c)
+ mut(&c)
+ if err := c.Validate(); err == nil {
+ t.Errorf("%s validated", name)
+ }
+ }
+}
+
+func TestParkGenerationValidation(t *testing.T) {
+ for _, name := range []string{"valid", "no region", "no generation", "box", "wrong owner", "no owner", "park-replica", "park-local"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ before := servingClaim(t, ctx, s)
+ ob := Obligation{ID: "o1", Op: "m1", Kind: "park-generation", Region: "ie", Generation: "dead_gen", Retirement: "r1", Owner: "region:ie"}
+ switch name {
+ case "no region":
+ ob.Region, ob.Owner = "", "region:"
+ case "no generation":
+ ob.Generation = ""
+ case "box":
+ ob.Box = "boxS"
+ case "wrong owner":
+ ob.Owner = "region:uk"
+ case "no owner":
+ ob.Owner = ""
+ case "park-replica":
+ ob.Kind = name
+ case "park-local":
+ ob.Kind, ob.Region, ob.Box, ob.Owner = name, "", "boxS", "box:boxS"
+ }
+ next, err := Update(ctx, s, before, func(c *Claim) error { c.Obligations = []Obligation{ob}; return nil })
+ if name != "valid" {
+ if !errors.Is(err, ErrMalformed) {
+ t.Fatalf("invalid obligation = %v, want ErrMalformed", err)
+ }
+ assertAdmissionStored(t, ctx, s, before)
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ stored := assertAdmissionStored(t, ctx, s, next)
+ if !reflect.DeepEqual(stored.Claim.Obligations, []Obligation{ob}) {
+ t.Fatalf("stored obligations = %+v", stored.Claim.Obligations)
+ }
+ raw, err := json.Marshal(ob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), `"generation":"dead_gen"`) {
+ t.Fatalf("generation wire form = %s", raw)
+ }
+ ob.Generation = ""
+ raw, err = json.Marshal(ob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), `"generation"`) {
+ t.Fatalf("empty generation not omitted: %s", raw)
+ }
+ })
+ }
+}
diff --git a/internal/hostclaim/create.go b/internal/hostclaim/create.go
new file mode 100644
index 000000000..d42b76eca
--- /dev/null
+++ b/internal/hostclaim/create.go
@@ -0,0 +1,126 @@
+package hostclaim
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+var ErrRetiredIncarnation = errors.New("hostclaim: incarnation was retired on this host")
+
+type NewHolder struct {
+ Family, Key, Account, App, Name, Host string
+ RecordRegion string // Region encoded in the record; empty means home region.
+ Region string // Resolved storage region; must be non-empty.
+ CreatedAt time.Time
+}
+
+func keyMatches(nh NewHolder) bool {
+ return holderKeyMatches(nh.Family, nh.Key, nh.Account, nh.App, nh.Name, nh.Host)
+}
+
+// recordMatches checks the record body that create persists as its
+// reconstruction intent is this holder's record: valid JSON naming the
+// same host, account, app, sidecar name (empty for instances) and region,
+// and the same CreatedAt.
+func recordMatches(nh NewHolder, body []byte) bool {
+ var r struct {
+ Name string `json:"name"` // sidecar records only
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Region string `json:"region"`
+ CreatedAt time.Time `json:"created_at"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ return false
+ }
+ return r.Host == nh.Host && r.Account == nh.Account && r.App == nh.App && r.Name == nh.Name &&
+ r.Region == nh.RecordRegion && r.CreatedAt.Equal(nh.CreatedAt)
+}
+
+func retire(c *Claim, inc string) {
+ c.RetiredIncarnations = append([]string{inc}, c.RetiredIncarnations...)
+ if len(c.RetiredIncarnations) > retiredCap {
+ c.RetiredIncarnations = c.RetiredIncarnations[:retiredCap]
+ }
+}
+
+func BeginCreate(nh NewHolder, recordBody []byte, opID, by string, now time.Time) func(*Claim) error {
+ return func(c *Claim) error {
+ if nh.Host != c.Host || !keyMatches(nh) || nh.Region == "" {
+ return fmt.Errorf("%w: create inputs are not this host's", ErrWrongState)
+ }
+ if !c.Free() {
+ return fmt.Errorf("%w: host is held", ErrWrongState)
+ }
+ if nh.CreatedAt.IsZero() || opID == "" {
+ return fmt.Errorf("%w: create needs CreatedAt and an op id", ErrMalformed)
+ }
+ if !recordMatches(nh, recordBody) {
+ return fmt.Errorf("%w: the record body is not this holder's record", ErrWrongState)
+ }
+ inc, err := Incarnation(nh.Account, nh.App, nh.Host, nh.CreatedAt)
+ if err != nil {
+ return err
+ }
+ for _, r := range c.RetiredIncarnations {
+ if r == inc {
+ return ErrRetiredIncarnation
+ }
+ }
+ c.Holder = &Holder{Family: nh.Family, Key: nh.Key, Account: nh.Account, App: nh.App, Name: nh.Name,
+ Incarnation: inc, CreatedAt: nh.CreatedAt, Location: "instances/" + nh.Host + "/" + inc}
+ c.Op = Op{Kind: KindCreating, ID: opID, By: by, StartedAt: now.UTC(),
+ Intent: map[string]string{"key": nh.Key, "record": string(recordBody)}}
+ c.Touched = Touched{Complete: true, Regions: []string{nh.Region}}
+ return nil
+ }
+}
+
+func CompleteCreate(opID, execBox string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if c.Op.Kind != KindCreating || execBox == "" {
+ return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
+ }
+ if err := requireNoCreateAuthority(c); err != nil {
+ return err
+ }
+ c.Op = Op{Kind: KindServing, Exec: execBox, Participants: []string{execBox}}
+ c.Holder.Stamp = c.Gen + 1 // the resulting gen: Update sets gen = observed+1
+ if !set(c.Touched.Boxes)[execBox] { // the executor will hold local state (§2 touched)
+ c.Touched.Boxes = append(c.Touched.Boxes, execBox)
+ }
+ return nil
+ }
+}
+
+func CancelCreate(opID string, now time.Time) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if c.Op.Kind != KindCreating {
+ return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
+ }
+ if err := requireNoCreateAuthority(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)})
+ c.Holder, c.Op, c.Touched = nil, Op{}, Touched{}
+ return nil
+ }
+}
+
+func requireNoCreateAuthority(c *Claim) error {
+ if c.Op.Exec != "" || len(c.Op.Participants) > 0 || c.Holder.Stamp != 0 {
+ return fmt.Errorf("%w: create carries execution authority", ErrWrongState)
+ }
+ return nil
+}
diff --git a/internal/hostclaim/create_test.go b/internal/hostclaim/create_test.go
new file mode 100644
index 000000000..53968923e
--- /dev/null
+++ b/internal/hostclaim/create_test.go
@@ -0,0 +1,278 @@
+package hostclaim
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func newHolder() NewHolder {
+ return NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + testHost + ".json",
+ Account: "bab", App: "jam", Host: testHost, Region: "ie", RecordRegion: "ie",
+ CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)}
+}
+
+var record = []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`)
+
+func TestCreateLifecycle(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ reg, _ := Read(ctx, s, testHost)
+ o, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now))
+ if err != nil || o.Claim.Op.Kind != KindCreating || o.Claim.Op.Intent["record"] != string(record) {
+ t.Fatalf("begin = %+v, %v", o, err)
+ }
+ if o.Claim.Holder.Location != "instances/"+testHost+"/"+o.Claim.Holder.Incarnation {
+ t.Fatalf("location = %q", o.Claim.Holder.Location)
+ }
+ o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting"))
+ if err != nil || o.Claim.Op.Kind != KindServing || o.Claim.Op.Exec != "adi-knitting" || o.Claim.Holder.Stamp != o.Claim.Gen {
+ t.Fatalf("complete = %+v, %v", o.Claim, err)
+ }
+ if len(o.Claim.Touched.Boxes) != 1 || o.Claim.Touched.Boxes[0] != "adi-knitting" {
+ t.Fatalf("executor not recorded as touched: %+v", o.Claim.Touched)
+ }
+ if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("replay = %v, want ErrStaleOp", err)
+ }
+ if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c2", "console", now)); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale create = %v, want ErrConflict", err)
+ }
+}
+
+func TestBeginCreateRefusesAnotherHostOrKey(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ reg, _ := Read(ctx, s, testHost)
+ other := newHolder()
+ other.Host = "other.bab.oncarlos.com"
+ if _, err := Update(ctx, s, reg, BeginCreate(other, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("another host's create on this claim = %v", err)
+ }
+ badKey := newHolder()
+ badKey.Key = "apps/zzz/jam/instances/" + testHost + ".json"
+ if _, err := Update(ctx, s, reg, BeginCreate(badKey, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("mismatched record key = %v", err)
+ }
+ other2 := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T09:00:00Z"}`)
+ if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), other2, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("record body with another CreatedAt = %v", err)
+ }
+ if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), []byte("not json"), "op_c1", "console", now)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("record body not JSON = %v", err)
+ }
+ wrongRegion := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"uk","created_at":"2026-10-04T01:00:00Z"}`)
+ if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), wrongRegion, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("record body in another region = %v", err)
+ }
+ // A sidecar key cannot claim an instance-shaped host (P1, plan review round 2).
+ sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/shop.json", Account: "bab", App: "jam", Name: "shop",
+ Host: testHost, Region: "ie", RecordRegion: "ie", CreatedAt: newHolder().CreatedAt}
+ if _, err := Update(ctx, s, reg, BeginCreate(sc, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("sidecar key on a non-sidecar host = %v", err)
+ }
+}
+
+func TestBeginCreateSidecar(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ host := "bot.jam.bab.sidecar.internal"
+ sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/bot.json", Account: "bab", App: "jam", Name: "bot",
+ Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)}
+ body := []byte(`{"name":"bot","host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`)
+ reg, _ := Read(ctx, s, host)
+ if _, err := Update(ctx, s, reg, BeginCreate(sc, body, "op_s1", "console", now)); err != nil {
+ t.Fatalf("sidecar create = %v", err)
+ }
+ s2 := release.NewDirStore(t.TempDir())
+ reg2, _ := Read(ctx, s2, host)
+ wrongName := []byte(`{"name":"other","host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`)
+ if _, err := Update(ctx, s2, reg2, BeginCreate(sc, wrongName, "op_s1", "console", now)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("sidecar body with another name = %v", err)
+ }
+}
+
+func TestCancelRetiresAndRefusesReuse(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ reg, _ := Read(ctx, s, testHost)
+ o, _ := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now))
+ inc := o.Claim.Holder.Incarnation
+ o, err := Update(ctx, s, o, CancelCreate("op_c1", now))
+ if err != nil || !o.Claim.Free() || len(o.Claim.CancelledCreates) != 1 || o.Claim.RetiredIncarnations[0] != inc {
+ t.Fatalf("cancel = %+v, %v", o.Claim, err)
+ }
+ if _, err := Update(ctx, s, o, BeginCreate(newHolder(), record, "op_c3", "console", now)); !errors.Is(err, ErrRetiredIncarnation) {
+ t.Fatalf("reuse = %v, want ErrRetiredIncarnation", err)
+ }
+}
+
+func TestCancelCreateRefusesExecutionAuthority(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ exec string
+ participants []string
+ stamp uint64
+ }{
+ {name: "executor", exec: "adi-knitting"},
+ {name: "participants", participants: []string{"adi-knitting"}},
+ {name: "stamp", stamp: 1},
+ {name: "all", exec: "adi-knitting", participants: []string{"adi-knitting"}, stamp: 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ reg, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := Update(ctx, s, reg, func(c *Claim) error {
+ if err := BeginCreate(newHolder(), record, "op_c1", "console", now)(c); err != nil {
+ return err
+ }
+ c.Op.Exec = tc.exec
+ c.Op.Participants = tc.participants
+ c.Holder.Stamp = tc.stamp
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("store creating claim with authority: %v", err)
+ }
+ key, err := Key(testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ before, beforeETag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, o, CancelCreate("op_c1", now)); !errors.Is(err, ErrWrongState) {
+ t.Errorf("cancel with authority = %v, want ErrWrongState", err)
+ }
+ after, afterETag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(before, after) || beforeETag != afterETag {
+ t.Errorf("cancel changed stored claim: before ETag %q, after ETag %q", beforeETag, afterETag)
+ }
+ })
+ }
+}
+
+func TestCreateHomeRegionParksEffectiveRegion(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ nh := newHolder()
+ nh.RecordRegion = ""
+ body := bytes.Replace(record, []byte(`"region":"ie"`), []byte(`"region":""`), 1)
+ obs, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := Update(ctx, s, obs, BeginCreate(nh, body, "op_c", "console", time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(o.Claim.Touched.Regions) != 1 || o.Claim.Touched.Regions[0] != "ie" || o.Claim.Op.Intent["record"] != string(body) {
+ t.Fatalf("home-region create = %+v", o.Claim)
+ }
+ o, err = Update(ctx, s, o, CompleteCreate("op_c", "B0"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, ToParking("op_d", nil, nil, counter()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertParkingTargets(t, o.Claim.Obligations, []Obligation{
+ {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"},
+ {Op: "op_d", Kind: "park-local", Box: "B0", Owner: "box:B0"},
+ })
+}
+
+func TestBeginCreateRefusesEmptyEffectiveRegion(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ nh := newHolder()
+ nh.Region, nh.RecordRegion = "", ""
+ body := bytes.Replace(record, []byte(`"region":"ie"`), []byte(`"region":""`), 1)
+ obs, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, obs, BeginCreate(nh, body, "op_c", "console", time.Now())); !errors.Is(err, ErrWrongState) {
+ t.Errorf("empty effective region = %v, want ErrWrongState", err)
+ }
+ after, err := Read(ctx, s, testHost)
+ if err != nil || !after.Absent {
+ t.Fatalf("read after refused create = %+v, %v; want absent", after, err)
+ }
+}
+
+func TestCompleteCreateRefusesExecutionAuthority(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ exec string
+ participants []string
+ stamp uint64
+ }{
+ {name: "executor", exec: "adi-knitting"},
+ {name: "participants", participants: []string{"adi-knitting"}},
+ {name: "stamp", stamp: 1},
+ {name: "all", exec: "adi-knitting", participants: []string{"adi-knitting"}, stamp: 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ reg, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := Update(ctx, s, reg, func(c *Claim) error {
+ if err := BeginCreate(newHolder(), record, "op_c1", "console", now)(c); err != nil {
+ return err
+ }
+ c.Op.Exec = tc.exec
+ c.Op.Participants = tc.participants
+ c.Holder.Stamp = tc.stamp
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("store creating claim with authority: %v", err)
+ }
+ key, err := Key(testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ before, beforeETag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "new-executor")); !errors.Is(err, ErrWrongState) {
+ t.Errorf("complete with authority = %v, want ErrWrongState", err)
+ }
+ after, afterETag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(before, after) || beforeETag != afterETag {
+ t.Errorf("complete changed stored claim: before ETag %q, after ETag %q", beforeETag, afterETag)
+ }
+ })
+ }
+}
diff --git a/internal/hostclaim/delete.go b/internal/hostclaim/delete.go
new file mode 100644
index 000000000..22b1f996b
--- /dev/null
+++ b/internal/hostclaim/delete.go
@@ -0,0 +1,115 @@
+package hostclaim
+
+import (
+ "fmt"
+ "time"
+)
+
+// WriteSet is where a delete parks: the touched set, plus the whole
+// deployment while discovery is incomplete (§5.2 step 3, §9).
+func WriteSet(c *Claim, allRegions, allBoxes []string) (regions, boxes []string) {
+ if c.Touched.Complete {
+ return c.Touched.Regions, c.Touched.Boxes
+ }
+ return union(c.Touched.Regions, allRegions), union(c.Touched.Boxes, allBoxes)
+}
+
+func union(known, supplied []string) []string {
+ var result []string
+ seen := make(map[string]bool)
+ for _, locations := range [][]string{known, supplied} {
+ for _, location := range locations {
+ if !seen[location] {
+ result = append(result, location)
+ seen[location] = true
+ }
+ }
+ }
+ return result
+}
+
+func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string) func(*Claim) error {
+ return func(c *Claim) error {
+ regions, boxes := WriteSet(c, allRegions, allBoxes)
+ if err := StartGuard(c, regions, boxes); err != nil {
+ return err
+ }
+ if opID == "" || recordETag == "" {
+ return fmt.Errorf("%w: delete needs an op id and the record's etag", ErrMalformed)
+ }
+ exec, parts := c.Op.Exec, c.Op.Participants // execution authority is kept (R6-4)
+ c.Op = Op{Kind: KindDeleting, ID: opID, By: by, StartedAt: now.UTC(), Exec: exec, Participants: parts,
+ Intent: map[string]string{"key": c.Holder.Key, "etag": recordETag}}
+ return nil
+ }
+}
+
+func ToParking(opID string, allRegions, allBoxes []string, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ // purging: §5.3 step 4 retires records under the purge's own claim
+ // (R4-4); Plan 5 adds the purge journal's preconditions around it.
+ if c.Op.Kind != KindDeleting && c.Op.Kind != KindPurging {
+ return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
+ }
+ if c.Holder == nil { // a holderless purge fence (CC21) has nothing to park
+ return fmt.Errorf("%w: no holder to park", ErrWrongState)
+ }
+ regions, boxes := WriteSet(c, allRegions, allBoxes)
+ appendParkObligations(c, opID, regions, boxes, newID)
+ retire(c, c.Holder.Incarnation)
+ c.Op.Kind = KindParking
+ return nil
+ }
+}
+
+func appendParkObligations(c *Claim, opID string, regions, boxes []string, newID func() string) {
+ for _, r := range regions {
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID(), Owner: "region:" + r})
+ }
+ for _, b := range boxes {
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID(), Owner: "box:" + b})
+ }
+}
+
+// CompleteObligation removes one chore, matched by its OWN op id and id.
+// Discovery chores outlive the adoption that made them (§9).
+func CompleteObligation(obligationOp, obligationID string) func(*Claim) error {
+ return func(c *Claim) error {
+ idx := -1
+ for i, o := range c.Obligations {
+ if o.ID == obligationID && o.Op == obligationOp {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ return fmt.Errorf("%w: no obligation %q of op %q", ErrWrongState, obligationID, obligationOp)
+ }
+ c.Obligations = append(c.Obligations[:idx], c.Obligations[idx+1:]...)
+ if c.Touched.DiscoveryOp != "" && c.Touched.DiscoveryOp == obligationOp {
+ for _, o := range c.Obligations {
+ if o.Op == obligationOp {
+ return nil
+ }
+ }
+ c.Touched.Complete, c.Touched.DiscoveryOp = true, ""
+ }
+ return nil
+ }
+}
+
+func FreeAfterPark(opID string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ 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))
+ }
+ c.Holder, c.Op, c.Touched, c.Inventory = nil, Op{}, Touched{}, nil
+ return nil
+ }
+}
diff --git a/internal/hostclaim/delete_test.go b/internal/hostclaim/delete_test.go
new file mode 100644
index 000000000..a20467fe6
--- /dev/null
+++ b/internal/hostclaim/delete_test.go
@@ -0,0 +1,203 @@
+package hostclaim
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func counter() func() string {
+ n := 0
+ return func() string { n++; return fmt.Sprintf("id_%d", n) }
+}
+
+func servingClaim(t *testing.T, ctx context.Context, s release.Store) Observed {
+ t.Helper()
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ reg, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); err != nil {
+ t.Fatal(err)
+ }
+ return o // no fixture repair: CompleteCreate itself records adi-knitting in touched.boxes
+}
+
+func TestDeleteParkFree(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC)
+ o := servingClaim(t, ctx, s)
+ inc, stamp := o.Claim.Holder.Incarnation, o.Claim.Holder.Stamp
+ o, err := Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now, nil, nil))
+ if err != nil || o.Claim.Op.Kind != KindDeleting || o.Claim.Op.Intent["etag"] != `"etag1"` {
+ t.Fatalf("begin delete = %+v, %v", o.Claim, err)
+ }
+ if o.Claim.Op.Exec != "adi-knitting" || o.Claim.Holder.Stamp != stamp {
+ t.Fatalf("delete dropped execution authority: exec %q stamp %d", o.Claim.Op.Exec, o.Claim.Holder.Stamp)
+ }
+ o, err = Update(ctx, s, o, ToParking("op_d1", nil, nil, counter()))
+ if err != nil || o.Claim.Op.Kind != KindParking || len(o.Claim.Obligations) != 2 {
+ t.Fatalf("to parking = %+v, %v", o.Claim, err)
+ }
+ if _, err := Update(ctx, s, o, FreeAfterPark("op_d1")); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("free with chores left = %v", err)
+ }
+ for _, ob := range o.Claim.Obligations {
+ if o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := Update(ctx, s, o, CompleteObligation("op_d1", "id_1")); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("completing a chore twice = %v", err)
+ }
+ o, err = Update(ctx, s, o, FreeAfterPark("op_d1"))
+ if err != nil || !o.Claim.Free() || o.Claim.RetiredIncarnations[0] != inc {
+ t.Fatalf("free = %+v, %v", o.Claim, err)
+ }
+}
+
+func TestIncompleteTouchedParksEverywhere(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC)
+ o := servingClaim(t, ctx, s)
+ o, err := Update(ctx, s, o, func(c *Claim) error { c.Touched.Complete = false; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ regions, boxes := []string{"ie", "uk", "us"}, []string{"b1", "b2"}
+ o, err = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, ToParking("op_d1", regions, boxes, counter()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertParkingTargets(t, o.Claim.Obligations, []Obligation{
+ {Op: "op_d1", Kind: "park-replica", Region: "ie", Owner: "region:ie"},
+ {Op: "op_d1", Kind: "park-replica", Region: "uk", Owner: "region:uk"},
+ {Op: "op_d1", Kind: "park-replica", Region: "us", Owner: "region:us"},
+ {Op: "op_d1", Kind: "park-local", Box: "adi-knitting", Owner: "box:adi-knitting"},
+ {Op: "op_d1", Kind: "park-local", Box: "b1", Owner: "box:b1"},
+ {Op: "op_d1", Kind: "park-local", Box: "b2", Owner: "box:b2"},
+ })
+}
+
+func TestToParkingRefusesAPurgeFence(t *testing.T) {
+ fence := &Claim{V: 2, Host: testHost, Gen: 3, Op: Op{Kind: KindPurging, ID: "op_p"}}
+ if err := ToParking("op_p", nil, nil, counter())(fence); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("purge fence = %v, want ErrWrongState", err)
+ }
+}
+
+func TestRetiredCapKeepsNewest32(t *testing.T) {
+ c := &Claim{}
+ for i := 0; i < 33; i++ {
+ retire(c, fmt.Sprintf("inc%02d", i))
+ }
+ if len(c.RetiredIncarnations) != 32 || c.RetiredIncarnations[0] != "inc32" || c.RetiredIncarnations[31] != "inc01" {
+ t.Fatalf("retired = %v", c.RetiredIncarnations)
+ }
+}
+
+func TestAppendParkObligations(t *testing.T) {
+ existing := Obligation{ID: "existing", Op: "op_old", Kind: "park-local", Box: "b0", Retirement: "retired", Owner: "box:b0"}
+ c := &Claim{Obligations: []Obligation{existing}}
+ newID := counter()
+ appendParkObligations(c, "op_park", []string{"uk", "us"}, []string{"b1", "b2"}, newID)
+ want := []Obligation{
+ existing,
+ {ID: "id_1", Op: "op_park", Kind: "park-replica", Region: "uk", Retirement: "id_2", Owner: "region:uk"},
+ {ID: "id_3", Op: "op_park", Kind: "park-replica", Region: "us", Retirement: "id_4", Owner: "region:us"},
+ {ID: "id_5", Op: "op_park", Kind: "park-local", Box: "b1", Retirement: "id_6", Owner: "box:b1"},
+ {ID: "id_7", Op: "op_park", Kind: "park-local", Box: "b2", Retirement: "id_8", Owner: "box:b2"},
+ }
+ if !reflect.DeepEqual(c.Obligations, want) {
+ t.Fatalf("obligations = %+v, want %+v", c.Obligations, want)
+ }
+ appendParkObligations(c, "op_empty", nil, nil, newID)
+ if !reflect.DeepEqual(c.Obligations, want) {
+ t.Fatalf("empty append changed obligations: %+v", c.Obligations)
+ }
+ if got := newID(); got != "id_9" {
+ t.Fatalf("next id = %q, want id_9", got)
+ }
+}
+
+func assertParkingTargets(t *testing.T, obligations, want []Obligation) {
+ t.Helper()
+ got := append([]Obligation(nil), obligations...)
+ for i := range got {
+ got[i].ID, got[i].Retirement = "", ""
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("parking targets = %+v, want %+v", got, want)
+ }
+}
+
+func TestWriteSetIncompleteUnion(t *testing.T) {
+ c := &Claim{Touched: Touched{
+ Regions: []string{"ie", "ie", "uk"}, Boxes: []string{"B0", "B0", "B2"},
+ }}
+ regions, boxes := WriteSet(c, []string{"us", "uk", "us", "ie"}, []string{"B3", "B2", "B3", "B0"})
+ if !reflect.DeepEqual(regions, []string{"ie", "uk", "us"}) || !reflect.DeepEqual(boxes, []string{"B0", "B2", "B3"}) {
+ t.Fatalf("write set = %v, %v; want [ie uk us], [B0 B2 B3]", regions, boxes)
+ }
+}
+
+func TestIncompleteAdoptionPreservesParkingTargets(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ newID := counter()
+ o, err := Update(ctx, s, obs, Adopt(newHolder(), "B0", []string{"uk"}, []string{"B2"}, "op_adopt", newID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ regions := []string{"ie"}
+ o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), regions, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, ToParking("op_d", regions, nil, newID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertParkingTargets(t, o.Claim.Obligations, []Obligation{
+ {Op: "op_adopt", Kind: "park-replica", Region: "uk", Owner: "region:uk"},
+ {Op: "op_adopt", Kind: "park-local", Box: "B2", Owner: "box:B2"},
+ {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"},
+ {Op: "op_d", Kind: "park-local", Box: "B0", Owner: "box:B0"},
+ })
+ for _, ob := range o.Claim.Obligations {
+ if _, err := Update(ctx, s, o, FreeAfterPark("op_d")); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("free with %d chores remaining = %v", len(o.Claim.Obligations), err)
+ }
+ o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ o, err = Update(ctx, s, o, FreeAfterPark("op_d"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !o.Claim.Free() {
+ t.Fatalf("not free after all chores: %+v", o.Claim)
+ }
+}
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go
new file mode 100644
index 000000000..5491b62f0
--- /dev/null
+++ b/internal/hostclaim/generation.go
@@ -0,0 +1,229 @@
+package hostclaim
+
+import "fmt"
+
+const RootGeneration = "@root"
+
+type Admission struct {
+ ObservedOpID, ObservedPhase string // Empty values are compared literally.
+ ObservedSession uint64
+ ObservedGeneration string
+ Box string
+ Mode string
+ NewGeneration string
+ PredecessorCertified bool
+ RemoteMaxTXID uint64
+ KeepStamp bool
+ Coverage string
+ Region string
+ PredecessorRegion string
+ AdditionalWritesLost []WritesLost
+}
+
+// AdmitWriter is §6.4a's writer admission CAS within the current operation.
+// Warm admission keeps the installation; cold admission installs a fresh
+// generation and stamp. The caller checks the evidence required for warm mode
+// and supplies PredecessorCertified.
+func AdmitWriter(in Admission) func(*Claim) error {
+ return func(c *Claim) error {
+ if in.Region == "" {
+ return fmt.Errorf("%w: an admission names its region", ErrWrongState)
+ }
+ if c.Holder == nil || c.Op.Exec == "" || c.Op.Exec != in.Box {
+ return fmt.Errorf("%w: only the executor may admit a writer", ErrWrongState)
+ }
+ if c.Op.ID != in.ObservedOpID || c.Op.Phase != in.ObservedPhase {
+ return fmt.Errorf("%w: operation or phase moved since it was observed", ErrWrongState)
+ }
+ switch c.Op.Kind {
+ case KindCreating, KindDeleting, KindParking, KindPurging, KindPlacing:
+ return fmt.Errorf("%w: op kind %q cannot admit a writer", ErrWrongState, c.Op.Kind)
+ }
+ h := c.Holder
+ if h.Session != in.ObservedSession || h.Generation != in.ObservedGeneration {
+ return fmt.Errorf("%w: writer session or generation moved since it was observed", ErrWrongState)
+ }
+ if in.KeepStamp && (in.Mode != "cold" || h.Stamp <= h.Session) {
+ return fmt.Errorf("%w: keeping a stamp requires cold admission before its first writer", ErrWrongState)
+ }
+ switch in.Mode {
+ case "warm":
+ if !in.PredecessorCertified || in.NewGeneration != "" {
+ return fmt.Errorf("%w: a warm admission requires a certificate and keeps the generation", ErrWrongState)
+ }
+ case "cold":
+ if in.NewGeneration == "" || in.NewGeneration == h.Generation {
+ return fmt.Errorf("%w: a cold recovery needs a new generation", ErrWrongState)
+ }
+ default:
+ return fmt.Errorf("%w: unknown writer admission mode %q", ErrWrongState, in.Mode)
+ }
+
+ switch in.Coverage {
+ case "", "sealed", "clean-return":
+ default:
+ return ErrWrongState
+ }
+ 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]
+ }
+ if in.Mode == "warm" && in.Region != predecessor.Region {
+ return fmt.Errorf("%w: a warm admission stays in its predecessor's region", ErrWrongState)
+ }
+ type writerSession struct {
+ box string
+ session uint64
+ }
+ seen := make(map[writerSession]bool, len(in.AdditionalWritesLost)+1)
+ if in.Mode == "cold" && !in.PredecessorCertified && in.Coverage == "" {
+ seen[writerSession{predecessor.Box, predecessor.Session}] = true
+ }
+ for _, loss := range in.AdditionalWritesLost {
+ writer := writerSession{loss.Box, loss.Session}
+ if loss.Box == "" || loss.Session == 0 || loss.Generation == "" || seen[writer] {
+ return ErrWrongState
+ }
+ seen[writer] = true
+ }
+ entry := AdmissionRecord{
+ Session: c.Gen + 1, Box: in.Box, Generation: h.Generation,
+ Predecessor: predecessor.Generation, Mode: in.Mode, Coverage: in.Coverage,
+ Region: in.Region, PredecessorRegion: predecessor.Region,
+ Covered: in.Mode == "warm",
+ AdditionalWritesLost: append([]WritesLost(nil), in.AdditionalWritesLost...),
+ }
+ for _, ob := range c.Obligations {
+ if ob.Kind == "park-replica" && ob.Region == entry.PredecessorRegion {
+ entry.Covered = true
+ break
+ }
+ }
+ if in.Mode == "cold" {
+ entry.Generation = in.NewGeneration
+ if !in.KeepStamp {
+ h.Stamp = entry.Session
+ }
+ if !in.PredecessorCertified && in.Coverage == "" {
+ entry.WritesLost = &WritesLost{Box: predecessor.Box, Generation: predecessor.Generation, Session: predecessor.Session, RemoteMaxTXID: in.RemoteMaxTXID}
+ }
+ }
+ h.Admissions = append(h.Admissions, entry)
+ for len(h.Admissions) > 8 && h.Admissions[0].Covered {
+ h.Admissions = h.Admissions[1:]
+ }
+ h.Session, h.Generation, h.WriterBox = entry.Session, entry.Generation, in.Box
+ return nil
+ }
+}
+
+func PublishGeneration(observedOpID, observedPhase string, observedSession uint64, publishedGeneration, region string, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if c.Holder == nil || c.Op.ID != observedOpID || c.Op.Phase != observedPhase ||
+ c.Holder.Session != observedSession || c.Holder.Generation != publishedGeneration || region == "" {
+ return ErrWrongState
+ }
+ switch c.Op.Kind {
+ case KindCreating, KindDeleting, KindParking, KindPurging, KindPlacing:
+ return ErrWrongState
+ }
+ h := c.Holder
+ admitted := false
+ for _, record := range h.Admissions {
+ if record.Generation == publishedGeneration {
+ admitted = true
+ break
+ }
+ }
+ if !admitted {
+ return ErrWrongState
+ }
+ activeGeneration := publishedGeneration
+ if activeGeneration == "" {
+ activeGeneration = RootGeneration
+ }
+ op := c.Op.ID
+ if op == "" {
+ op = fmt.Sprintf("session:%d", h.Session)
+ }
+ for i := range h.Admissions {
+ record := &h.Admissions[i]
+ if record.Covered {
+ continue
+ }
+ predecessor := record.Predecessor
+ if predecessor == "" {
+ predecessor = RootGeneration
+ }
+ if predecessor == activeGeneration {
+ continue
+ }
+ if record.PredecessorRegion == "" {
+ record.Covered = true
+ continue
+ }
+ if record.PredecessorRegion != region {
+ continue
+ }
+ queued := false
+ for _, ob := range c.Obligations {
+ if ob.Kind == "park-generation" && ob.Region == region && ob.Generation == predecessor {
+ queued = true
+ break
+ }
+ }
+ if !queued {
+ c.Obligations = append(c.Obligations, Obligation{
+ ID: newID(), Op: op, Kind: "park-generation", Region: region,
+ Generation: predecessor, Retirement: newID(), Owner: "region:" + region,
+ })
+ }
+ record.Covered = true
+ }
+ return nil
+ }
+}
+
+// CoverRegionPredecessors covers predecessors retired by a region's park of
+// the whole location. Callers compose it into the CAS that queues that park
+// (a committed move's source cleanup). It queues no chores itself.
+func CoverRegionPredecessors(observedOpID, observedPhase string, region string) func(*Claim) error {
+ return func(c *Claim) error {
+ if c.Holder == nil || c.Op.ID != observedOpID || c.Op.Phase != observedPhase || region == "" {
+ return ErrWrongState
+ }
+ for i := range c.Holder.Admissions {
+ record := &c.Holder.Admissions[i]
+ if !record.Covered && record.PredecessorRegion == region {
+ record.Covered = true
+ }
+ }
+ return nil
+ }
+}
+
+// CoverParkedPredecessors covers only the predecessors retired by a sweep.
+// Callers compose it into the CAS that queues the sweep and the CAS that
+// acknowledges it. It queues no chores itself.
+func CoverParkedPredecessors(observedOpID, observedPhase string, region string, generations []string) func(*Claim) error {
+ parked := make(map[string]bool, len(generations))
+ for _, generation := range generations {
+ parked[generation] = true
+ }
+ return func(c *Claim) error {
+ if c.Holder == nil || c.Op.ID != observedOpID || c.Op.Phase != observedPhase || region == "" {
+ return ErrWrongState
+ }
+ for i := range c.Holder.Admissions {
+ record := &c.Holder.Admissions[i]
+ predecessor := record.Predecessor
+ if predecessor == "" {
+ predecessor = RootGeneration
+ }
+ if !record.Covered && record.PredecessorRegion == region && parked[predecessor] {
+ record.Covered = true
+ }
+ }
+ return nil
+ }
+}
diff --git a/internal/hostclaim/generation_test.go b/internal/hostclaim/generation_test.go
new file mode 100644
index 000000000..ae486d4a7
--- /dev/null
+++ b/internal/hostclaim/generation_test.go
@@ -0,0 +1,960 @@
+package hostclaim
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAdmitWriterWarmColdAdmissions(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ stamp := o.Claim.Holder.Stamp
+ if o.Claim.Holder.Session != 0 || o.Claim.Holder.WriterBox != "" {
+ t.Fatalf("legacy session = %d, want zero", o.Claim.Holder.Session)
+ }
+ raw, err := json.Marshal(o.Claim.Holder)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"session", "writer_box", "admissions"} {
+ if _, ok := fields[name]; ok {
+ t.Fatalf("zero %s must be omitted", name)
+ }
+ }
+ o, err = Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a", PredecessorCertified: true}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ cold := o.Claim.Gen
+ if o.Claim.Holder.WriterBox != "adi-knitting" || o.Claim.Holder.Generation != "gen_a" || o.Claim.Holder.Stamp != cold || o.Claim.Holder.Session != cold || cold <= stamp {
+ t.Fatalf("cold holder = %+v, gen = %d", o.Claim.Holder, cold)
+ }
+ first := AdmissionRecord{Region: "ie", PredecessorRegion: "ie", Session: cold, Box: "adi-knitting", Generation: "gen_a", Mode: "cold"}
+ assertAdmissions(t, o.Claim, []AdmissionRecord{first})
+ o, err = Update(ctx, s, o, func(c *Claim) error {
+ c.Op.Exec = "boxT"
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: cold, ObservedGeneration: "gen_a", Box: "boxT", Mode: "warm", PredecessorCertified: true}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ warm := o.Claim.Gen
+ if o.Claim.Holder.WriterBox != "boxT" || o.Claim.Holder.Session != warm || warm <= cold || o.Claim.Holder.Generation != "gen_a" || o.Claim.Holder.Stamp != cold {
+ t.Fatalf("warm holder = %+v, gen = %d", o.Claim.Holder, warm)
+ }
+ second := AdmissionRecord{Region: "ie", PredecessorRegion: "ie", Session: warm, Box: "boxT", Generation: "gen_a", Mode: "warm", Predecessor: "gen_a", Covered: true}
+ assertAdmissions(t, o.Claim, []AdmissionRecord{first, second})
+ o, err = Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: warm, ObservedGeneration: "gen_a", Box: "boxT", Mode: "cold", NewGeneration: "gen_b", PredecessorCertified: true}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Claim.Holder.Generation != "gen_b" || o.Claim.Holder.Stamp != o.Claim.Gen || o.Claim.Holder.Session != o.Claim.Gen {
+ t.Fatalf("second cold holder = %+v, gen = %d", o.Claim.Holder, o.Claim.Gen)
+ }
+ third := AdmissionRecord{Region: "ie", PredecessorRegion: "ie", Session: o.Claim.Gen, Box: "boxT", Generation: "gen_b", Mode: "cold", Predecessor: "gen_a"}
+ assertAdmissions(t, o.Claim, []AdmissionRecord{first, second, third})
+ stored, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(stored, o) {
+ t.Fatalf("stored admission = %+v, want %+v", stored, o)
+ }
+}
+
+func assertAdmissions(t *testing.T, c *Claim, want []AdmissionRecord) {
+ t.Helper()
+ if !reflect.DeepEqual(c.Holder.Admissions, want) {
+ t.Fatalf("admissions = %+v, want %+v", c.Holder.Admissions, want)
+ }
+ if _, ok := c.Op.Journal["writers"]; ok {
+ t.Fatal("admission wrote an operation writers journal")
+ }
+}
+
+func TestAdmitWriterPreservesOperation(t *testing.T) {
+ for _, kind := range []string{KindServing, KindMoving, KindLanding, KindReverting, KindRestoring, KindPushing} {
+ for _, mode := range []string{"warm", "cold"} {
+ t.Run(kind+"/"+mode, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o, err := Update(ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: kind, ID: "m1", Phase: "probe", Exec: "boxT", Participants: []string{"boxS", "boxT"},
+ Intent: map[string]string{"target": "boxT"}, Journal: map[string]string{"source": "unchanged"},
+ By: "operator", StartedAt: c.Holder.CreatedAt}
+ c.Obligations = []Obligation{{ID: "ob1", Op: "m1", Kind: "park-local", Box: "boxS", Owner: "box:boxS", Retirement: "rt1"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ generation := ""
+ if mode == "cold" {
+ generation = "gen_a"
+ }
+ next, err := Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedOpID: "m1", ObservedPhase: "probe", Box: "boxT", Mode: mode, NewGeneration: generation, PredecessorCertified: true}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := *o.Claim
+ holder := *want.Holder
+ want.Holder = &holder
+ want.Gen++
+ want.Holder.Session = want.Gen
+ want.Holder.WriterBox = "boxT"
+ if mode == "cold" {
+ want.Holder.Generation = generation
+ want.Holder.Stamp = want.Gen
+ }
+ want.Holder.Admissions = []AdmissionRecord{{Region: "ie", PredecessorRegion: "ie", Session: want.Gen, Box: "boxT", Generation: generation, Mode: mode, Covered: mode == "warm"}}
+ if !reflect.DeepEqual(next.Claim, &want) {
+ t.Fatalf("admission changed fields outside holder writer fields and admissions: got %+v, want %+v", next.Claim, &want)
+ }
+ })
+ }
+ }
+}
+
+func TestAdmitWriterRefusals(t *testing.T) {
+ for _, name := range []string{"no holder", "no executor", "non-executor", "stale session", "stale generation", "warm with generation", "warm with current generation", "cold without generation", "cold same generation", "unknown mode", "empty mode", "unknown coverage", "warm without certificate", "stale op", "stale phase", "creating", "deleting", "parking", "purging", "placing"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o, err := Update(ctx, s, o, func(c *Claim) error {
+ c.Holder.Session = c.Gen + 1
+ c.Holder.Generation = "gen_a"
+ switch name {
+ case "no holder":
+ c.Holder = nil
+ c.Op = Op{}
+ case "no executor":
+ c.Op.Exec = ""
+ case "creating", "deleting", "parking", "purging", "placing":
+ c.Op.Kind, c.Op.ID = name, "op1"
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, generation, box, mode, newGeneration := o.Claim.Gen, "gen_a", "adi-knitting", "cold", "gen_b"
+ switch name {
+ case "no executor":
+ box = ""
+ case "non-executor":
+ box = "boxT"
+ case "stale session":
+ session--
+ case "stale generation":
+ generation = "old"
+ case "warm with generation":
+ mode = "warm"
+ case "warm with current generation":
+ mode, newGeneration = "warm", generation
+ case "cold without generation":
+ newGeneration = ""
+ case "cold same generation":
+ newGeneration = generation
+ case "unknown mode":
+ mode = "hot"
+ case "empty mode":
+ mode = ""
+ }
+ in := Admission{Region: "ie", PredecessorRegion: "ie", ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, ObservedSession: session, ObservedGeneration: generation, Box: box, Mode: mode, NewGeneration: newGeneration, PredecessorCertified: true}
+ switch name {
+ case "warm without certificate":
+ in.Mode, in.NewGeneration, in.PredecessorCertified = "warm", "", false
+ case "unknown coverage":
+ in.Coverage = "remote"
+ case "stale op":
+ in.ObservedOpID = "old"
+ case "stale phase":
+ in.ObservedPhase = "old"
+ }
+ if _, err := Update(ctx, s, o, AdmitWriter(in)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("admit writer = %v, want ErrWrongState", err)
+ }
+ stored, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(stored, o) {
+ t.Fatalf("refused admission changed stored claim or ETag: got %+v, want %+v", stored, o)
+ }
+ })
+ }
+}
+
+func TestAdmitWriterCompetingAdmissions(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ before := servingClaim(t, ctx, s)
+ in := Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a"}
+ first := AdmitWriter(in)
+ in.NewGeneration = "gen_b"
+ second := AdmitWriter(in)
+ admitted, err := Update(ctx, s, before, first)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, before, second); !errors.Is(err, ErrConflict) {
+ t.Fatalf("competing admission = %v, want ErrConflict", err)
+ }
+ current := assertAdmissionStored(t, ctx, s, admitted)
+ if _, err := Update(ctx, s, current, second); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("stale evidence after reread = %v, want ErrWrongState", err)
+ }
+ assertAdmissionStored(t, ctx, s, admitted)
+}
+
+func TestAdmitWriterLostResponse(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ before := servingClaim(t, ctx, s)
+ admit := AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a"})
+ admitted, err := Update(ctx, s, before, admit)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, before, admit); !errors.Is(err, ErrConflict) {
+ t.Fatalf("retry after lost response = %v, want ErrConflict", err)
+ }
+ assertAdmissionStored(t, ctx, s, admitted)
+}
+
+func TestAdmitWriterBoundToOperationAndPhase(t *testing.T) {
+ for _, change := range []string{"operation", "phase", "delete"} {
+ t.Run(change, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ stale := AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a"})
+ var err error
+ if change == "delete" {
+ o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil))
+ } else {
+ o, err = Update(ctx, s, o, func(c *Claim) error {
+ if change == "operation" {
+ c.Op.Kind, c.Op.ID = KindMoving, "m1"
+ } else {
+ c.Op.Phase = "probe"
+ }
+ return nil
+ })
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, o, stale); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("admission after %s changed = %v, want ErrWrongState", change, err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ if change == "delete" {
+ fresh := AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedOpID: "d1", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a"})
+ if _, err := Update(ctx, s, o, fresh); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("deleting admission = %v", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ id := 0
+ o, err = Update(ctx, s, o, ToParking("d1", nil, nil, func() string { id++; return fmt.Sprintf("o%d", id) }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update(ctx, s, o, fresh); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("parking admission = %v", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ }
+ })
+ }
+}
+
+func TestAdmitWriterSurvivesOperationReplacement(t *testing.T) {
+ for _, transition := range []string{"delete", "move"} {
+ t.Run(transition, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o, err := Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ writer := *o.Claim.Holder
+ if writer.WriterBox != "adi-knitting" || writer.Session != o.Claim.Gen {
+ t.Fatalf("admitted holder = %+v", writer)
+ }
+ if transition == "delete" {
+ o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil))
+ } else {
+ o, err = Update(ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindMoving, ID: "m1", Phase: "probe", Exec: "boxT"}
+ return nil
+ })
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ o = assertAdmissionStored(t, ctx, s, o)
+ if !reflect.DeepEqual(*o.Claim.Holder, writer) {
+ t.Fatalf("%s changed writer: %+v", transition, o.Claim.Holder)
+ }
+ if transition == "delete" {
+ return
+ }
+ o, err = Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedOpID: "m1", ObservedPhase: "probe", ObservedSession: writer.Session, ObservedGeneration: writer.Generation, Box: "boxT", Mode: "cold", NewGeneration: "gen_b", RemoteMaxTXID: 42}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := append(append([]AdmissionRecord(nil), writer.Admissions...), AdmissionRecord{Region: "ie", PredecessorRegion: "ie",
+ Session: o.Claim.Gen, Box: "boxT", Generation: "gen_b", Mode: "cold", Predecessor: "gen_a",
+ WritesLost: &WritesLost{Box: "adi-knitting", Generation: "gen_a", Session: writer.Session, RemoteMaxTXID: 42},
+ })
+ assertAdmissions(t, o.Claim, want)
+ if o.Claim.Holder.WriterBox != "boxT" {
+ t.Fatalf("new writer = %q", o.Claim.Holder.WriterBox)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ })
+ }
+}
+
+func assertAdmissionStored(t *testing.T, ctx context.Context, s release.Store, want Observed) Observed {
+ t.Helper()
+ got, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("stored claim or ETag changed: got %+v, want %+v", got, want)
+ }
+ return got
+}
+
+func TestAdmitWriterKeepStamp(t *testing.T) {
+ for _, name := range []string{"landing", "equal session", "later session", "warm"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o, err := Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a", PredecessorCertified: true}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if name == "landing" || name == "warm" {
+ o, err = Update(ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindLanding, ID: "m1", Phase: "probe", Exec: "boxT"}
+ c.Holder.Stamp = c.Gen + 1
+ return nil
+ })
+ } else if name == "later session" {
+ o, err = Update(ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "gen_a", Box: "adi-knitting", Mode: "warm", PredecessorCertified: true}))
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ before := *o.Claim.Holder
+ in := Admission{Region: "ie", PredecessorRegion: "ie", ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, ObservedSession: before.Session, ObservedGeneration: before.Generation, Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "gen_b", KeepStamp: true, PredecessorCertified: true}
+ if name == "warm" {
+ in.Mode, in.NewGeneration = "warm", ""
+ }
+ next, err := Update(ctx, s, o, AdmitWriter(in))
+ if name != "landing" {
+ if !errors.Is(err, ErrWrongState) {
+ t.Fatalf("KeepStamp admission = %v, want ErrWrongState", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if next.Claim.Holder.Stamp != before.Stamp || next.Claim.Holder.Session != next.Claim.Gen || next.Claim.Holder.Generation != "gen_b" {
+ t.Fatalf("landing admission holder = %+v, prior stamp = %d", next.Claim.Holder, before.Stamp)
+ }
+ want := append(append([]AdmissionRecord(nil), before.Admissions...), AdmissionRecord{Region: "ie", PredecessorRegion: "ie",
+ Session: next.Claim.Gen, Box: "boxT", Generation: "gen_b", Mode: "cold", Predecessor: "gen_a",
+ })
+ assertAdmissions(t, next.Claim, want)
+ assertAdmissionStored(t, ctx, s, next)
+ in.ObservedSession, in.ObservedGeneration, in.NewGeneration = next.Claim.Holder.Session, "gen_b", "gen_c"
+ if _, err := Update(ctx, s, next, AdmitWriter(in)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("restarted probe keeping stamp = %v, want ErrWrongState", err)
+ }
+ assertAdmissionStored(t, ctx, s, next)
+ })
+ }
+}
+
+func TestAdmitWriterCoverage(t *testing.T) {
+ for _, mode := range []string{"cold", "warm"} {
+ for _, certified := range []bool{false, true} {
+ for _, coverage := range []string{"", "sealed", "clean-return"} {
+ if mode == "warm" && !certified {
+ continue
+ }
+ t.Run(fmt.Sprintf("%s/certified=%t/coverage=%s", mode, certified, coverage), func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a", PredecessorCertified: true}))
+ before := *o.Claim.Holder
+ in := Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: before.Session, ObservedGeneration: before.Generation, Box: "adi-knitting", Mode: mode, PredecessorCertified: certified, Coverage: coverage, RemoteMaxTXID: 42}
+ generation := before.Generation
+ if mode == "cold" {
+ in.NewGeneration, generation = "gen_b", "gen_b"
+ }
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(in))
+ last := AdmissionRecord{Region: "ie", PredecessorRegion: "ie", Session: o.Claim.Gen, Box: "adi-knitting", Generation: generation, Predecessor: "gen_a", Mode: mode, Coverage: coverage, Covered: mode == "warm"}
+ if mode == "cold" && !certified && coverage == "" {
+ last.WritesLost = &WritesLost{Box: "adi-knitting", Generation: "gen_a", Session: before.Session, RemoteMaxTXID: 42}
+ }
+ assertAdmissions(t, o.Claim, append(append([]AdmissionRecord(nil), before.Admissions...), last))
+ assertAdmissionStored(t, ctx, s, o)
+ })
+ }
+ }
+ }
+}
+
+func updateGenerationTest(t *testing.T, ctx context.Context, s release.Store, o Observed, tr func(*Claim) error) Observed {
+ t.Helper()
+ next, err := Update(ctx, s, o, tr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return assertAdmissionStored(t, ctx, s, next)
+}
+
+func TestAdmitWriterCleanReturnPredecessor(t *testing.T) {
+ for _, coverage := range []string{"", "clean-return"} {
+ t.Run(coverage, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindLanding, ID: "move", Phase: "probe", Exec: "boxT"}
+ return nil
+ })
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedOpID: "move", ObservedPhase: "probe", Box: "boxT", Mode: "cold", NewGeneration: "GT"}))
+ target := o.Claim.Holder.Admissions[0]
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Holder.Generation = "GS"
+ c.Op = Op{Kind: KindServing, Exec: "boxS"}
+ return nil
+ })
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: target.Session, ObservedGeneration: "GS", Box: "boxS", Mode: "cold", NewGeneration: "GS2", Coverage: coverage, RemoteMaxTXID: 42}))
+ last := AdmissionRecord{Region: "ie", PredecessorRegion: "ie", Session: o.Claim.Gen, Box: "boxS", Generation: "GS2", Predecessor: "GT", Mode: "cold", Coverage: coverage}
+ if coverage == "" {
+ last.WritesLost = &WritesLost{Box: "boxT", Generation: "GT", Session: target.Session, RemoteMaxTXID: 42}
+ }
+ assertAdmissions(t, o.Claim, []AdmissionRecord{target, last})
+ })
+ }
+}
+
+func TestAdoptAdmitPublishRootGeneration(t *testing.T) {
+ for _, replaceOp := range []bool{false, true} {
+ t.Run(fmt.Sprintf("replaceOp=%t", replaceOp), func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o = updateGenerationTest(t, ctx, s, o, Adopt(newHolder(), "boxS", nil, nil, "adopt", counter()))
+ if o.Claim.Holder.WriterBox != "boxS" || o.Claim.Holder.Generation != "" || RootGeneration != "@root" {
+ t.Fatalf("adopted holder = %+v", o.Claim.Holder)
+ }
+ for _, generation := range []string{"", RootGeneration} {
+ newID := func() string { t.Fatal("unadmitted publication allocated an ID"); return "" }
+ if _, err := Update(ctx, s, o, PublishGeneration("", "", 0, generation, "ie", newID)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("publication before admission = %v", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ }
+ if replaceOp {
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindLanding, ID: "move", Phase: "probe", Exec: "boxT"}
+ return nil
+ })
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindServing, Exec: "boxT"}
+ return nil
+ })
+ }
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G1", RemoteMaxTXID: 42}))
+ assertAdmissions(t, o.Claim, []AdmissionRecord{{Region: "ie", PredecessorRegion: "ie",
+ Session: o.Claim.Gen, Box: o.Claim.Op.Exec, Generation: "G1", Mode: "cold",
+ WritesLost: &WritesLost{Box: "boxS", Session: 0, Generation: "", RemoteMaxTXID: 42},
+ }})
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, "G1", "ie", counter()))
+ if !o.Claim.Holder.Admissions[0].Covered || len(o.Claim.Obligations) != 1 {
+ t.Fatalf("published claim = %+v", o.Claim)
+ }
+ ob := o.Claim.Obligations[0]
+ if ob.Generation != RootGeneration || ob.Kind != "park-generation" || ob.Region != "ie" || ob.Owner != "region:ie" || ob.Op != fmt.Sprintf("session:%d", o.Claim.Holder.Session) {
+ t.Fatalf("root obligation = %+v", ob)
+ }
+ holder := *o.Claim.Holder
+ o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ stored, err := Read(ctx, s, testHost)
+ if err != nil || stored.ETag != o.ETag || len(stored.Claim.Obligations) != 0 || !reflect.DeepEqual(*stored.Claim.Holder, holder) || stored.Claim.Op.Kind != KindServing {
+ t.Fatalf("completed root retirement = %+v, %v", stored, err)
+ }
+ })
+ }
+}
+
+func TestPublishGenerationChain(t *testing.T) {
+ for _, kind := range []string{KindServing, KindMoving, KindLanding, KindReverting, KindRestoring, KindPushing} {
+ t.Run(kind, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ if kind != KindServing {
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: kind, ID: "move", Phase: "probe", Exec: "boxT", Journal: map[string]string{"source": "unchanged"}}
+ return nil
+ })
+ }
+ for _, generation := range []string{"G1", "G2"} {
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase,
+ ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation,
+ Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: generation}))
+ }
+ before := o
+ calls := 0
+ newID := func() string { calls++; return fmt.Sprintf("id%d", calls) }
+ publish := PublishGeneration(o.Claim.Op.ID, o.Claim.Op.Phase, o.Claim.Holder.Session, "G2", "ie", newID)
+ o = updateGenerationTest(t, ctx, s, o, publish)
+ op := before.Claim.Op.ID
+ if op == "" {
+ op = fmt.Sprintf("session:%d", before.Claim.Holder.Session)
+ }
+ want := *before.Claim
+ holder := *want.Holder
+ want.Holder = &holder
+ want.Gen++
+ holder.Admissions = append([]AdmissionRecord(nil), holder.Admissions...)
+ for i := range holder.Admissions {
+ holder.Admissions[i].Covered = true
+ }
+ want.Obligations = []Obligation{
+ {ID: "id1", Op: op, Kind: "park-generation", Region: "ie", Generation: RootGeneration, Retirement: "id2", Owner: "region:ie"},
+ {ID: "id3", Op: op, Kind: "park-generation", Region: "ie", Generation: "G1", Retirement: "id4", Owner: "region:ie"},
+ }
+ if calls != 4 || !reflect.DeepEqual(o.Claim, &want) {
+ t.Fatalf("publication = %+v, want %+v; ID calls %d", o.Claim, &want, calls)
+ }
+ o = updateGenerationTest(t, ctx, s, o, publish)
+ want.Gen++
+ if calls != 4 || !reflect.DeepEqual(o.Claim, &want) {
+ t.Fatalf("republication changed coverage or chores: %+v; calls %d", o.Claim, calls)
+ }
+ })
+ }
+}
+
+func TestPublishGenerationRefusals(t *testing.T) {
+ for _, name := range []string{"no holder", "creating", "deleting", "parking", "purging", "placing", "stale op", "stale phase", "stale session", "wrong generation", "empty region", "no matching admission"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "G1"}))
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindLanding, ID: "move", Phase: "probe", Exec: "boxT"}
+ if name == "no holder" {
+ c.Holder, c.Op = nil, Op{}
+ } else if knownKinds[name] {
+ c.Op.Kind = name
+ } else if name == "no matching admission" {
+ c.Holder.Generation = "unrecorded"
+ }
+ return nil
+ })
+ op, phase, session, generation, region := o.Claim.Op.ID, o.Claim.Op.Phase, uint64(0), "G1", "ie"
+ if o.Claim.Holder != nil {
+ session, generation = o.Claim.Holder.Session, o.Claim.Holder.Generation
+ }
+ switch name {
+ case "stale op":
+ op = ""
+ case "stale phase":
+ phase = ""
+ case "stale session":
+ session--
+ case "wrong generation":
+ generation = "old"
+ case "empty region":
+ region = ""
+ }
+ newID := func() string { t.Fatal("refusal allocated an ID"); return "" }
+ if _, err := Update(ctx, s, o, PublishGeneration(op, phase, session, generation, region, newID)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("publication = %v, want ErrWrongState", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ })
+ }
+}
+
+func TestPublishGenerationAllAdmissionsInOrder(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Holder.Generation = "G2"
+ c.Holder.Admissions = []AdmissionRecord{
+ {Region: "ie", PredecessorRegion: "ie", Generation: "old", Predecessor: "already-covered", Covered: true},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "G1", Predecessor: ""},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "G1", Predecessor: RootGeneration},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "G2", Predecessor: "G1"},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "G2", Predecessor: "G2", Mode: "warm", Covered: true},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "later", Predecessor: "G2", Mode: "cold"},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "after-last-match", Predecessor: "later", Mode: "cold"},
+ {Region: "ie", PredecessorRegion: "uk", Generation: "foreign", Predecessor: "foreign-predecessor", Mode: "cold"},
+ {Region: "ie", Generation: "empty", Predecessor: "no-objects", Mode: "cold"},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "duplicate", Predecessor: "later", Mode: "cold"},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "warm-only", Predecessor: "warm-only", Mode: "warm", Covered: true},
+ {Region: "ie", PredecessorRegion: "ie", Generation: "last", Predecessor: "final-predecessor", Mode: "cold"},
+ }
+ c.Obligations = []Obligation{
+ {ID: "existing", Op: "old-op", Kind: "park-generation", Region: "ie", Generation: "G1", Retirement: "ret1", Owner: "region:ie"},
+ {ID: "other-region", Op: "old-op", Kind: "park-generation", Region: "uk", Generation: RootGeneration, Retirement: "ret2", Owner: "region:uk"},
+ }
+ return nil
+ })
+ before := o
+ calls := 0
+ newID := func() string { calls++; return fmt.Sprintf("id%d", calls) }
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", 0, "G2", "ie", newID))
+ want := append(append([]Obligation(nil), before.Claim.Obligations...),
+ Obligation{ID: "id1", Op: "session:0", Kind: "park-generation", Region: "ie", Generation: RootGeneration, Retirement: "id2", Owner: "region:ie"},
+ Obligation{ID: "id3", Op: "session:0", Kind: "park-generation", Region: "ie", Generation: "later", Retirement: "id4", Owner: "region:ie"},
+ Obligation{ID: "id5", Op: "session:0", Kind: "park-generation", Region: "ie", Generation: "final-predecessor", Retirement: "id6", Owner: "region:ie"},
+ )
+ if calls != 6 || !reflect.DeepEqual(o.Claim.Obligations, want) {
+ t.Fatalf("deduplicated chores = %+v, want %+v; calls %d", o.Claim.Obligations, want, calls)
+ }
+ for i, record := range o.Claim.Holder.Admissions {
+ if record.Covered != (i != 5 && i != 7) {
+ t.Fatalf("record %d covered = %t", i, record.Covered)
+ }
+ }
+}
+
+func TestPublishGenerationActiveRoot(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "warm", PredecessorCertified: true}))
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, "", "ie", func() string {
+ t.Fatal("active root must not be retired")
+ return ""
+ }))
+ if !o.Claim.Holder.Admissions[0].Covered || len(o.Claim.Obligations) != 0 {
+ t.Fatalf("active root publication = %+v", o.Claim)
+ }
+}
+
+func TestAdmitWriterTrimsCoveredPrefix(t *testing.T) {
+ for _, name := range []string{"all covered", "uncovered first", "uncovered middle"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ for i := 1; i <= 10; i++ {
+ 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: fmt.Sprintf("G%d", i)}))
+ }
+ if len(o.Claim.Holder.Admissions) != 10 {
+ t.Fatalf("uncovered records were trimmed: %+v", o.Claim.Holder.Admissions)
+ }
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, "G10", "ie", counter()))
+ if name != "all covered" {
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ i := 0
+ if name == "uncovered middle" {
+ i = 1
+ }
+ c.Holder.Admissions[i].Covered = false
+ return nil
+ })
+ }
+ 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
+ }
+ 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)
+ })
+ }
+}
+
+func TestAdmissionRecordJSON(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "G1", RemoteMaxTXID: 42}))
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "G1", Box: "adi-knitting", Mode: "cold", NewGeneration: "G2", Coverage: "sealed", PredecessorCertified: true}))
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, "G2", "ie", counter()))
+ raw, err := json.Marshal(o.Claim.Holder)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var holder map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &holder); err != nil {
+ t.Fatal(err)
+ }
+ var got []map[string]any
+ if err := json.Unmarshal(holder["admissions"], &got); err != nil {
+ t.Fatal(err)
+ }
+ want := []map[string]any{
+ {"region": "ie", "predecessor_region": "ie", "session": float64(o.Claim.Holder.Admissions[0].Session), "box": "adi-knitting", "generation": "G1", "mode": "cold", "covered": true,
+ "writes_lost": map[string]any{"box": "", "generation": "", "session": float64(0), "remote_max_txid": float64(42)}},
+ {"region": "ie", "predecessor_region": "ie", "session": float64(o.Claim.Holder.Session), "box": "adi-knitting", "generation": "G2", "predecessor": "G1", "mode": "cold", "coverage": "sealed", "covered": true},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("admissions JSON = %s, want %+v", holder["admissions"], want)
+ }
+}
+
+func TestAdmitWriterIgnoresLegacyWriterJournal(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op.Journal = map[string]string{"writers": "not-json", "source": "unchanged"}
+ return nil
+ })
+ before := o.Claim.Op
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "G1"}))
+ if !reflect.DeepEqual(o.Claim.Op, before) || len(o.Claim.Holder.Admissions) != 1 {
+ t.Fatalf("legacy journal affected admission: %+v", o.Claim)
+ }
+}
+
+func TestPublishGenerationForeignRegion(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G1"}))
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "uk", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "G1", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G2"}))
+ newID := func() string { t.Fatal("foreign-region publication allocated an ID"); return "" }
+ publish := PublishGeneration("", "", o.Claim.Holder.Session, "G2", "uk", newID)
+ o = updateGenerationTest(t, ctx, s, o, publish)
+ for _, record := range o.Claim.Holder.Admissions {
+ if record.Covered || record.PredecessorRegion != "ie" {
+ t.Fatalf("foreign predecessor = %+v", record)
+ }
+ }
+ if len(o.Claim.Obligations) != 0 {
+ t.Fatalf("foreign publication queued chores: %+v", o.Claim.Obligations)
+ }
+ o = updateGenerationTest(t, ctx, s, o, CoverRegionPredecessors("", "", "ie"))
+ for _, record := range o.Claim.Holder.Admissions {
+ if !record.Covered {
+ t.Fatalf("source cleanup left predecessor uncovered: %+v", record)
+ }
+ }
+ before := *o.Claim
+ o = updateGenerationTest(t, ctx, s, o, publish)
+ before.Gen++
+ if len(o.Claim.Obligations) != 0 || !reflect.DeepEqual(o.Claim, &before) {
+ t.Fatalf("publication after source coverage changed claim: %+v", o.Claim)
+ }
+}
+
+func TestPublishGenerationHoldsNone(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G1"}))
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, "G1", "ie", func() string {
+ t.Fatal("predecessor with no objects allocated an ID")
+ return ""
+ }))
+ if !o.Claim.Holder.Admissions[0].Covered || len(o.Claim.Obligations) != 0 {
+ t.Fatalf("empty predecessor publication = %+v", o.Claim)
+ }
+ raw, err := json.Marshal(o.Claim.Holder.Admissions[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ t.Fatal(err)
+ }
+ if string(fields["region"]) != `"ie"` || fields["predecessor_region"] != nil {
+ t.Fatalf("region JSON = %s", raw)
+ }
+}
+
+func TestAdmitWriterRegionRules(t *testing.T) {
+ for _, name := range []string{"empty region", "warm other region", "warm same region", "recorded predecessor region", "first predecessor region", "first warm other region"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ if name != "first predecessor region" && name != "empty region" && name != "first warm other region" {
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G1"}))
+ }
+ in := Admission{Region: "uk", PredecessorRegion: "fr", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation,
+ Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G2", PredecessorCertified: true}
+ switch name {
+ case "empty region":
+ in.Region = ""
+ case "warm other region", "warm same region", "first warm other region":
+ in.Mode, in.NewGeneration = "warm", ""
+ if name == "warm same region" {
+ in.Region = "ie"
+ }
+ }
+ next, err := Update(ctx, s, o, AdmitWriter(in))
+ if name == "empty region" || name == "warm other region" || name == "first warm other region" {
+ if !errors.Is(err, ErrWrongState) {
+ t.Fatalf("admission = %v, want ErrWrongState", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertAdmissionStored(t, ctx, s, next)
+ record := next.Claim.Holder.Admissions[len(next.Claim.Holder.Admissions)-1]
+ wantPredecessorRegion := "ie"
+ if name == "first predecessor region" || name == "first warm other region" {
+ wantPredecessorRegion = "fr"
+ }
+ if record.Region != in.Region || record.PredecessorRegion != wantPredecessorRegion {
+ t.Fatalf("admission regions = %+v, want %q / %q", record, in.Region, wantPredecessorRegion)
+ }
+ })
+ }
+}
+
+func TestAdmitWriterNeverTrimsForeignUncovered(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ newID := counter()
+ for i := 1; i <= 12; i++ {
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "uk", PredecessorRegion: "ie",
+ ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation,
+ Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: fmt.Sprintf("G%d", i)}))
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, o.Claim.Holder.Generation, "uk", newID))
+ }
+ if len(o.Claim.Holder.Admissions) != 12 || o.Claim.Holder.Admissions[0].Generation != "G1" || o.Claim.Holder.Admissions[0].Covered {
+ t.Fatalf("foreign uncovered head was trimmed or covered: %+v", o.Claim.Holder.Admissions)
+ }
+ for _, record := range o.Claim.Holder.Admissions[1:] {
+ if !record.Covered {
+ t.Fatalf("publication stopped at foreign predecessor: %+v", record)
+ }
+ }
+}
+
+func TestCoverRegionPredecessors(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindLanding, ID: "move", Phase: "cleanup", Exec: c.Op.Exec}
+ c.Holder.Admissions = []AdmissionRecord{
+ {Region: "ie", PredecessorRegion: "ie", Covered: true},
+ {Region: "uk", PredecessorRegion: "uk"},
+ {Region: "uk", PredecessorRegion: "ie"},
+ {Region: "ie"},
+ {Region: "ie", PredecessorRegion: "ie"},
+ }
+ c.Obligations = []Obligation{{ID: "source", Op: "move", Kind: "park-replica", Region: "ie", Retirement: "ret", Owner: "region:ie"}}
+ return nil
+ })
+ want := *o.Claim
+ holder := *want.Holder
+ want.Holder = &holder
+ holder.Admissions = append([]AdmissionRecord(nil), holder.Admissions...)
+ holder.Admissions[2].Covered, holder.Admissions[4].Covered = true, true
+ for i := 0; i < 2; i++ {
+ o = updateGenerationTest(t, ctx, s, o, CoverRegionPredecessors("move", "cleanup", "ie"))
+ want.Gen++
+ if !reflect.DeepEqual(o.Claim, &want) {
+ t.Fatalf("region coverage = %+v, want %+v", o.Claim, &want)
+ }
+ }
+}
+
+func TestCoverRegionPredecessorsRefusals(t *testing.T) {
+ for _, name := range []string{"stale op", "stale phase", "nil holder", "empty region"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindLanding, ID: "move", Phase: "cleanup", Exec: c.Op.Exec}
+ c.Holder.Admissions = []AdmissionRecord{{Region: "uk", PredecessorRegion: "ie"}}
+ if name == "nil holder" {
+ c.Holder, c.Op = nil, Op{}
+ }
+ return nil
+ })
+ op, phase, region := o.Claim.Op.ID, o.Claim.Op.Phase, "ie"
+ switch name {
+ case "stale op":
+ op = ""
+ case "stale phase":
+ phase = ""
+ case "empty region":
+ region = ""
+ }
+ if _, err := Update(ctx, s, o, CoverRegionPredecessors(op, phase, region)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("region coverage = %v, want ErrWrongState", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ })
+ }
+}
+
+func TestValidateAdmissionRegion(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ for _, records := range [][]AdmissionRecord{
+ {{Region: ""}},
+ {{Region: "ie"}, {Region: ""}},
+ {{Region: "", Covered: true}, {Region: "ie"}},
+ } {
+ o.Claim.Holder.Admissions = records
+ if err := o.Claim.Validate(); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("admissions %+v: Validate = %v, want ErrMalformed", records, err)
+ }
+ }
+}
diff --git a/internal/hostclaim/ops.go b/internal/hostclaim/ops.go
new file mode 100644
index 000000000..85b3e740c
--- /dev/null
+++ b/internal/hostclaim/ops.go
@@ -0,0 +1,44 @@
+package hostclaim
+
+import (
+ "errors"
+ "fmt"
+)
+
+var (
+ ErrStaleOp = errors.New("hostclaim: the claim is under a different operation")
+ ErrWrongState = errors.New("hostclaim: transition not legal from the claim's state")
+ ErrPendingChore = errors.New("hostclaim: an obligation is pending in a location this operation writes")
+)
+
+func RequireOp(id string) func(*Claim) error {
+ return func(c *Claim) error {
+ if id == "" || c.Op.ID != id {
+ return fmt.Errorf("%w: have %q, want %q", ErrStaleOp, c.Op.ID, id)
+ }
+ return nil
+ }
+}
+
+// StartGuard is §4: one operation at a time, started from serving, and
+// CC11: never over a chore in a region or box the operation will write.
+func StartGuard(c *Claim, regions, boxes []string) error {
+ if c.Holder == nil || c.Op.Kind != KindServing {
+ return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
+ }
+ inR, inB := set(regions), set(boxes)
+ for _, o := range c.Obligations {
+ if (o.Region != "" && inR[o.Region]) || (o.Box != "" && inB[o.Box]) {
+ return fmt.Errorf("%w: %s %s%s", ErrPendingChore, o.Kind, o.Region, o.Box)
+ }
+ }
+ return nil
+}
+
+func set(xs []string) map[string]bool {
+ m := make(map[string]bool, len(xs))
+ for _, x := range xs {
+ m[x] = true
+ }
+ return m
+}
diff --git a/internal/hostclaim/ops_test.go b/internal/hostclaim/ops_test.go
new file mode 100644
index 000000000..53fbcea17
--- /dev/null
+++ b/internal/hostclaim/ops_test.go
@@ -0,0 +1,53 @@
+package hostclaim
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestRequireOpRefusesStale(t *testing.T) {
+ c := &Claim{Op: Op{Kind: KindDeleting, ID: "op_new"}}
+ if err := RequireOp("op_old")(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("stale op = %v", err)
+ }
+ if err := RequireOp("op_new")(c); err != nil {
+ t.Fatalf("current op = %v", err)
+ }
+ if err := RequireOp("")(c); !errors.Is(err, ErrStaleOp) {
+ t.Fatalf("empty id must never match: %v", err)
+ }
+}
+
+func TestStartGuard(t *testing.T) {
+ h := &Holder{Incarnation: "i"}
+ if err := StartGuard(&Claim{Holder: h, Op: Op{Kind: KindDeleting, ID: "x"}}, nil, nil); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("start over a running op = %v", err)
+ }
+ busy := &Claim{Holder: h, Op: Op{Kind: KindServing}, Obligations: []Obligation{{Kind: "park-replica", Region: "uk"}}}
+ if err := StartGuard(busy, []string{"uk"}, nil); !errors.Is(err, ErrPendingChore) {
+ t.Fatalf("start over a pending chore = %v", err)
+ }
+ if err := StartGuard(busy, []string{"ie"}, nil); err != nil {
+ t.Fatalf("start elsewhere = %v", err)
+ }
+}
+
+func TestStartGuardLocalBox(t *testing.T) {
+ busy := &Claim{Holder: &Holder{Incarnation: "i"}, Op: Op{Kind: KindServing},
+ Obligations: []Obligation{{Kind: "park-local", Box: "B0"}}}
+ for _, tc := range []struct {
+ name string
+ boxes []string
+ want error
+ }{
+ {name: "overlap", boxes: []string{"B0"}, want: ErrPendingChore},
+ {name: "elsewhere", boxes: []string{"B2"}},
+ {name: "no boxes"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := StartGuard(busy, nil, tc.boxes); !errors.Is(err, tc.want) {
+ t.Fatalf("start with boxes %v = %v, want %v", tc.boxes, err, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/hostclaim/orphan.go b/internal/hostclaim/orphan.go
new file mode 100644
index 000000000..072728710
--- /dev/null
+++ b/internal/hostclaim/orphan.go
@@ -0,0 +1,16 @@
+package hostclaim
+
+// IsOrphan is §5.1 step 5. The caller reads the record FIRST and this
+// claim AFTER it (R3-2). Each incarnation gets exactly one claim attempt,
+// and a record exists only after that attempt succeeded, so a claim read
+// after the record that does not name recordInc means recordInc can never
+// become the holder. A creating holder is named by Holder, so it is
+// covered. An absent claim is a legacy host (Q2), never an orphan. The
+// caller deletes only with DeleteIfMatch on the ETag it read.
+func IsOrphan(recordInc string, claimReadAfterRecord Observed) bool {
+ if claimReadAfterRecord.Absent || claimReadAfterRecord.Claim == nil {
+ return false
+ }
+ h := claimReadAfterRecord.Claim.Holder
+ return h == nil || h.Incarnation != recordInc
+}
diff --git a/internal/hostclaim/orphan_test.go b/internal/hostclaim/orphan_test.go
new file mode 100644
index 000000000..1dc8c7a4d
--- /dev/null
+++ b/internal/hostclaim/orphan_test.go
@@ -0,0 +1,23 @@
+package hostclaim
+
+import "testing"
+
+func TestIsOrphan(t *testing.T) {
+ if IsOrphan("A", Observed{Absent: true}) {
+ t.Fatal("no claim = legacy, never orphan")
+ }
+ held := Observed{Claim: &Claim{Holder: &Holder{Incarnation: "A"}, Op: Op{Kind: KindServing}}}
+ if IsOrphan("A", held) {
+ t.Fatal("the holder's own record is not an orphan")
+ }
+ creating := Observed{Claim: &Claim{Holder: &Holder{Incarnation: "A"}, Op: Op{Kind: KindCreating, ID: "c"}}}
+ if IsOrphan("A", creating) {
+ t.Fatal("a record of the creating incarnation is not an orphan")
+ }
+ if !IsOrphan("A", Observed{Claim: &Claim{Holder: &Holder{Incarnation: "B"}, Op: Op{Kind: KindServing}}}) {
+ t.Fatal("another incarnation's claim makes A an orphan")
+ }
+ if !IsOrphan("A", Observed{Claim: &Claim{}}) {
+ t.Fatal("a free claim makes A an orphan")
+ }
+}
diff --git a/internal/hostclaim/rev713_test.go b/internal/hostclaim/rev713_test.go
new file mode 100644
index 000000000..b14aa245f
--- /dev/null
+++ b/internal/hostclaim/rev713_test.go
@@ -0,0 +1,472 @@
+package hostclaim
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAdmitWriterReturnRegion(t *testing.T) {
+ for _, mode := range []string{"warm", "cold"} {
+ for _, region := range []string{"ie", ""} {
+ t.Run(mode+"/region="+region, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "uk", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "GT"}))
+ target := o.Claim.Holder.Admissions[0]
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindReverting, ID: "move", Phase: "return", Exec: "boxS"}
+ return nil
+ })
+ in := Admission{
+ ObservedOpID: "move", ObservedPhase: "return", ObservedSession: target.Session, ObservedGeneration: "GT",
+ Box: "boxS", Mode: mode, Region: region, PredecessorRegion: "ignored",
+ PredecessorCertified: true, Coverage: "clean-return",
+ }
+ if mode == "cold" {
+ in.NewGeneration = "GS2"
+ }
+ next, err := Update(ctx, s, o, AdmitWriter(in))
+ if mode == "warm" || region == "" {
+ if !errors.Is(err, ErrWrongState) {
+ t.Fatalf("return admission = %v, want ErrWrongState", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ last := next.Claim.Holder.Admissions[1]
+ if last.Region != "ie" || last.PredecessorRegion != "uk" || last.Predecessor != "GT" || last.Generation != "GS2" || last.Mode != "cold" || last.Covered {
+ t.Fatalf("cold return = %+v", last)
+ }
+ assertAdmissionStored(t, ctx, s, next)
+ })
+ }
+ }
+}
+
+func TestAdmitWriterPendingParkCoverage(t *testing.T) {
+ for _, name := range []string{"whole predecessor region", "no park", "other region", "generation only", "local only"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "uk", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "GT"}))
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindReverting, ID: "move", Phase: "return", Exec: "boxS"}
+ ob := Obligation{ID: "park", Op: "release", Kind: "park-replica", Region: "uk", Owner: "region:uk", Retirement: "ret"}
+ switch name {
+ case "no park":
+ return nil
+ case "other region":
+ ob.Region, ob.Owner = "ie", "region:ie"
+ case "generation only":
+ ob.Kind, ob.Generation = "park-generation", "GT"
+ case "local only":
+ ob.Kind, ob.Region, ob.Owner, ob.Box = "park-local", "", "box:boxT", "boxT"
+ }
+ c.Obligations = []Obligation{ob}
+ return nil
+ })
+ before := o
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{
+ ObservedOpID: "move", ObservedPhase: "return", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "GT",
+ Box: "boxS", Mode: "cold", Region: "ie", NewGeneration: "GS2",
+ }))
+ last := o.Claim.Holder.Admissions[1]
+ if last.Covered != (name == "whole predecessor region") || last.PredecessorRegion != "uk" || last.WritesLost == nil {
+ t.Fatalf("return admission = %+v", last)
+ }
+ if !reflect.DeepEqual(before.Claim.Obligations, o.Claim.Obligations) {
+ t.Fatal("admission changed park obligations")
+ }
+ })
+ }
+}
+
+func TestPublishGenerationServingCyclesBoundAdmissions(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ newID := counter()
+ for cycle := 1; cycle <= 20; cycle++ {
+ 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,
+ }))
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, generation, "ie", newID))
+ for _, record := range o.Claim.Holder.Admissions {
+ if !record.Covered {
+ t.Fatalf("cycle %d left superseded predecessor uncovered: %+v", cycle, record)
+ }
+ }
+ if len(o.Claim.Obligations) != 1 {
+ t.Fatalf("cycle %d chores = %+v", cycle, o.Claim.Obligations)
+ }
+ predecessor := RootGeneration
+ if cycle > 1 {
+ predecessor = fmt.Sprintf("G%d", cycle-1)
+ }
+ ob := o.Claim.Obligations[0]
+ if ob.Generation != predecessor {
+ t.Fatalf("retiring %q, want %q", ob.Generation, predecessor)
+ }
+ var err error
+ o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{
+ Region: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: generation,
+ Box: o.Claim.Op.Exec, Mode: "warm", PredecessorCertified: true,
+ }))
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, generation, "ie", newID))
+ records := o.Claim.Holder.Admissions
+ if !records[len(records)-1].Covered || len(o.Claim.Obligations) != 0 {
+ t.Fatalf("cycle %d left warm entry uncovered or retired live predecessor", cycle)
+ }
+ if len(records) > 8 {
+ t.Fatalf("cycle %d admissions grew to %d", cycle, len(records))
+ }
+ }
+}
+
+func TestPublishGenerationReturnedSourceRetiredLater(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Holder.Generation = "GS"
+ c.Holder.Admissions = []AdmissionRecord{
+ {Region: "uk", Generation: "GT", Predecessor: "GS", PredecessorRegion: "ie"},
+ {Region: "ie", Generation: "GS", Predecessor: "GT", PredecessorRegion: "uk"},
+ }
+ return nil
+ })
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, "GS", "ie", counter()))
+ if o.Claim.Holder.Admissions[0].Covered || o.Claim.Holder.Admissions[1].Covered || len(o.Claim.Obligations) != 0 {
+ t.Fatal("source publication covered a live or foreign predecessor")
+ }
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{
+ Region: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "GS",
+ Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "GS2",
+ }))
+ o = updateGenerationTest(t, ctx, s, o, PublishGeneration("", "", o.Claim.Holder.Session, "GS2", "ie", counter()))
+ if !o.Claim.Holder.Admissions[0].Covered || o.Claim.Holder.Admissions[1].Covered || !o.Claim.Holder.Admissions[2].Covered || len(o.Claim.Obligations) != 1 || o.Claim.Obligations[0].Generation != "GS" {
+ t.Fatalf("source retirement after return = %+v; admissions = %+v", o.Claim.Obligations, o.Claim.Holder.Admissions)
+ }
+}
+
+func TestCoverParkedPredecessors(t *testing.T) {
+ for _, kind := range []string{KindServing, KindParking} {
+ t.Run(kind, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ if kind == KindParking {
+ c.Op = Op{Kind: kind, ID: "sweep", Phase: "park", Intent: map[string]string{"park_sweep": "v1"}}
+ }
+ c.Holder.Admissions = []AdmissionRecord{
+ {Region: "ie", PredecessorRegion: "ie", Predecessor: "G1"},
+ {Region: "uk", PredecessorRegion: "ie", Predecessor: ""},
+ {Region: "ie", PredecessorRegion: "ie", Predecessor: RootGeneration},
+ {Region: "ie", PredecessorRegion: "uk", Predecessor: "G1"},
+ {Region: "ie", PredecessorRegion: "ie", Predecessor: "G2"},
+ {Region: "ie", Predecessor: "G1"},
+ {Region: "ie", PredecessorRegion: "ie", Predecessor: "G3", Covered: true},
+ }
+ c.Obligations = []Obligation{{ID: "existing", Op: "old", Kind: "park-generation", Region: "ie", Generation: "G1", Retirement: "ret", Owner: "region:ie"}}
+ return nil
+ })
+ before := o
+ for _, generations := range [][]string{nil, {}, {""}} {
+ o = updateGenerationTest(t, ctx, s, o, CoverParkedPredecessors(o.Claim.Op.ID, o.Claim.Op.Phase, "ie", generations))
+ if !reflect.DeepEqual(o.Claim.Holder, before.Claim.Holder) {
+ t.Fatalf("empty selection changed records: %+v", o.Claim.Holder.Admissions)
+ }
+ }
+ want := *o.Claim
+ h := *want.Holder
+ want.Holder = &h
+ h.Admissions = append([]AdmissionRecord(nil), h.Admissions...)
+ for i := 0; i < 3; i++ {
+ h.Admissions[i].Covered = true
+ }
+ for repeat := 0; repeat < 2; repeat++ {
+ o = updateGenerationTest(t, ctx, s, o, CoverParkedPredecessors(o.Claim.Op.ID, o.Claim.Op.Phase, "ie", []string{"G1", RootGeneration, "G1", "absent"}))
+ want.Gen++
+ if !reflect.DeepEqual(o.Claim, &want) {
+ t.Fatalf("sweep changed more than selected coverage: %+v", o.Claim)
+ }
+ }
+ })
+ }
+}
+
+func TestCoverParkedPredecessorsRefusals(t *testing.T) {
+ for _, name := range []string{"stale op", "stale phase", "nil holder", "empty region"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindParking, ID: "sweep", Phase: "park"}
+ c.Holder.Admissions = []AdmissionRecord{{Region: "ie", PredecessorRegion: "ie", Predecessor: "G1"}}
+ if name == "nil holder" {
+ c.Holder, c.Op = nil, Op{}
+ }
+ return nil
+ })
+ op, phase, region := o.Claim.Op.ID, o.Claim.Op.Phase, "ie"
+ switch name {
+ case "stale op":
+ op = "old"
+ case "stale phase":
+ phase = "old"
+ case "empty region":
+ region = ""
+ }
+ if _, err := Update(ctx, s, o, CoverParkedPredecessors(op, phase, region, []string{"G1"})); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("sweep = %v", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ })
+ }
+}
+
+func TestCoverParkedPredecessorsComposesWithRetirement(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ cover := CoverParkedPredecessors("", "", "ie", []string{"G1"})
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Holder.Admissions = []AdmissionRecord{{Region: "uk", PredecessorRegion: "ie", Predecessor: "G1"}}
+ c.Obligations = append(c.Obligations, Obligation{ID: "park", Op: "sweep", Kind: "park-generation", Region: "ie", Generation: "G1", Retirement: "ret", Owner: "region:ie"})
+ return cover(c)
+ })
+ if !o.Claim.Holder.Admissions[0].Covered {
+ t.Fatal("queue CAS did not cover predecessor")
+ }
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Holder.Admissions = append(c.Holder.Admissions, AdmissionRecord{Region: "uk", PredecessorRegion: "ie", Predecessor: "G1"})
+ return nil
+ })
+ next, err := Update(ctx, s, o, func(c *Claim) error {
+ if err := cover(c); err != nil {
+ return err
+ }
+ return CompleteObligation("sweep", "park")(c)
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ stored, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.ETag != next.ETag || len(stored.Claim.Obligations) != 0 || !stored.Claim.Holder.Admissions[1].Covered {
+ t.Fatal("acknowledgement CAS did not cover later predecessor")
+ }
+}
+
+func TestAdmitWriterAdditionalWritesLost(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "GS", PredecessorCertified: true}))
+ cut := o.Claim.Holder.Session
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Op = Op{Kind: KindLanding, ID: "move", Phase: "probe", Exec: "boxT"}
+ return nil
+ })
+ var target []AdmissionRecord
+ for _, mode := range []string{"cold", "warm", "warm"} {
+ in := Admission{Region: "uk", ObservedOpID: "move", ObservedPhase: "probe", ObservedSession: o.Claim.Holder.Session,
+ ObservedGeneration: o.Claim.Holder.Generation, Box: "boxT", Mode: mode, PredecessorCertified: true}
+ if mode == "cold" {
+ in.NewGeneration, in.Coverage = "GT", "sealed"
+ }
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(in))
+ target = append(target, o.Claim.Holder.Admissions[len(o.Claim.Holder.Admissions)-1])
+ }
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ c.Holder.Generation = "GS"
+ c.Op = Op{Kind: KindReverting, ID: "move", Phase: "return", Exec: "boxS"}
+ return nil
+ })
+ additional := []WritesLost{}
+ for i, record := range target[:2] {
+ if record.Session <= cut {
+ t.Fatal("target session does not follow the cut")
+ }
+ additional = append(additional, WritesLost{Box: record.Box, Session: record.Session, Generation: record.Generation, RemoteMaxTXID: uint64(10 + i)})
+ }
+ wantAdditional := append([]WritesLost(nil), additional...)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{
+ Region: "ie", ObservedOpID: "move", ObservedPhase: "return", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "GS",
+ Box: "boxS", Mode: "cold", NewGeneration: "GS2", RemoteMaxTXID: 42, AdditionalWritesLost: additional,
+ }))
+ last := o.Claim.Holder.Admissions[len(o.Claim.Holder.Admissions)-1]
+ wantPrimary := &WritesLost{Box: "boxT", Session: target[2].Session, Generation: "GT", RemoteMaxTXID: 42}
+ if !reflect.DeepEqual(last.WritesLost, wantPrimary) || !reflect.DeepEqual(last.AdditionalWritesLost, wantAdditional) {
+ t.Fatalf("loss declarations = %+v, %+v", last.WritesLost, last.AdditionalWritesLost)
+ }
+ additional[0].Box = "changed"
+ if !reflect.DeepEqual(last.AdditionalWritesLost, wantAdditional) {
+ t.Fatal("record aliases the caller's loss declarations")
+ }
+ raw, err := json.Marshal(last)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ t.Fatal(err)
+ }
+ var decoded []WritesLost
+ if err := json.Unmarshal(fields["additional_writes_lost"], &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(decoded, wantAdditional) {
+ t.Fatalf("additional loss JSON = %s", raw)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+}
+
+func TestAdmitWriterAdditionalWritesLostValidation(t *testing.T) {
+ for _, name := range []string{"empty box", "zero session", "empty generation", "duplicate session", "duplicate predecessor", "same box different session", "same session different box", "root generation", "warm", "sealed"} {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{Region: "uk", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "GT"}))
+ loss := WritesLost{Box: "boxT", Session: 42, Generation: "GT", RemoteMaxTXID: 0}
+ in := Admission{Region: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "GT", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "GS"}
+ switch name {
+ case "empty box":
+ loss.Box = ""
+ case "zero session":
+ loss.Session = 0
+ case "empty generation":
+ loss.Generation = ""
+ case "duplicate predecessor":
+ loss.Box, loss.Session = o.Claim.Holder.WriterBox, o.Claim.Holder.Session
+ case "root generation":
+ loss.Generation = RootGeneration
+ case "warm":
+ in.Mode, in.NewGeneration, in.PredecessorCertified = "warm", "", true
+ in.Region = "uk"
+ case "sealed":
+ in.Coverage = "sealed"
+ }
+ in.AdditionalWritesLost = []WritesLost{loss}
+ switch name {
+ case "duplicate session", "same box different session", "same session different box":
+ second := loss
+ second.Generation, second.RemoteMaxTXID = "other", 99
+ if name == "same box different session" {
+ second.Session++
+ }
+ if name == "same session different box" {
+ second.Box = "boxU"
+ }
+ in.AdditionalWritesLost = append(in.AdditionalWritesLost, second)
+ }
+ next, err := Update(ctx, s, o, AdmitWriter(in))
+ switch name {
+ case "empty box", "zero session", "empty generation", "duplicate session", "duplicate predecessor":
+ if !errors.Is(err, ErrWrongState) {
+ t.Fatalf("invalid loss admission = %v", err)
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ default:
+ if err != nil {
+ t.Fatal(err)
+ }
+ last := next.Claim.Holder.Admissions[len(next.Claim.Holder.Admissions)-1]
+ if !reflect.DeepEqual(last.AdditionalWritesLost, in.AdditionalWritesLost) {
+ t.Fatalf("loss declarations = %+v", last.AdditionalWritesLost)
+ }
+ assertAdmissionStored(t, ctx, s, next)
+ }
+ })
+ }
+}
+
+func TestAdmitWriterWarmBornCovered(t *testing.T) {
+ for _, generation := range []string{"", "G1"} {
+ t.Run("generation="+generation, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ if generation != "" {
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{
+ Region: "ie", PredecessorRegion: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: generation,
+ }))
+ }
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{
+ Region: "ie", PredecessorRegion: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: generation,
+ Box: o.Claim.Op.Exec, Mode: "warm", PredecessorCertified: true,
+ }))
+ records := o.Claim.Holder.Admissions
+ last := records[len(records)-1]
+ if !last.Covered || last.Mode != "warm" || last.Generation != generation || last.Predecessor != generation || last.WritesLost != nil || len(o.Claim.Obligations) != 0 {
+ t.Fatalf("warm birth = %+v; chores = %+v", last, o.Claim.Obligations)
+ }
+ if len(records) > 1 && records[0].Covered {
+ t.Fatal("warm birth covered an earlier cold entry")
+ }
+ assertAdmissionStored(t, ctx, s, o)
+ })
+ }
+}
+
+func TestCompleteObligationDoesNotCoverAdmissions(t *testing.T) {
+ for _, kind := range []string{"park-replica", "park-generation"} {
+ t.Run(kind, func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{
+ Region: "ie", PredecessorRegion: "ie", Box: o.Claim.Op.Exec, Mode: "cold", NewGeneration: "G1",
+ }))
+ o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error {
+ ob := Obligation{ID: "park", Op: "cleanup", Kind: kind, Region: "ie", Owner: "region:ie", Retirement: "ret"}
+ if kind == "park-generation" {
+ ob.Generation = RootGeneration
+ }
+ c.Obligations = append(c.Obligations, ob)
+ return nil
+ })
+ before := o.Claim.Holder.Admissions
+ if before[0].Covered {
+ t.Fatal("queueing a park alone covered its predecessor")
+ }
+ next, err := Update(ctx, s, o, CompleteObligation("cleanup", "park"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ stored, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.ETag != next.ETag || len(stored.Claim.Obligations) != 0 || !reflect.DeepEqual(stored.Claim.Holder.Admissions, before) {
+ t.Fatalf("acknowledgement changed admissions: %+v", stored.Claim)
+ }
+ })
+ }
+}
diff --git a/internal/hostclaim/store.go b/internal/hostclaim/store.go
new file mode 100644
index 000000000..b421bab85
--- /dev/null
+++ b/internal/hostclaim/store.go
@@ -0,0 +1,117 @@
+package hostclaim
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var ErrConflict = fmt.Errorf("hostclaim: claim changed since it was read: %w", release.ErrCASConflict)
+
+type Observed struct {
+ Host string
+ Claim *Claim
+ ETag string
+ Absent bool
+}
+
+func decodeStrict(body []byte) (*Claim, error) {
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.DisallowUnknownFields()
+ var c Claim
+ if err := dec.Decode(&c); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrMalformed, err)
+ }
+ if _, err := dec.Token(); err != io.EOF {
+ return nil, fmt.Errorf("%w: trailing data", ErrMalformed)
+ }
+ return &c, nil
+}
+
+func Read(ctx context.Context, s release.Store, host string) (Observed, error) {
+ key, err := Key(host)
+ if err != nil {
+ return Observed{}, err
+ }
+ body, etag, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ return Observed{Host: host, Absent: true}, nil
+ }
+ if err != nil {
+ return Observed{}, err // C6: an error is never absence
+ }
+ c, err := decodeStrict(body)
+ if err != nil {
+ return Observed{}, err
+ }
+ if c.Host != host {
+ return Observed{}, fmt.Errorf("%w: host mismatch", ErrMalformed)
+ }
+ if err := c.Validate(); err != nil {
+ return Observed{}, err
+ }
+ return Observed{Host: host, Claim: c, ETag: etag}, nil
+}
+
+func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error) {
+ key, err := Key(obs.Host)
+ if err != nil {
+ return Observed{}, err
+ }
+ var next *Claim
+ var oldGen uint64
+ if obs.Absent {
+ next = &Claim{V: schemaVersion, Host: obs.Host}
+ } else {
+ if obs.ETag == "" {
+ return Observed{}, fmt.Errorf("%w: non-absent observation without ETag", ErrMalformed)
+ }
+ if obs.Claim == nil || obs.Claim.Host != obs.Host {
+ return Observed{}, fmt.Errorf("%w: observation not bound to %s", ErrMalformed, obs.Host)
+ }
+ raw, err := json.Marshal(obs.Claim)
+ if err != nil {
+ return Observed{}, err
+ }
+ if next, err = decodeStrict(raw); err != nil {
+ return Observed{}, err
+ }
+ oldGen = obs.Claim.Gen
+ }
+ if err := tr(next); err != nil {
+ return Observed{}, err
+ }
+ if next.Host != obs.Host || next.Gen != oldGen {
+ return Observed{}, fmt.Errorf("%w: a transition may not change host or gen", ErrMalformed)
+ }
+ if oldGen == math.MaxUint64 {
+ return Observed{}, fmt.Errorf("%w: gen overflow", ErrMalformed)
+ }
+ next.Gen = oldGen + 1
+ if err := next.Validate(); err != nil {
+ return Observed{}, err
+ }
+ body, err := json.Marshal(next)
+ if err != nil {
+ return Observed{}, err
+ }
+ ifMatch := obs.ETag
+ if obs.Absent {
+ ifMatch = "" // the host's very first claim (C5a)
+ }
+ etag, err := s.Put(ctx, key, body, ifMatch)
+ if errors.Is(err, release.ErrCASConflict) {
+ return Observed{}, ErrConflict
+ }
+ if err != nil {
+ return Observed{}, err
+ }
+ return Observed{Host: obs.Host, Claim: next, ETag: etag}, nil
+}
diff --git a/internal/hostclaim/store_test.go b/internal/hostclaim/store_test.go
new file mode 100644
index 000000000..988969ec8
--- /dev/null
+++ b/internal/hostclaim/store_test.go
@@ -0,0 +1,109 @@
+package hostclaim
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestReadRefusesMalformed(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, err := Read(ctx, s, testHost)
+ if err != nil || !obs.Absent || obs.Host != testHost {
+ t.Fatalf("absent read = %+v, %v", obs, err)
+ }
+ key, _ := Key(testHost)
+ good := `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false}}`
+ unknownChore := servingClaim(t, ctx, s).Claim
+ unknownChore.Op = Op{Kind: KindParking, ID: "x"}
+ unknownChore.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-replica", Region: "ie", Retirement: "r", Owner: "region:ie"}}
+ if err := unknownChore.Validate(); err != nil {
+ t.Fatalf("invalid fixture before changing chore kind: %v", err)
+ }
+ unknownChore.Obligations[0].Kind = "teleport"
+ unknownChoreBody, err := json.Marshal(unknownChore)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string]string{
+ "unknown field": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},"surprise":1}`,
+ "trailing data": good + ` {"v":2}`,
+ "wrong host": `{"v":2,"host":"other.bab.oncarlos.com","gen":1,"op":{},"touched":{"complete":false}}`,
+ "free with chores": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},` +
+ `"obligations":[{"id":"o","op":"x","kind":"park-local","box":"b","retirement":"r","owner":"box:b"}]}`,
+ "unknown chore kind": string(unknownChoreBody),
+ } {
+ s2 := release.NewDirStore(t.TempDir())
+ if _, err := s2.Put(ctx, key, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ _, err := Read(ctx, s2, testHost)
+ if name == "unknown chore kind" && (err == nil || !strings.Contains(err.Error(), `obligation kind "teleport"`)) {
+ t.Errorf("%s: read = %v, want obligation-kind rejection", name, err)
+ }
+ if !errors.Is(err, ErrMalformed) {
+ t.Errorf("%s: read = %v, want ErrMalformed", name, err)
+ }
+ }
+}
+
+func TestUpdateDerivesGenAndBindsHost(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, _ := Read(ctx, s, testHost)
+ noop := func(c *Claim) error { return nil }
+ o1, err := Update(ctx, s, obs, noop)
+ if err != nil || o1.Claim.Gen != 1 || o1.Claim.Host != testHost {
+ t.Fatalf("first write = %+v, %v", o1, err)
+ }
+ if _, err := Update(ctx, s, obs, noop); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale absent write = %v, want ErrConflict (C5a)", err)
+ }
+ if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Gen = 99; return nil }); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("transition changing gen = %v", err)
+ }
+ if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Host = "other.bab.oncarlos.com"; return nil }); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("transition changing host = %v", err)
+ }
+ o2, err := Update(ctx, s, o1, noop)
+ if err != nil || o2.Claim.Gen != 2 {
+ t.Fatalf("second write = %+v, %v", o2, err)
+ }
+ if _, err := Update(ctx, s, o1, noop); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale etag write = %v, want ErrConflict", err)
+ }
+}
+
+func TestUpdateRejectsNonAbsentObservationWithoutETag(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs := Observed{
+ Host: testHost,
+ Claim: &Claim{V: 2, Host: testHost, Gen: 1},
+ Absent: false,
+ ETag: "",
+ }
+ if err := obs.Claim.Validate(); err != nil {
+ t.Fatalf("invalid test claim: %v", err)
+ }
+ transitionCalled := false
+ _, err := Update(ctx, s, obs, func(c *Claim) error {
+ transitionCalled = true
+ return nil
+ })
+ if !errors.Is(err, ErrMalformed) {
+ t.Errorf("Update = %v, want ErrMalformed", err)
+ }
+ if transitionCalled {
+ t.Error("transition called for observation without ETag")
+ }
+ after, err := Read(ctx, s, testHost)
+ if err != nil || !after.Absent {
+ t.Errorf("read after rejected update = %+v, %v; want absent", after, err)
+ }
+}