carlos / platform Public

hostclaim: the claim object (schema v2), key, validation and incarnation

Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev ac0278b322f6f5914c95bed6e470eb60d5b9c75d parent 8d8e4ba
2 files changed, +315 −0
  • internal/hostclaim/claim.go +227 −0
  • internal/hostclaim/claim_test.go +88 −0
diff --git a/internal/hostclaim/claim.go b/internal/hostclaim/claim.go
new file mode 100644
index 000000000..d7429affe
--- /dev/null
+++ b/internal/hostclaim/claim.go
@@ -0,0 +1,227 @@
+// 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"`
+}
+
+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"
+ Region string `json:"region,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)
+ }
+ }
+ 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-replica":
+ if o.Region == "" || o.Box != "" || 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.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..f72cd3faa
--- /dev/null
+++ b/internal/hostclaim/claim_test.go
@@ -0,0 +1,88 @@
+package hostclaim
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+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)
+ }
+ }
+}