| 1 | // Package hostclaim is the host lifecycle claim (spec |
| 2 | // 2026-10-02-host-lifecycle-claim-design.md): one object per host that |
| 3 | // names the incarnation entitled to it, the operation running on it, where |
| 4 | // its data lives and what is still owed. This package only reads, |
| 5 | // validates and conditionally writes that object. It never deletes it |
| 6 | // (C5a). |
| 7 | package hostclaim |
| 8 | |
| 9 | import ( |
| 10 | "crypto/sha256" |
| 11 | "encoding/hex" |
| 12 | "errors" |
| 13 | "fmt" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/controlname" |
| 18 | "github.com/carlosframework/platform/internal/maintenanceguard" |
| 19 | ) |
| 20 | |
| 21 | const ( |
| 22 | KindServing = "serving" |
| 23 | KindCreating = "creating" |
| 24 | KindDeleting = "deleting" |
| 25 | KindParking = "parking" |
| 26 | KindPurging = "purging" |
| 27 | KindPlacing = "placing" |
| 28 | KindMoving = "moving" // move-any-app |
| 29 | KindLanding = "landing" // move-any-app |
| 30 | KindReverting = "reverting" // move-any-app |
| 31 | KindRestoring = "restoring" // move-any-app |
| 32 | KindPushing = "pushing" // later push spec |
| 33 | |
| 34 | schemaVersion = 2 |
| 35 | retiredCap = 32 |
| 36 | ) |
| 37 | |
| 38 | var knownKinds = map[string]bool{KindServing: true, KindCreating: true, KindDeleting: true, KindParking: true, |
| 39 | KindPurging: true, KindPlacing: true, KindMoving: true, KindLanding: true, KindReverting: true, |
| 40 | KindRestoring: true, KindPushing: true, KindRelocating: true} |
| 41 | |
| 42 | var ( |
| 43 | ErrMalformed = errors.New("hostclaim: malformed claim") |
| 44 | ErrInvalidHost = errors.New("hostclaim: invalid host") |
| 45 | ) |
| 46 | |
| 47 | type Holder struct { |
| 48 | Acquisition string `json:"acq,omitempty"` |
| 49 | Family string `json:"family"` // "instance" | "sidecar" |
| 50 | Key string `json:"key"` // exact record key |
| 51 | Account string `json:"account"` |
| 52 | App string `json:"app"` |
| 53 | Name string `json:"name,omitempty"` // sidecars only |
| 54 | Incarnation string `json:"incarnation"` |
| 55 | CreatedAt time.Time `json:"created_at"` |
| 56 | Location string `json:"location"` |
| 57 | Generation string `json:"generation,omitempty"` |
| 58 | Stamp uint64 `json:"stamp,omitempty"` |
| 59 | Session uint64 `json:"session,omitempty"` |
| 60 | WriterBox string `json:"writer_box,omitempty"` |
| 61 | Admissions []AdmissionRecord `json:"admissions,omitempty"` |
| 62 | WakeRetry *WakeRetry `json:"wake_retry,omitempty"` |
| 63 | } |
| 64 | |
| 65 | type AdmissionRecord struct { |
| 66 | Region string `json:"region"` |
| 67 | PredecessorRegion string `json:"predecessor_region,omitempty"` |
| 68 | Session uint64 `json:"session"` |
| 69 | Box string `json:"box"` |
| 70 | Generation string `json:"generation"` |
| 71 | Predecessor string `json:"predecessor,omitempty"` |
| 72 | Mode string `json:"mode"` |
| 73 | Coverage string `json:"coverage,omitempty"` |
| 74 | Covered bool `json:"covered"` |
| 75 | WritesLost *WritesLost `json:"writes_lost,omitempty"` |
| 76 | AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"` |
| 77 | LossID string `json:"loss_id,omitempty"` |
| 78 | LossArchivedClaimGen uint64 `json:"loss_archived_claim_gen,omitempty"` |
| 79 | } |
| 80 | |
| 81 | type WritesLost struct { |
| 82 | Box string `json:"box"` |
| 83 | Generation string `json:"generation"` |
| 84 | Session uint64 `json:"session"` |
| 85 | RemoteMaxTXID uint64 `json:"remote_max_txid"` |
| 86 | } |
| 87 | |
| 88 | type Op struct { |
| 89 | Kind string `json:"kind,omitempty"` |
| 90 | ID string `json:"id,omitempty"` |
| 91 | Phase string `json:"phase,omitempty"` |
| 92 | Intent map[string]string `json:"intent,omitempty"` |
| 93 | Journal map[string]string `json:"journal,omitempty"` |
| 94 | By string `json:"by,omitempty"` |
| 95 | StartedAt time.Time `json:"started_at,omitempty"` |
| 96 | Exec string `json:"exec,omitempty"` |
| 97 | Participants []string `json:"participants,omitempty"` |
| 98 | } |
| 99 | |
| 100 | type Touched struct { |
| 101 | DiscoveryBound bool `json:"discovery_bound,omitempty"` |
| 102 | DiscoveryBoxes []string `json:"discovery_boxes,omitempty"` |
| 103 | Complete bool `json:"complete"` |
| 104 | Regions []string `json:"regions,omitempty"` |
| 105 | Boxes []string `json:"boxes,omitempty"` |
| 106 | DiscoveryOp string `json:"discovery_op,omitempty"` |
| 107 | } |
| 108 | |
| 109 | // InventoryEntry is one box's local paths for the host (§6.3), each tagged |
| 110 | // with the installation stamp that produced it (§6.4). |
| 111 | type InventoryEntry struct { |
| 112 | Layout string `json:"layout"` // "flat" | "nested" |
| 113 | Paths map[string]uint64 `json:"paths"` // path -> stamp |
| 114 | } |
| 115 | |
| 116 | type ParkScope struct { |
| 117 | Location string `json:"location"` |
| 118 | Incarnation string `json:"incarnation"` |
| 119 | EvidenceAt time.Time `json:"evidence_at"` |
| 120 | RequestLimit time.Duration `json:"request_limit_ns"` |
| 121 | } |
| 122 | |
| 123 | func (p *ParkScope) Validate(host string) error { |
| 124 | if p == nil || !isHex64(p.Incarnation) || p.Incarnation != strings.ToLower(p.Incarnation) || p.EvidenceAt.IsZero() || p.RequestLimit <= 0 || p.RequestLimit > time.Duration(1<<63-1)-15*time.Minute { |
| 125 | return fmt.Errorf("%w: park scope", ErrMalformed) |
| 126 | } |
| 127 | base, staging := "instances/"+host, "moves/"+host |
| 128 | if p.Location == base || p.Location == base+"/"+p.Incarnation || p.Location == staging { |
| 129 | return nil |
| 130 | } |
| 131 | if op, ok := strings.CutPrefix(p.Location, staging+"/"); ok && op != "" && op != "." && op != ".." && !strings.Contains(op, "/") { |
| 132 | return nil |
| 133 | } |
| 134 | return fmt.Errorf("%w: park location", ErrMalformed) |
| 135 | } |
| 136 | |
| 137 | type Obligation struct { |
| 138 | EvidenceRefs map[string]RetirementEvidenceRef `json:"evidence_refs,omitempty"` |
| 139 | ParkTarget string `json:"park_target,omitempty"` |
| 140 | Park *ParkScope `json:"park,omitempty"` |
| 141 | ID string `json:"id"` |
| 142 | Op string `json:"op"` |
| 143 | Kind string `json:"kind"` // "park-replica" | "park-local" | "park-generation" |
| 144 | Region string `json:"region,omitempty"` |
| 145 | Generation string `json:"generation,omitempty"` |
| 146 | Box string `json:"box,omitempty"` |
| 147 | Retirement string `json:"retirement"` |
| 148 | Owner string `json:"owner"` // "region:<r>" | "box:<b>" |
| 149 | } |
| 150 | |
| 151 | type CancelledCreate struct { |
| 152 | Key string `json:"key"` |
| 153 | Incarnation string `json:"incarnation"` |
| 154 | Until time.Time `json:"until"` |
| 155 | } |
| 156 | |
| 157 | type Claim struct { |
| 158 | RetainedAdmissionLosses []RetainedAdmissionLoss `json:"retained_admission_losses,omitempty"` |
| 159 | Origins *OriginHead `json:"admitted_origins,omitempty"` |
| 160 | originAdmission *originTuple |
| 161 | |
| 162 | RetiredLocations map[string]string `json:"retired_locations,omitempty"` |
| 163 | EvidenceRefs []AutomaticLoss `json:"evidence_refs,omitempty"` |
| 164 | V int `json:"v"` |
| 165 | Host string `json:"host"` |
| 166 | Gen uint64 `json:"gen"` |
| 167 | Holder *Holder `json:"holder,omitempty"` |
| 168 | Op Op `json:"op"` |
| 169 | Touched Touched `json:"touched"` |
| 170 | Inventory map[string]InventoryEntry `json:"inventory,omitempty"` |
| 171 | Obligations []Obligation `json:"obligations,omitempty"` |
| 172 | Lost []string `json:"lost,omitempty"` |
| 173 | LostRevisions map[string]uint64 `json:"lost_revisions,omitempty"` |
| 174 | LossRefs []string `json:"loss_refs,omitempty"` |
| 175 | RetiredIncarnations []string `json:"retired_incarnations,omitempty"` |
| 176 | QuietAfter map[string]time.Time `json:"quiet_after,omitempty"` |
| 177 | CancelledCreates []CancelledCreate `json:"cancelled_creates,omitempty"` |
| 178 | } |
| 179 | |
| 180 | // Free reports whether the host has no holder and no operation. |
| 181 | func (c *Claim) Free() bool { return c.Holder == nil && c.Op.Kind == "" } |
| 182 | |
| 183 | func Key(host string) (string, error) { |
| 184 | if !controlname.ValidHostShape(host) { |
| 185 | return "", ErrInvalidHost |
| 186 | } |
| 187 | return "control/hosts/" + host + ".json", nil |
| 188 | } |
| 189 | |
| 190 | // Incarnation is §2's identity. A record with a zero CreatedAt (written |
| 191 | // before CreatedAt existed) gets the domain-separated legacy identity. |
| 192 | func Incarnation(account, app, host string, created time.Time) (string, error) { |
| 193 | if created.IsZero() { |
| 194 | if !controlname.ValidAccount(account) || !controlname.ValidApp(app) || !controlname.ValidHostShape(host) { |
| 195 | return "", ErrMalformed |
| 196 | } |
| 197 | sum := sha256.Sum256([]byte("carlos-legacy-incarnation-v1\x00" + account + "\x00" + app + "\x00" + host)) |
| 198 | return hex.EncodeToString(sum[:]), nil |
| 199 | } |
| 200 | return maintenanceguard.RecordIdentity(account, app, host, created) |
| 201 | } |
| 202 | |
| 203 | // sidecarSuffix restates provision.SidecarSuffix (no import: avoids a cycle). |
| 204 | const sidecarSuffix = ".sidecar.internal" |
| 205 | |
| 206 | // holderKeyMatches is the one identity check for a holder's record key and |
| 207 | // host, shared by Validate, BeginCreate and Adopt. An instance's key is |
| 208 | // exactly apps/<acct>/<app>/instances/<host>.json. A sidecar's key is |
| 209 | // apps/<acct>/<app>/sidecars/<name>.json, and its host is exactly |
| 210 | // <name>.<app>.<acct>.sidecar.internal (provision.SidecarHost). |
| 211 | func holderKeyMatches(family, key, account, app, name, host string) bool { |
| 212 | base := "apps/" + account + "/" + app + "/" |
| 213 | switch family { |
| 214 | case "instance": |
| 215 | return name == "" && key == base+"instances/"+host+".json" |
| 216 | case "sidecar": |
| 217 | return controlname.ValidLabel(name) && key == base+"sidecars/"+name+".json" && |
| 218 | host == name+"."+app+"."+account+sidecarSuffix |
| 219 | } |
| 220 | return false |
| 221 | } |
| 222 | |
| 223 | func isHex64(s string) bool { |
| 224 | if len(s) != 64 { |
| 225 | return false |
| 226 | } |
| 227 | _, err := hex.DecodeString(s) |
| 228 | return err == nil |
| 229 | } |
| 230 | |
| 231 | func (c *Claim) Validate() error { |
| 232 | if c != nil && !originHeadValid(c.Origins) { |
| 233 | return ErrMalformed |
| 234 | } |
| 235 | if c == nil || c.V != schemaVersion || !controlname.ValidHostShape(c.Host) || c.Gen == 0 { |
| 236 | return fmt.Errorf("%w: header", ErrMalformed) |
| 237 | } |
| 238 | if err := validateRetainedAdmissionLosses(c); err != nil { |
| 239 | return err |
| 240 | } |
| 241 | if err := validateRelocating(c); err != nil { |
| 242 | return err |
| 243 | } |
| 244 | if c.Op.Kind != "" && !knownKinds[c.Op.Kind] { |
| 245 | return fmt.Errorf("%w: unknown op kind %q", ErrMalformed, c.Op.Kind) |
| 246 | } |
| 247 | if c.Op.Kind != "" && c.Op.Kind != KindServing && c.Op.ID == "" { |
| 248 | return fmt.Errorf("%w: op %q without id", ErrMalformed, c.Op.Kind) |
| 249 | } |
| 250 | if c.Holder == nil { |
| 251 | if c.Op.Kind != "" && c.Op.Kind != KindPurging && !holderlessSweep(c) { // a purge fence has no holder (CC21) |
| 252 | return fmt.Errorf("%w: op %q without holder", ErrMalformed, c.Op.Kind) |
| 253 | } |
| 254 | if len(c.Obligations) > 0 && !holderlessSweep(c) { |
| 255 | return fmt.Errorf("%w: a free claim with outstanding obligations", ErrMalformed) |
| 256 | } |
| 257 | } else { |
| 258 | h := c.Holder |
| 259 | if (h.Family != "instance" && h.Family != "sidecar") || h.Key == "" || !isHex64(h.Incarnation) || |
| 260 | !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.App) { |
| 261 | return fmt.Errorf("%w: holder identity", ErrMalformed) |
| 262 | } |
| 263 | if !holderKeyMatches(h.Family, h.Key, h.Account, h.App, h.Name, c.Host) { |
| 264 | return fmt.Errorf("%w: holder key %q is not this host's", ErrMalformed, h.Key) |
| 265 | } |
| 266 | if want, err := Incarnation(h.Account, h.App, c.Host, h.CreatedAt); err != nil || want != h.Incarnation { |
| 267 | return fmt.Errorf("%w: incarnation does not match the holder's creation fields", ErrMalformed) |
| 268 | } |
| 269 | legacy, scoped := "instances/"+c.Host, "instances/"+c.Host+"/"+h.Incarnation |
| 270 | if h.Location != legacy && h.Location != scoped { |
| 271 | return fmt.Errorf("%w: holder location %q", ErrMalformed, h.Location) |
| 272 | } |
| 273 | if c.Op.Kind == "" { |
| 274 | return fmt.Errorf("%w: holder without op", ErrMalformed) |
| 275 | } |
| 276 | for _, record := range h.Admissions { |
| 277 | if record.Region == "" || !validWriteLosses(record.WritesLost, record.AdditionalWritesLost) { |
| 278 | return fmt.Errorf("%w: invalid admission", ErrMalformed) |
| 279 | } |
| 280 | if record.LossID != "" && !safeLossID(record.LossID) { |
| 281 | return fmt.Errorf("%w: invalid loss declaration id", ErrMalformed) |
| 282 | } |
| 283 | if record.LossArchivedClaimGen != 0 && (!admissionHasLoss(record) || record.LossArchivedClaimGen != record.Session) { |
| 284 | return fmt.Errorf("%w: invalid archived admission loss", ErrMalformed) |
| 285 | } |
| 286 | } |
| 287 | if err := h.WakeRetry.validate(); err != nil { |
| 288 | return err |
| 289 | } |
| 290 | if h.WakeRetry != nil && h.WakeRetry.State == "attempting" && h.WakeRetry.Attempt.Session != 0 && |
| 291 | (h.WakeRetry.Attempt.Session != h.Session || h.WakeRetry.Attempt.Box != h.WriterBox) { |
| 292 | return fmt.Errorf("%w: wake retry attempt is not the live writer session", ErrMalformed) |
| 293 | } |
| 294 | } |
| 295 | lost := set(c.Lost) |
| 296 | for box, revision := range c.LostRevisions { |
| 297 | if box == "" || revision == 0 || !lost[box] { |
| 298 | return fmt.Errorf("%w: invalid lost revision", ErrMalformed) |
| 299 | } |
| 300 | } |
| 301 | seenLossRefs := map[string]bool{} |
| 302 | for _, id := range c.LossRefs { |
| 303 | if !safeLossID(id) || seenLossRefs[id] { |
| 304 | return fmt.Errorf("%w: invalid loss references", ErrMalformed) |
| 305 | } |
| 306 | seenLossRefs[id] = true |
| 307 | } |
| 308 | if err := validateAutomaticLosses(c, seenLossRefs); err != nil { |
| 309 | return err |
| 310 | } |
| 311 | for inc, location := range c.RetiredLocations { |
| 312 | if !isHex64(inc) || !stringPresent(c.RetiredIncarnations, inc) || (location != "instances/"+c.Host && location != "instances/"+c.Host+"/"+inc) { |
| 313 | return ErrMalformed |
| 314 | } |
| 315 | } |
| 316 | if len(c.RetiredIncarnations) > retiredCap { |
| 317 | return fmt.Errorf("%w: retired_incarnations over cap", ErrMalformed) |
| 318 | } |
| 319 | seen := map[string]bool{} |
| 320 | for _, o := range c.Obligations { |
| 321 | if o.ID == "" || o.Op == "" || o.Retirement == "" || seen[o.ID] { |
| 322 | return fmt.Errorf("%w: obligation ids", ErrMalformed) |
| 323 | } |
| 324 | if o.Park != nil { |
| 325 | if o.Kind != "park-replica" && o.Kind != "park-generation" { |
| 326 | return fmt.Errorf("%w: park scope", ErrMalformed) |
| 327 | } |
| 328 | if err := o.Park.Validate(c.Host); err != nil { |
| 329 | return err |
| 330 | } |
| 331 | } |
| 332 | seen[o.ID] = true |
| 333 | switch o.Kind { |
| 334 | case "relocation-release": |
| 335 | if o.Region == "" || !validBoxReference(o.Box) || o.Box == "none" || o.Generation != "" || o.ParkTarget != "" || o.Owner != "region:"+o.Region { |
| 336 | return fmt.Errorf("%w: relocation-release identity", ErrMalformed) |
| 337 | } |
| 338 | case "park-generation": |
| 339 | if o.Region == "" || o.Generation == "" || o.Box != "" || !validRegionalOwner(o.Owner, o.Region) { |
| 340 | return fmt.Errorf("%w: park-generation %q location/generation/owner", ErrMalformed, o.ID) |
| 341 | } |
| 342 | case "park-replica": |
| 343 | if o.Region == "" || o.Box != "" || o.Generation != "" || !validRegionalOwner(o.Owner, o.Region) { |
| 344 | return fmt.Errorf("%w: park-replica %q location/owner", ErrMalformed, o.ID) |
| 345 | } |
| 346 | case "park-local": |
| 347 | if o.Box == "" || o.Region != "" || o.Generation != "" || o.Owner != "box:"+o.Box { |
| 348 | return fmt.Errorf("%w: park-local %q location/owner", ErrMalformed, o.ID) |
| 349 | } |
| 350 | default: |
| 351 | return fmt.Errorf("%w: obligation kind %q", ErrMalformed, o.Kind) |
| 352 | } |
| 353 | } |
| 354 | return nil |
| 355 | } |
| 356 | |
| 357 | func holderlessSweep(c *Claim) bool { |
| 358 | if c.Holder != nil || c.Op.Kind != KindParking || c.Op.ID == "" || c.Op.Intent["park_sweep"] != "v1" || c.Op.Exec != "" || len(c.Obligations) == 0 { |
| 359 | return false |
| 360 | } |
| 361 | for _, o := range c.Obligations { |
| 362 | if o.Op != c.Op.ID || o.Park == nil || (o.Kind != "park-replica" && o.Kind != "park-generation") { |
| 363 | return false |
| 364 | } |
| 365 | } |
| 366 | return true |
| 367 | } |
| 368 | |