plan: host lifecycle claim, Plan 1 of 8 (the claim library) and the roadmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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. |