carlos / platform Public

plan: host lifecycle claim, Plan 1 of 8 (the claim library) and the roadmap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 066b31cf01244b2559f8ad758e61dc5472a0ca6a parent d36adcf
1 file changed, +1314 −0
  • docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md +1314 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md b/docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md
new file mode 100644
index 000000000..369e88383
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md
@@ -0,0 +1,1314 @@
+# Host Lifecycle Claim, Plan 1: the claim library
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A pure Go package, `internal/hostclaim`, that reads, validates
+and conditionally writes `control/hosts/<host>.json`. It implements the
+claim's pure state transitions: create, cancel, delete, park, free,
+adopt, start-operation guards and the orphan rule. Nothing in it is wired
+into the console or the boxes yet.
+
+**Architecture:**
+- The claim is one JSON object per host in the control bucket. It is
+ written through `release.Store.Put` with an ETag condition, and
+ `If-None-Match` only for a host's very first claim.
+- Every transition is a pure function `func(*Claim) error`, applied by
+ one `Update` helper. `Update` re-reads the claim, applies the
+ transition, increments `gen` and writes conditionally.
+- Callers outside this package never construct a write themselves.
+
+**Tech Stack:** Go (the repo's version), `internal/release` (Store,
+ErrCASConflict, DirStore for tests), `internal/maintenanceguard`
+(RecordIdentity), `internal/controlname` (validators). No new
+dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`
+rev 6.5 (d36adcfa). Read §2 (the object), §3 (C1-C6, C5a), §4, §5.1,
+§5.2 and §9 before starting. Joint round 6 judged the spec ready for
+implementation planning.
+
+## Roadmap (this plan is 1 of 8)
+
+| Plan | Scope (spec §) | Depends on |
+|---|---|---|
+| **1. Claim library** (this) | §2, C1-C6, C5a, §4 guards, §5.1/§5.2 state transitions, §9 adoption, the orphan rule | none |
+| 2. Versioned storage primitives | `ListObjectVersions` paging, copy and delete by version id, `DeleteObject If-Match`, on the data-bucket client; IAM grants (§6.2, §10) | none |
+| 3. Park engine | §6.2 archive and deletion sets, version-keyed destinations, the manifest; §6.2a horizon and `quiet_after`; §6.3 local retirement with inventory and journal | 1, 2 |
+| 4. Box-local guard and stop evidence | §6.4 flock guard, installation stamps and local record; §6.1 strict containment and evidence reconstruction | 1 |
+| 5. Console operations | create, delete and `RetireRecord` (every call site), admission.json, purge journal and fence, placement, the claim-recovery pass (§5, §8) | 1, 3 |
+| 6. Box wiring | reconcile admission and retirement, wake admission (C4), the at-rest sweep (§6.2a), adoption on the box (§9) | 1, 3, 4 |
+| 7. Pull door | O1/O2 bracketing and version-pinned handles (§6.5) | 1, 2 |
+| 8. Execution leases and lost boxes | §7 leases, self-fence, `carlos ops host resolve --declare-lost` with compute fencing, quarantine; the floor on every restore path (§10) | 1, 4 |
+
+Each later plan is written against the code this plan lands, after it
+merges. Moves (`design-moves-any-region`) builds its operations on Plan
+1's `Update` and transitions.
+
+## Global Constraints
+
+- **Object key:** `control/hosts/<host>.json`. The host must pass
+ `controlname.ValidHostShape`.
+- **Never delete a claim object (C5a).** No function in this package
+ deletes. "Free" is a written state. `If-None-Match` (Put with
+ `ifMatch == ""`) is used only when the host has **never** had a claim.
+- **Every write increments `gen` by exactly 1** and is conditional on
+ the ETag read (§2). Callers never set `gen`.
+- **Every transition after an operation starts is conditional on
+ `op.id` (C2).** A stale finalizer must get `ErrStaleOp`, never succeed.
+- **Read errors never act (C6).** A failed or malformed read returns an
+ error. Only `release` not-found counts as absent.
+- **`retired_incarnations` keeps the newest 32**, and a create whose
+ incarnation is listed is refused (§2, R3-12).
+- **`holder.stamp`** is written by every CAS that makes an installation
+ current, set to that CAS's resulting `gen`. In this plan, those CASes
+ are `CompleteCreate` and `Adopt`.
+- **Copy:** this package has no user-facing strings. Error values are for
+ callers, which map them to copy-reviewed text in later plans.
+- **Go cache in this repo's sandbox:** `export GOCACHE=$TMPDIR/gocache`.
+
+## Review Focus
+
+- **A claim body with unknown or extra fields.** Strict decoding must
+ return `ErrMalformed`, never a half-read claim that a caller then acts
+ on (Task 2).
+- **The same transition applied twice** (a retry after an ambiguous
+ write). The second attempt must fail cleanly with `ErrStaleOp` or
+ `ErrWrongState` and never double-increment or double-append (Tasks 4
+ and 5).
+- **A legacy record with zero CreatedAt.** Its incarnation is the
+ domain-separated legacy identity. It is stable across calls and
+ distinct per account, app and host (Task 1).
+- **The 33rd retired incarnation.** The oldest is dropped. The newest 32
+ still refuse reuse (Task 5).
+- **A free claim recreated.** `BeginCreate` against a free claim uses
+ `If-Match`, never `If-None-Match`, so a stale actor holding "absent"
+ loses (Task 4).
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `internal/hostclaim/claim.go` | Types, the key, `Validate`, the incarnation function |
+| `internal/hostclaim/store.go` | `Read` and `Update`, the only I/O |
+| `internal/hostclaim/create.go` | `BeginCreate`, `CompleteCreate`, `CancelCreate` |
+| `internal/hostclaim/delete.go` | `BeginDelete`, `ToParking`, `CompleteObligation`, `Free` |
+| `internal/hostclaim/adopt.go` | `Adopt` |
+| `internal/hostclaim/orphan.go` | `IsOrphan` |
+| `internal/hostclaim/*_test.go` | One test file per source file, all using `release.NewDirStore(t.TempDir())` |
+
+---
+
+### Task 1: Types, key, validation and incarnation
+
+**Files:**
+- Create: `internal/hostclaim/claim.go`
+- Test: `internal/hostclaim/claim_test.go`
+
+**Interfaces:**
+- Produces:
+ - `type Claim struct`, `Holder`, `Op`, `Touched`, `Obligation`,
+ `CancelledCreate`;
+ - `func Key(host string) (string, error)`;
+ - `func (c *Claim) Validate() error`;
+ - `func Incarnation(account, app, host string, created time.Time) (string, error)`;
+ - the constants `KindServing`, `KindCreating`, `KindDeleting`,
+ `KindParking`, `KindPurging`, `KindPlacing`;
+ - the errors `ErrMalformed`, `ErrInvalidHost`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package hostclaim
+
+import (
+ "testing"
+ "time"
+)
+
+func TestKeyRefusesBadHosts(t *testing.T) {
+ if k, err := Key("shop.bab.oncarlos.com"); err != nil || k != "control/hosts/shop.bab.oncarlos.com.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", "shop.bab.oncarlos.com", at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a2, _ := Incarnation("bab", "jam", "shop.bab.oncarlos.com", at)
+ if a1 != a2 || len(a1) != 64 {
+ t.Fatalf("not stable 64-hex: %q %q", a1, a2)
+ }
+ l1, err := Incarnation("bab", "jam", "shop.bab.oncarlos.com", time.Time{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ l2, _ := Incarnation("bab", "other", "shop.bab.oncarlos.com", time.Time{})
+ if l1 == a1 || l1 == l2 || len(l1) != 64 {
+ t.Fatalf("legacy identity not distinct: %q %q %q", l1, l2, a1)
+ }
+}
+
+func TestValidateRejectsHolderlessServing(t *testing.T) {
+ c := &Claim{V: 1, Host: "shop.bab.oncarlos.com", Gen: 1, Op: Op{Kind: KindServing}}
+ if err := c.Validate(); err == nil {
+ t.Fatal("serving with no holder validated")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/hostclaim/ -run 'TestKey|TestIncarnation|TestValidate' -count=1`
+Expected: FAIL, with "undefined: Key".
+
+- [ ] **Step 3: Write the minimal implementation**
+
+```go
+// 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"
+ // Move kinds (moving, landing, reverting, restoring) and pushing are
+ // owned by their own specs; Validate accepts them by name.
+ retiredCap = 32
+)
+
+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"`
+ 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"`
+}
+
+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"`
+}
+
+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][]string `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, so
+// every deletion is recordable.
+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)
+}
+
+func (c *Claim) Validate() error {
+ if c == nil || c.V != 1 || !controlname.ValidHostShape(c.Host) || c.Gen == 0 {
+ return fmt.Errorf("%w: header", ErrMalformed)
+ }
+ free := c.Holder == nil
+ if free && c.Op.Kind != "" && c.Op.Kind != KindPurging {
+ return fmt.Errorf("%w: op %q without holder", ErrMalformed, c.Op.Kind)
+ }
+ if !free {
+ h := c.Holder
+ if h.Incarnation == "" || h.Key == "" || h.Location == "" || (h.Family != "instance" && h.Family != "sidecar") {
+ return fmt.Errorf("%w: holder", ErrMalformed)
+ }
+ if c.Op.Kind == "" {
+ return fmt.Errorf("%w: holder without op", ErrMalformed)
+ }
+ }
+ if c.Op.Kind != "" && c.Op.Kind != KindServing && c.Op.ID == "" {
+ return fmt.Errorf("%w: op %q without id", ErrMalformed, c.Op.Kind)
+ }
+ if len(c.RetiredIncarnations) > retiredCap {
+ return fmt.Errorf("%w: retired_incarnations over cap", ErrMalformed)
+ }
+ return nil
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/hostclaim/ -run 'TestKey|TestIncarnation|TestValidate' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/hostclaim/claim.go internal/hostclaim/claim_test.go
+git commit -m "hostclaim: the claim object, its key, validation and incarnation"
+```
+
+---
+
+### Task 2: Read and Update, the only I/O
+
+**Files:**
+- Create: `internal/hostclaim/store.go`
+- Test: `internal/hostclaim/store_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `Claim`, `Key` and `Validate`; `release.Store`;
+ `release.ErrCASConflict`.
+- Produces:
+ - `type Observed struct { Claim *Claim; ETag string; Absent bool }`;
+ - `func Read(ctx context.Context, s release.Store, host string) (Observed, error)`;
+ - `func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error)`;
+ - the errors `ErrConflict` (wraps `release.ErrCASConflict`) and
+ `ErrNeverClaimed`.
+
+`Update` applies `tr` to a **copy** of `obs.Claim`. For an absent
+observation, it starts from a fresh `&Claim{V:1, Host: host}`. It then
+sets `Gen = old+1`, validates, and writes:
+- if `obs.Absent`: `Put(..., ifMatch="")`, create-only;
+- otherwise: `Put(..., ifMatch=obs.ETag)`.
+
+A lost race returns `ErrConflict`, and the caller re-reads. `Update`
+never retries by itself: re-reading and re-deciding is the caller's job
+(C2).
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package hostclaim
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const testHost = "shop.bab.oncarlos.com"
+
+func TestReadAbsentAndMalformed(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, err := Read(ctx, s, testHost)
+ if err != nil || !obs.Absent {
+ t.Fatalf("absent read = %+v, %v", obs, err)
+ }
+ key, _ := Key(testHost)
+ if _, err := s.Put(ctx, key, []byte(`{"v":1,"host":"shop.bab.oncarlos.com","gen":1,"op":{},"touched":{},"surprise":true}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Read(ctx, s, testHost); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("unknown field read = %v, want ErrMalformed", err)
+ }
+}
+
+func TestUpdateIncrementsGenAndLosesRaces(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, _ := Read(ctx, s, testHost)
+ setFree := func(c *Claim) error { c.Host = testHost; return nil } // a free claim is a valid first write
+ o1, err := Update(ctx, s, obs, setFree)
+ if err != nil || o1.Claim.Gen != 1 {
+ t.Fatalf("first write = %+v, %v", o1, err)
+ }
+ // A second actor still holding the absent observation must lose (C5a).
+ if _, err := Update(ctx, s, obs, setFree); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale absent write = %v, want ErrConflict", err)
+ }
+ o2, err := Update(ctx, s, o1, setFree)
+ if err != nil || o2.Claim.Gen != 2 {
+ t.Fatalf("second write = %+v, %v", o2, err)
+ }
+ if _, err := Update(ctx, s, o1, setFree); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale etag write = %v, want ErrConflict", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/hostclaim/ -run 'TestRead|TestUpdate' -count=1`
+Expected: FAIL, with "undefined: Read".
+
+- [ ] **Step 3: Write the minimal implementation**
+
+```go
+package hostclaim
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var (
+ ErrConflict = fmt.Errorf("hostclaim: claim changed since it was read: %w", release.ErrCASConflict)
+ ErrNeverClaimed = errors.New("hostclaim: host has never had a claim")
+)
+
+type Observed struct {
+ Claim *Claim
+ ETag string
+ Absent bool
+}
+
+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{Absent: true}, nil
+ }
+ if err != nil {
+ return Observed{}, err // C6: an error is never absence
+ }
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.DisallowUnknownFields()
+ var c Claim
+ if err := dec.Decode(&c); err != nil {
+ return Observed{}, fmt.Errorf("%w: %v", ErrMalformed, err)
+ }
+ if c.Host != host {
+ return Observed{}, fmt.Errorf("%w: host mismatch", ErrMalformed)
+ }
+ if err := c.Validate(); err != nil {
+ return Observed{}, err
+ }
+ return Observed{Claim: &c, ETag: etag}, nil
+}
+
+func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error) {
+ var next Claim
+ if obs.Absent {
+ next = Claim{V: 1}
+ } else {
+ raw, err := json.Marshal(obs.Claim)
+ if err != nil {
+ return Observed{}, err
+ }
+ if err := json.Unmarshal(raw, &next); err != nil {
+ return Observed{}, err
+ }
+ }
+ host := next.Host
+ if err := tr(&next); err != nil {
+ return Observed{}, err
+ }
+ if host != "" && next.Host != host {
+ return Observed{}, fmt.Errorf("%w: transition changed host", ErrMalformed)
+ }
+ next.Gen++
+ if err := next.Validate(); err != nil {
+ return Observed{}, err
+ }
+ key, err := Key(next.Host)
+ if err != nil {
+ return Observed{}, err
+ }
+ body, err := json.Marshal(&next)
+ if err != nil {
+ return Observed{}, err
+ }
+ ifMatch := obs.ETag
+ if obs.Absent {
+ ifMatch = ""
+ }
+ 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{Claim: &next, ETag: etag}, nil
+}
+```
+
+For an absent observation, the transition sets `Host`, as `setFree`
+does in the test.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/hostclaim/ -run 'TestRead|TestUpdate' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/hostclaim/store.go internal/hostclaim/store_test.go
+git commit -m "hostclaim: Read (strict, errors never absent) and Update (gen+1, conditional, never delete)"
+```
+
+---
+
+### Task 3: Operation guards
+
+**Files:**
+- Create: `internal/hostclaim/ops.go`
+- Test: `internal/hostclaim/ops_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `Claim` and `Obligation`.
+- Produces:
+ - `func RequireOp(id string) func(*Claim) error`;
+ - `func StartGuard(c *Claim, regions, boxes []string) error`;
+ - the errors `ErrStaleOp`, `ErrWrongState`, `ErrPendingChore`.
+
+`RequireOp` is composed into every post-start transition (C2).
+`StartGuard` enforces one operation at a time (§4) and CC11: no new
+operation may start over an obligation that names a region or box the
+operation will write.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+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)
+ }
+}
+
+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)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/hostclaim/ -run 'TestRequireOp|TestStartGuard' -count=1`
+Expected: FAIL, with "undefined: RequireOp".
+
+- [ ] **Step 3: Write the minimal implementation**
+
+```go
+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 c.Op.ID != id {
+ return fmt.Errorf("%w: have %q, want %q", ErrStaleOp, c.Op.ID, id)
+ }
+ return nil
+ }
+}
+
+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 := map[string]bool{}
+ for _, r := range regions {
+ inR[r] = true
+ }
+ inB := map[string]bool{}
+ for _, b := range boxes {
+ inB[b] = true
+ }
+ 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
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/hostclaim/ -run 'TestRequireOp|TestStartGuard' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/hostclaim/ops.go internal/hostclaim/ops_test.go
+git commit -m "hostclaim: op-id guard (C2) and the start guard (one op, CC11)"
+```
+
+---
+
+### Task 4: Create, complete and cancel (§5.1, CC22, R3-12)
+
+**Files:**
+- Create: `internal/hostclaim/create.go`
+- Test: `internal/hostclaim/create_test.go`
+
+**Interfaces:**
+- Consumes: `Update`, `Observed`, `RequireOp`, `Incarnation`.
+- Produces:
+ - `type NewHolder struct { Family, Key, Account, App, Name, Host, Region string; CreatedAt time.Time }`;
+ - `func BeginCreate(nh NewHolder, opID, by string, now time.Time) func(*Claim) error`;
+ - `func CompleteCreate(opID, execBox string) func(*Claim) error`;
+ - `func CancelCreate(opID string, now time.Time) func(*Claim) error`;
+ - `ErrRetiredIncarnation`.
+
+The caller applies these through `Update`, using the observation it took
+at registration time (CC22): `Update` uses `If-Match` that ETag, or
+`If-None-Match` if it was absent. Any conflict means restarting from
+registration.
+- `BeginCreate` refuses unless the claim is free (or absent). It also
+ refuses if the incarnation is in `RetiredIncarnations`.
+- It sets `holder` with `location = instances/<host>/<incarnation>`, and
+ `op = {creating, id, intent: {key}}`.
+- `CompleteCreate` sets `op = {serving}`, `exec`, and
+ `holder.stamp = Gen+1`. That is the gen this write will produce,
+ because `Update` increments after the transition.
+- `CancelCreate` frees the claim, retires the incarnation (keeping the
+ newest 32) and adds a `cancelled_creates` entry with `until = now+1h`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package hostclaim
+
+import (
+ "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",
+ CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)}
+}
+
+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) // the registration-time observation
+ o, err := Update(ctx, s, reg, BeginCreate(newHolder(), "op_c1", "console", now))
+ if err != nil || o.Claim.Op.Kind != KindCreating {
+ 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.Holder.Stamp != o.Claim.Gen {
+ t.Fatalf("complete = %+v, %v", o.Claim, err)
+ }
+ // A replayed CompleteCreate is refused, never applied twice.
+ if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); !errors.Is(err, ErrStaleOp) && !errors.Is(err, ErrWrongState) {
+ t.Fatalf("replay = %v", err)
+ }
+ // The stale registration-time observation (absent) can never win (CC22, C5a).
+ if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), "op_c2", "console", now)); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale create = %v, want ErrConflict", 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(), "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(), "op_c3", "console", now)); !errors.Is(err, ErrRetiredIncarnation) {
+ t.Fatalf("reuse = %v, want ErrRetiredIncarnation", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/hostclaim/ -run 'TestCreate|TestCancel' -count=1`
+Expected: FAIL, with "undefined: BeginCreate".
+
+- [ ] **Step 3: Write the minimal implementation**
+
+```go
+package hostclaim
+
+import (
+ "errors"
+ "fmt"
+ "time"
+)
+
+var ErrRetiredIncarnation = errors.New("hostclaim: incarnation was retired on this host")
+
+type NewHolder struct {
+ Family, Key, Account, App, Name, Host, Region string
+ CreatedAt time.Time
+}
+
+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, opID, by string, now time.Time) func(*Claim) error {
+ return func(c *Claim) error {
+ if c.Host == "" {
+ c.Host = nh.Host
+ }
+ if !c.Free() {
+ return fmt.Errorf("%w: host is held", ErrWrongState)
+ }
+ if nh.CreatedAt.IsZero() {
+ return fmt.Errorf("%w: create needs a CreatedAt", ErrMalformed)
+ }
+ 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}}
+ 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 {
+ return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
+ }
+ c.Op = Op{Kind: KindServing, Exec: execBox}
+ c.Holder.Stamp = c.Gen + 1 // the gen this write produces (Update increments after)
+ 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)
+ }
+ 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
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/hostclaim/ -run 'TestCreate|TestCancel' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/hostclaim/create.go internal/hostclaim/create_test.go
+git commit -m "hostclaim: create, complete (holder.stamp) and cancel (retired, cancelled_creates)"
+```
+
+---
+
+### Task 5: Delete, parking, obligations and free (§5.2)
+
+**Files:**
+- Create: `internal/hostclaim/delete.go`
+- Test: `internal/hostclaim/delete_test.go`
+
+**Interfaces:**
+- Consumes: `Update`, `RequireOp`, `StartGuard`, `retire`.
+- Produces:
+ - `func BeginDelete(opID, by, recordETag string, now time.Time) func(*Claim) error`;
+ - `func ToParking(opID string, allRegions, allBoxes []string, newID func() string) func(*Claim) error`;
+ - `func CompleteObligation(opID, obligationID string) func(*Claim) error`;
+ - `func FreeAfterPark(opID string) func(*Claim) error`.
+
+The rules:
+- `BeginDelete` passes `StartGuard`, then records
+ `intent{key, etag}`.
+- `ToParking` writes one `park-replica` obligation per touched region and
+ one `park-local` per touched box. When `touched.complete` is false it
+ uses `allRegions` and `allBoxes` instead (§5.2 step 3). Each
+ obligation gets a fresh retirement id from `newID`. It also retires the
+ incarnation.
+- `FreeAfterPark` refuses while any obligation remains. It clears
+ holder, op, touched and inventory, and keeps `RetiredIncarnations`,
+ `QuietAfter`, `CancelledCreates` and `Lost`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package hostclaim
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+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, _ := Read(ctx, s, testHost)
+ o, err := Update(ctx, s, reg, BeginCreate(newHolder(), "op_c1", "console", now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return o
+}
+
+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)
+ o, err := Update(ctx, s, o, func(c *Claim) error { c.Touched.Boxes = []string{"adi-knitting"}; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ inc := o.Claim.Holder.Incarnation
+ o, err = Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now))
+ if err != nil || o.Claim.Op.Kind != KindDeleting || o.Claim.Op.Intent["etag"] != `"etag1"` {
+ t.Fatalf("begin delete = %+v, %v", o.Claim, err)
+ }
+ n := 0
+ ids := func() string { n++; return fmt.Sprintf("rt_%d", n) }
+ o, err = Update(ctx, s, o, ToParking("op_d1", nil, nil, ids))
+ 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("op_d1", ob.ID)); err != nil {
+ t.Fatal(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, _ = Update(ctx, s, o, func(c *Claim) error { c.Touched.Complete = false; return nil })
+ o, _ = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now))
+ n := 0
+ ids := func() string { n++; return fmt.Sprintf("rt_%d", n) }
+ o, err := Update(ctx, s, o, ToParking("op_d1", []string{"ie", "uk", "us"}, []string{"b1", "b2"}, ids))
+ if err != nil || len(o.Claim.Obligations) != 5 {
+ t.Fatalf("incomplete touched = %d obligations, %v; want 3 regions + 2 boxes", len(o.Claim.Obligations), 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)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/hostclaim/ -run 'TestDelete|TestIncomplete|TestRetiredCap' -count=1`
+Expected: FAIL, with "undefined: BeginDelete".
+
+- [ ] **Step 3: Write the minimal implementation**
+
+```go
+package hostclaim
+
+import (
+ "fmt"
+ "time"
+)
+
+func BeginDelete(opID, by, recordETag string, now time.Time) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := StartGuard(c, nil, nil); err != nil {
+ return err
+ }
+ c.Op = Op{Kind: KindDeleting, ID: opID, By: by, StartedAt: now.UTC(),
+ 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
+ }
+ if c.Op.Kind != KindDeleting && c.Op.Kind != KindPurging {
+ return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
+ }
+ regions, boxes := c.Touched.Regions, c.Touched.Boxes
+ if !c.Touched.Complete {
+ regions, boxes = allRegions, allBoxes
+ }
+ for _, r := range regions {
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID()})
+ }
+ for _, b := range boxes {
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID()})
+ }
+ retire(c, c.Holder.Incarnation)
+ c.Op.Kind = KindParking
+ return nil
+ }
+}
+
+func CompleteObligation(opID, obligationID string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ for i, o := range c.Obligations {
+ if o.ID == obligationID {
+ c.Obligations = append(c.Obligations[:i], c.Obligations[i+1:]...)
+ return nil
+ }
+ }
+ return fmt.Errorf("%w: no obligation %q", ErrWrongState, obligationID)
+ }
+}
+
+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
+ }
+}
+```
+
+`ToParking` accepts `KindPurging` because §5.3 step 4 retires records
+under the purge's own claim (R4-4). Plan 5 adds the purge journal's
+preconditions around it.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/hostclaim/ -run 'TestDelete|TestIncomplete|TestRetiredCap' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/hostclaim/delete.go internal/hostclaim/delete_test.go
+git commit -m "hostclaim: delete, parking obligations, completion and free (keeps anti-reuse history)"
+```
+
+---
+
+### Task 6: Adoption (§9, CC4, CC8)
+
+**Files:**
+- Create: `internal/hostclaim/adopt.go`
+- Test: `internal/hostclaim/adopt_test.go`
+
+**Interfaces:**
+- Consumes: `Update`, `Incarnation`, `NewHolder`.
+- Produces: `func Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, opID string, newID func() string) func(*Claim) error`.
+
+`Adopt` runs **only against an absent observation**: a host that never
+had a claim. Under C5a, that is the only case where a record without a
+claim can exist. It writes:
+- the holder, with the **legacy** `location = instances/<host>`;
+- `op = {serving, exec: servingBox}`;
+- `holder.stamp = Gen+1`;
+- `touched = {complete: false, regions: [nh.Region], boxes: [servingBox]}`;
+- a `park-replica` obligation for each of `otherRegions`, and a
+ `park-local` for each of `otherBoxes`, all under `opID` (CC4).
+
+The caller passes every deployment region and box other than the
+record's region and the serving box.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package hostclaim
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAdoptLegacy(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ n := 0
+ ids := func() string { n++; return fmt.Sprintf("id%d", n) }
+ nh := newHolder()
+ obs, _ := Read(ctx, s, testHost)
+ o, err := Update(ctx, s, obs, Adopt(nh, "adi-knitting", []string{"uk", "us"}, []string{"b2"}, "op_adopt", ids))
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := o.Claim
+ if c.Holder.Location != "instances/"+testHost || c.Touched.Complete || c.Holder.Stamp != c.Gen || len(c.Obligations) != 3 {
+ t.Fatalf("adopt = %+v", c)
+ }
+ // Adoption against an existing claim is refused: only a never-claimed host adopts.
+ if _, err := Update(ctx, s, o, Adopt(nh, "adi-knitting", nil, nil, "op_adopt2", ids)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("re-adopt = %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/hostclaim/ -run TestAdopt -count=1`
+Expected: FAIL, with "undefined: Adopt".
+
+- [ ] **Step 3: Write the minimal implementation**
+
+```go
+package hostclaim
+
+import "fmt"
+
+func Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, opID string, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if c.Host != "" || c.Gen != 0 {
+ return fmt.Errorf("%w: adoption is only for a host that never had a claim", ErrWrongState)
+ }
+ c.Host = nh.Host
+ 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}
+ c.Op = Op{Kind: KindServing, Exec: servingBox}
+ c.Touched = Touched{Complete: false, Regions: []string{nh.Region}, Boxes: []string{servingBox}}
+ for _, r := range otherRegions {
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID()})
+ }
+ for _, b := range otherBoxes {
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID()})
+ }
+ return nil
+ }
+}
+```
+
+Validation: a `serving` claim with discovery obligations is legal, so no
+change to `Validate` is needed. Plan 6's sweep and park engine complete
+those obligations, and set `touched.complete = true` once all have
+acknowledged.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/hostclaim/ -run TestAdopt -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/hostclaim/adopt.go internal/hostclaim/adopt_test.go
+git commit -m "hostclaim: one adoption routine with discovery obligations (CC4, CC8)"
+```
+
+---
+
+### Task 7: The orphan rule (§5.1 step 5, R3-2, CC17, Q2)
+
+**Files:**
+- Create: `internal/hostclaim/orphan.go`
+- Test: `internal/hostclaim/orphan_test.go`
+
+**Interfaces:**
+- Consumes: `Observed`, `Incarnation`.
+- Produces: `func IsOrphan(recordInc string, claimReadAfterRecord Observed) bool`.
+
+This is a pure predicate. **The caller must read the record first, then
+the claim** (R3-2). The record is an orphan only if the claim exists and
+names neither `recordInc` as holder nor a `creating` operation for it.
+An absent claim means a legacy host, which is **not** an orphan (Q2).
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+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")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/hostclaim/ -run TestIsOrphan -count=1`
+Expected: FAIL, with "undefined: IsOrphan".
+
+- [ ] **Step 3: Write the minimal implementation**
+
+```go
+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. 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
+}
+```
+
+The `creating` case is covered by the holder check: `BeginCreate` sets
+the holder while the operation is `creating`.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/hostclaim/ -count=1`
+Expected: PASS (the whole package).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/hostclaim/orphan.go internal/hostclaim/orphan_test.go
+git commit -m "hostclaim: the orphan predicate (record first, then claim; no claim = legacy)"
+```
+
+---
+
+### Task 8: Package gate and docs
+
+**Files:**
+- Modify: `AGENTS.md`. Add one line under its package list, if it has
+ one, naming `internal/hostclaim` and the spec. If it has no package
+ list, skip this.
+
+- [ ] **Step 1: Run the full gate**
+
+```bash
+export GOCACHE=$TMPDIR/gocache
+go build ./... && go vet ./internal/hostclaim/ && gofmt -l internal/hostclaim/ && go test ./internal/hostclaim/ -count=1 -race
+```
+Expected: build and vet clean, `gofmt -l` prints nothing, and the tests
+PASS under `-race`.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add -A internal/hostclaim AGENTS.md
+git commit -m "hostclaim: package gate"
+```
+
+---
+
+## Self-review
+
+- **Spec coverage, this plan's scope:**
+ - §2 object: Task 1.
+ - C2 and C6: Tasks 2 and 3.
+ - C5a: Task 2 and Global Constraints.
+ - §4 one operation and CC11: Task 3.
+ - §5.1 with CC22 and R3-12: Task 4.
+ - §5.2: Task 5.
+ - §9 with CC4 and CC8: Task 6.
+ - The orphan rule (R3-2, CC17, Q2): Task 7.
+ - `holder.stamp` at installing CASes: Tasks 4 and 6.
+- **Left to later plans, by the roadmap:**
+ - versioned storage (Plan 2);
+ - parking I/O, the horizon and local retirement (Plan 3);
+ - the guard, stamps' local records and stop evidence (Plan 4);
+ - console wiring, admission, purge and the recovery pass (Plan 5);
+ - box wiring and the sweep (Plan 6);
+ - the door (Plan 7);
+ - leases, lost boxes and the floor (Plan 8).
+- **Types are consistent across tasks:** `Observed`, `Update`,
+ `RequireOp`, `NewHolder` and `retire`. Test ID helpers are counters.