plan: host lifecycle claim Plan 1 rev 2, after Astra's plan review
Record body in create intent; chores matched by their own op id with discovery completion (spec gains touched.discovery_op); delete keeps exec and guards CC11 over its write set; observations bound to their host; schema v2 with structured inventory and owner; strict decode, known kinds, identity checks; gen derived centrally; roadmap dependencies and the inert activation boundary; the repository gate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+557
−368
docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md+549 −368docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md+8 −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| index 369e88383..c12b050bb 100644 |
| --- 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 |
| @@ -2,87 +2,109 @@ |
| > **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 |
| +**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. |
| +claim's pure state transitions: |
| + |
| +- create, complete and cancel; |
| +- delete, parking chores and free; |
| +- adoption, including discovery completion; |
| +- the start-operation guards; |
| +- the orphan rule. |
| + |
| +Nothing is wired into the console or boxes. |
| **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. |
| + |
| +- The claim is one JSON object per host in the control bucket, written |
| + with `release.Store.Put`. Each write is conditional on the ETag read; |
| + only a host's very first claim uses `If-None-Match`. |
| +- A transition is a pure function `func(*Claim) error`. `Update` is the |
| + only writer: it copies the observed claim, applies the transition, sets |
| + `gen = observed+1` centrally, validates the result and writes it |
| + conditionally. |
| +- Transitions never set `gen` or the host. |
| **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. |
| +**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, |
| +rev 6.5 (d36adcfa), plus the `touched.discovery_op` note added with this |
| +plan. Read §2 (the object), §3 (C1-C6, C5a), §4, §5.1, §5.2, §6.4 (stamps |
| +on artifacts) and §9 before starting. Joint round 6 judged the spec ready |
| +for implementation planning. Rev 2 of this plan answers Astra's review of |
| +rev 1 (five P1s, four P2s and a P3). |
| ## 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 | |
| +| **1. Claim library** (this) | §2, C1-C6, C5a, §4 guards, §5.1/§5.2 state transitions, §9 adoption and discovery completion, 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 | |
| +| 3. Box-local guard and stop evidence | §6.4 flock guard, installation stamps and local record; §6.1 strict containment and evidence reconstruction | 1 | |
| +| 4. Park engine | §6.2 archive and deletion sets, the manifest; §6.2a horizon and `quiet_after`; §6.3 local retirement with inventory and journal | 1, 2, 3 | |
| +| 5. Console operations | create, delete and `RetireRecord` (every call site), admission.json, purge journal and fence, placement, the claim-recovery pass (§5, §8) | 1, 4 | |
| | 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 | |
| +| 8. Execution leases, lost boxes, the floor | §7 leases, self-fence, `carlos ops host resolve --declare-lost` with compute fencing, quarantine; the floor on every restore path (§10) | 1, 3 | |
| -Each later plan is written against the code this plan lands, after it |
| -merges. Moves (`design-moves-any-region`) builds its operations on Plan |
| +**Activation boundary.** Plans 5 to 7 land **inert**: lifecycle |
| +operations stay off behind `CARLOS_HOST_CLAIM` (unset by default) until |
| +Plan 8 has landed and the protocol floor is published (§10). Until then, |
| +the #199 stopgap stays in force. Each later plan is written against the |
| +code the earlier ones land. The moves spec 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. |
| + `controlname.ValidHostShape`. Schema version `"v": 2` (§2). |
| +- **Never delete a claim object (C5a).** No function deletes, and "free" |
| + is a written state. `If-None-Match` is used only when the observation is |
| + `Absent`, meaning the host has never had a claim. |
| +- **`gen` is derived centrally.** The result is always the observed `gen` |
| + plus 1. A transition that changes `Gen` or `Host` is rejected, and so is |
| + overflow. |
| +- **Every post-start transition is conditional on `op.id` (C2).** Chores |
| + are matched by their **own** op id (`Obligation.Op`), not the claim's |
| + current op. |
| +- **Read errors never act (C6).** Strict decoding: unknown fields refused, |
| + trailing data refused, unknown op kinds refused. Only `release` |
| + not-found means `Absent`. |
| +- **An observation is bound to its host.** `Observed.Host` is always |
| + set, and `Update` refuses to write a different host's key. |
| - **`retired_incarnations` keeps the newest 32**, and a create whose |
| - incarnation is listed is refused (§2, R3-12). |
| + incarnation is listed is refused (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`. |
| + current, set to that CAS's resulting `gen`. In this plan: `CompleteCreate` |
| + and `Adopt`. Execution authority (`op.exec`, `op.participants`, the |
| + stamp) is **never cleared** by a transition in this plan except |
| + `FreeAfterPark` (R6-4). Revoking it before free is an evidence-backed |
| + transition in Plan 3. |
| +- **Copy:** no user-facing strings. Error values are for callers. |
| +- **Gate** (AGENTS.md): `go build ./...`, `go vet ./...`, |
| + `go test ./... -count=1` and `gofmt -l .`, all clean. Use the default |
| + Go cache. If a sandbox makes it read-only, use `GOCACHE=$TMPDIR/gocache`, |
| + never `/tmp`. CI (`amadan ci status`) is the authority for packages that |
| + only fail in the sandbox. |
| ## 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 |
| +- **A claim body with unknown fields, trailing data or an unknown op |
| + kind.** The read must return `ErrMalformed`, never a claim a caller 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 |
| + write). The second must fail with `ErrStaleOp`, `ErrWrongState` or |
| + `ErrConflict`, and never double-append (Tasks 4 to 6). |
| +- **A transition for host B applied to host A's observation** must be |
| + refused (Tasks 2 and 4). |
| +- **An adopted host's last discovery chore** must flip `touched.complete` |
| + in the same write. A delete must be refused while discovery is pending |
| + (CC11) (Tasks 5 and 6). |
| +- **The 33rd retired incarnation** drops the oldest, and 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). |
| --- |
| @@ -90,17 +112,18 @@ merges. Moves (`design-moves-any-region`) builds its operations on Plan |
| | File | Responsibility | |
| |---|---| |
| -| `internal/hostclaim/claim.go` | Types, the key, `Validate`, the incarnation function | |
| +| `internal/hostclaim/claim.go` | Types (wire schema v2), the key, `Validate`, the incarnation function | |
| | `internal/hostclaim/store.go` | `Read` and `Update`, the only I/O | |
| +| `internal/hostclaim/ops.go` | `RequireOp` and `StartGuard` (C2, §4, CC11) | |
| | `internal/hostclaim/create.go` | `BeginCreate`, `CompleteCreate`, `CancelCreate` | |
| -| `internal/hostclaim/delete.go` | `BeginDelete`, `ToParking`, `CompleteObligation`, `Free` | |
| +| `internal/hostclaim/delete.go` | `WriteSet`, `BeginDelete`, `ToParking`, `CompleteObligation`, `FreeAfterPark` | |
| | `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())` | |
| +| `internal/hostclaim/*_test.go` | One test file per source file, using `release.NewDirStore(t.TempDir())` | |
| --- |
| -### Task 1: Types, key, validation and incarnation |
| +### Task 1: Types (schema v2), key, validation and incarnation |
| **Files:** |
| - Create: `internal/hostclaim/claim.go` |
| @@ -108,14 +131,13 @@ merges. Moves (`design-moves-any-region`) builds its operations on Plan |
| **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`. |
| + - `Claim`, `Holder`, `Op`, `Touched`, `Obligation`, `InventoryEntry` |
| + and `CancelledCreate`; |
| + - `Key(host string) (string, error)`; |
| + - `(*Claim).Validate() error` and `(*Claim).Free() bool`; |
| + - `Incarnation(account, app, host string, created time.Time) (string, error)`; |
| + - the `Kind*` constants; |
| + - `ErrMalformed` and `ErrInvalidHost`. |
| - [ ] **Step 1: Write the failing test** |
| @@ -123,12 +145,16 @@ merges. Moves (`design-moves-any-region`) builds its operations on Plan |
| package hostclaim |
| import ( |
| + "encoding/json" |
| + "strings" |
| "testing" |
| "time" |
| ) |
| +const testHost = "shop.bab.oncarlos.com" |
| + |
| func TestKeyRefusesBadHosts(t *testing.T) { |
| - if k, err := Key("shop.bab.oncarlos.com"); err != nil || k != "control/hosts/shop.bab.oncarlos.com.json" { |
| + if k, err := Key(testHost); err != nil || k != "control/hosts/"+testHost+".json" { |
| t.Fatalf("Key = %q, %v", k, err) |
| } |
| for _, bad := range []string{"", "../x", "a/b", "UPPER.example.com"} { |
| @@ -140,28 +166,48 @@ func TestKeyRefusesBadHosts(t *testing.T) { |
| 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) |
| + a1, err := Incarnation("bab", "jam", testHost, at) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - a2, _ := Incarnation("bab", "jam", "shop.bab.oncarlos.com", at) |
| + a2, _ := Incarnation("bab", "jam", testHost, at) |
| if a1 != a2 || len(a1) != 64 { |
| t.Fatalf("not stable 64-hex: %q %q", a1, a2) |
| } |
| - l1, err := Incarnation("bab", "jam", "shop.bab.oncarlos.com", time.Time{}) |
| + l1, err := Incarnation("bab", "jam", testHost, time.Time{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - l2, _ := Incarnation("bab", "other", "shop.bab.oncarlos.com", time.Time{}) |
| + l2, _ := Incarnation("bab", "other", testHost, time.Time{}) |
| if l1 == a1 || l1 == l2 || len(l1) != 64 { |
| t.Fatalf("legacy identity not distinct: %q %q %q", l1, l2, a1) |
| } |
| } |
| -func 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") |
| +func TestValidate(t *testing.T) { |
| + inc := strings.Repeat("a", 64) |
| + ok := &Claim{V: 2, Host: testHost, Gen: 1, |
| + Holder: &Holder{Family: "instance", Key: "apps/bab/jam/instances/" + testHost + ".json", Account: "bab", App: "jam", |
| + Incarnation: inc, Location: "instances/" + testHost + "/" + inc}, |
| + Op: Op{Kind: KindServing}} |
| + if err := ok.Validate(); err != nil { |
| + t.Fatalf("valid claim: %v", err) |
| + } |
| + bad := map[string]func(c *Claim){ |
| + "holderless serving": func(c *Claim) { c.Holder = nil }, |
| + "unknown kind": func(c *Claim) { c.Op = Op{Kind: "teleporting", ID: "x"} }, |
| + "foreign location": func(c *Claim) { c.Holder.Location = "instances/other.example.com" }, |
| + "short incarnation": func(c *Claim) { c.Holder.Incarnation = "abc" }, |
| + "old schema": func(c *Claim) { c.V = 1 }, |
| + } |
| + for name, mut := range bad { |
| + raw, _ := json.Marshal(ok) |
| + var c Claim |
| + _ = json.Unmarshal(raw, &c) |
| + mut(&c) |
| + if err := c.Validate(); err == nil { |
| + t.Errorf("%s validated", name) |
| + } |
| } |
| } |
| ``` |
| @@ -194,17 +240,26 @@ import ( |
| ) |
| 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 |
| + KindServing = "serving" |
| + KindCreating = "creating" |
| + KindDeleting = "deleting" |
| + KindParking = "parking" |
| + KindPurging = "purging" |
| + KindPlacing = "placing" |
| + KindMoving = "moving" // move-any-app |
| + KindLanding = "landing" // move-any-app |
| + KindReverting = "reverting" // move-any-app |
| + KindRestoring = "restoring" // move-any-app |
| + KindPushing = "pushing" // later push spec |
| + |
| + schemaVersion = 2 |
| + retiredCap = 32 |
| ) |
| +var knownKinds = map[string]bool{KindServing: true, KindCreating: true, KindDeleting: true, KindParking: true, |
| + KindPurging: true, KindPlacing: true, KindMoving: true, KindLanding: true, KindReverting: true, |
| + KindRestoring: true, KindPushing: true} |
| + |
| var ( |
| ErrMalformed = errors.New("hostclaim: malformed claim") |
| ErrInvalidHost = errors.New("hostclaim: invalid host") |
| @@ -224,7 +279,7 @@ type Holder struct { |
| } |
| type Op struct { |
| - Kind string `json:"kind"` |
| + Kind string `json:"kind,omitempty"` |
| ID string `json:"id,omitempty"` |
| Phase string `json:"phase,omitempty"` |
| Intent map[string]string `json:"intent,omitempty"` |
| @@ -236,9 +291,17 @@ type Op struct { |
| } |
| type Touched struct { |
| - Complete bool `json:"complete"` |
| - Regions []string `json:"regions,omitempty"` |
| - Boxes []string `json:"boxes,omitempty"` |
| + Complete bool `json:"complete"` |
| + Regions []string `json:"regions,omitempty"` |
| + Boxes []string `json:"boxes,omitempty"` |
| + DiscoveryOp string `json:"discovery_op,omitempty"` |
| +} |
| + |
| +// InventoryEntry is one box's local paths for the host (§6.3), each tagged |
| +// with the installation stamp that produced it (§6.4). |
| +type InventoryEntry struct { |
| + Layout string `json:"layout"` // "flat" | "nested" |
| + Paths map[string]uint64 `json:"paths"` // path -> stamp |
| } |
| type Obligation struct { |
| @@ -248,6 +311,7 @@ type Obligation struct { |
| Region string `json:"region,omitempty"` |
| Box string `json:"box,omitempty"` |
| Retirement string `json:"retirement"` |
| + Owner string `json:"owner"` // "region:<r>" | "box:<b>" |
| } |
| type CancelledCreate struct { |
| @@ -257,18 +321,18 @@ type CancelledCreate struct { |
| } |
| 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"` |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Gen uint64 `json:"gen"` |
| + Holder *Holder `json:"holder,omitempty"` |
| + Op Op `json:"op"` |
| + Touched Touched `json:"touched"` |
| + Inventory map[string]InventoryEntry `json:"inventory,omitempty"` |
| + Obligations []Obligation `json:"obligations,omitempty"` |
| + Lost []string `json:"lost,omitempty"` |
| + RetiredIncarnations []string `json:"retired_incarnations,omitempty"` |
| + QuietAfter map[string]time.Time `json:"quiet_after,omitempty"` |
| + CancelledCreates []CancelledCreate `json:"cancelled_creates,omitempty"` |
| } |
| // Free reports whether the host has no holder and no operation. |
| @@ -282,8 +346,7 @@ func Key(host string) (string, error) { |
| } |
| // 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. |
| +// before CreatedAt existed) gets the domain-separated legacy identity. |
| func Incarnation(account, app, host string, created time.Time) (string, error) { |
| if created.IsZero() { |
| if !controlname.ValidAccount(account) || !controlname.ValidApp(app) || !controlname.ValidHostShape(host) { |
| @@ -295,26 +358,42 @@ func Incarnation(account, app, host string, created time.Time) (string, error) { |
| return maintenanceguard.RecordIdentity(account, app, host, created) |
| } |
| +func isHex64(s string) bool { |
| + if len(s) != 64 { |
| + return false |
| + } |
| + _, err := hex.DecodeString(s) |
| + return err == nil |
| +} |
| + |
| func (c *Claim) Validate() error { |
| - if c == nil || c.V != 1 || !controlname.ValidHostShape(c.Host) || c.Gen == 0 { |
| + if c == nil || c.V != schemaVersion || !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 c.Op.Kind != "" && !knownKinds[c.Op.Kind] { |
| + return fmt.Errorf("%w: unknown op kind %q", ErrMalformed, c.Op.Kind) |
| + } |
| + if c.Op.Kind != "" && c.Op.Kind != KindServing && c.Op.ID == "" { |
| + return fmt.Errorf("%w: op %q without id", ErrMalformed, c.Op.Kind) |
| } |
| - if !free { |
| + if c.Holder == nil { |
| + if c.Op.Kind != "" && c.Op.Kind != KindPurging { // a purge fence has no holder (CC21) |
| + return fmt.Errorf("%w: op %q without holder", ErrMalformed, c.Op.Kind) |
| + } |
| + } else { |
| h := c.Holder |
| - if h.Incarnation == "" || h.Key == "" || h.Location == "" || (h.Family != "instance" && h.Family != "sidecar") { |
| - return fmt.Errorf("%w: holder", ErrMalformed) |
| + if (h.Family != "instance" && h.Family != "sidecar") || h.Key == "" || !isHex64(h.Incarnation) || |
| + !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.App) { |
| + return fmt.Errorf("%w: holder identity", ErrMalformed) |
| + } |
| + legacy, scoped := "instances/"+c.Host, "instances/"+c.Host+"/"+h.Incarnation |
| + if h.Location != legacy && h.Location != scoped { |
| + return fmt.Errorf("%w: holder location %q", ErrMalformed, h.Location) |
| } |
| if c.Op.Kind == "" { |
| return fmt.Errorf("%w: holder without op", ErrMalformed) |
| } |
| } |
| - if 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) |
| } |
| @@ -331,7 +410,7 @@ Expected: PASS. |
| ```bash |
| git add internal/hostclaim/claim.go internal/hostclaim/claim_test.go |
| -git commit -m "hostclaim: the claim object, its key, validation and incarnation" |
| +git commit -m "hostclaim: the claim object (schema v2), key, validation and incarnation" |
| ``` |
| --- |
| @@ -343,24 +422,12 @@ git commit -m "hostclaim: the claim object, its key, validation and incarnation" |
| - Test: `internal/hostclaim/store_test.go` |
| **Interfaces:** |
| -- Consumes: Task 1's `Claim`, `Key` and `Validate`; `release.Store`; |
| - `release.ErrCASConflict`. |
| +- Consumes: Task 1. |
| - 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). |
| + - `type Observed struct { Host string; Claim *Claim; ETag string; Absent bool }`; |
| + - `Read(ctx, s release.Store, host string) (Observed, error)`; |
| + - `Update(ctx, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error)`; |
| + - `ErrConflict` (wraps `release.ErrCASConflict`). |
| - [ ] **Step 1: Write the failing test** |
| @@ -375,42 +442,53 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -const testHost = "shop.bab.oncarlos.com" |
| - |
| -func TestReadAbsentAndMalformed(t *testing.T) { |
| +func TestReadRefusesMalformed(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
| obs, err := Read(ctx, s, testHost) |
| - if err != nil || !obs.Absent { |
| + if err != nil || !obs.Absent || obs.Host != testHost { |
| 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) |
| + good := `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false}}` |
| + for name, body := range map[string]string{ |
| + "unknown field": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},"surprise":1}`, |
| + "trailing data": good + ` {"v":2}`, |
| + "wrong host": `{"v":2,"host":"other.bab.oncarlos.com","gen":1,"op":{},"touched":{"complete":false}}`, |
| + } { |
| + s2 := release.NewDirStore(t.TempDir()) |
| + if _, err := s2.Put(ctx, key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Read(ctx, s2, testHost); !errors.Is(err, ErrMalformed) { |
| + t.Errorf("%s: read = %v, want ErrMalformed", name, err) |
| + } |
| } |
| } |
| -func TestUpdateIncrementsGenAndLosesRaces(t *testing.T) { |
| +func TestUpdateDerivesGenAndBindsHost(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 { |
| + noop := func(c *Claim) error { return nil } |
| + o1, err := Update(ctx, s, obs, noop) |
| + if err != nil || o1.Claim.Gen != 1 || o1.Claim.Host != testHost { |
| t.Fatalf("first write = %+v, %v", o1, err) |
| } |
| - // 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) |
| + if _, err := Update(ctx, s, obs, noop); !errors.Is(err, ErrConflict) { |
| + t.Fatalf("stale absent write = %v, want ErrConflict (C5a)", err) |
| + } |
| + if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Gen = 99; return nil }); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("transition changing gen = %v", err) |
| } |
| - o2, err := Update(ctx, s, o1, setFree) |
| + if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Host = "other.bab.oncarlos.com"; return nil }); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("transition changing host = %v", err) |
| + } |
| + o2, err := Update(ctx, s, o1, noop) |
| if err != nil || o2.Claim.Gen != 2 { |
| t.Fatalf("second write = %+v, %v", o2, err) |
| } |
| - if _, err := Update(ctx, s, o1, setFree); !errors.Is(err, ErrConflict) { |
| + if _, err := Update(ctx, s, o1, noop); !errors.Is(err, ErrConflict) { |
| t.Fatalf("stale etag write = %v, want ErrConflict", err) |
| } |
| } |
| @@ -432,22 +510,35 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "io" |
| + "math" |
| "os" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -var ( |
| - ErrConflict = fmt.Errorf("hostclaim: claim changed since it was read: %w", release.ErrCASConflict) |
| - ErrNeverClaimed = errors.New("hostclaim: host has never had a claim") |
| -) |
| +var ErrConflict = fmt.Errorf("hostclaim: claim changed since it was read: %w", release.ErrCASConflict) |
| type Observed struct { |
| + Host string |
| Claim *Claim |
| ETag string |
| Absent bool |
| } |
| +func decodeStrict(body []byte) (*Claim, error) { |
| + dec := json.NewDecoder(bytes.NewReader(body)) |
| + dec.DisallowUnknownFields() |
| + var c Claim |
| + if err := dec.Decode(&c); err != nil { |
| + return nil, fmt.Errorf("%w: %v", ErrMalformed, err) |
| + } |
| + if _, err := dec.Token(); err != io.EOF { |
| + return nil, fmt.Errorf("%w: trailing data", ErrMalformed) |
| + } |
| + return &c, nil |
| +} |
| + |
| func Read(ctx context.Context, s release.Store, host string) (Observed, error) { |
| key, err := Key(host) |
| if err != nil { |
| @@ -455,16 +546,14 @@ func Read(ctx context.Context, s release.Store, host string) (Observed, error) { |
| } |
| body, etag, err := s.Get(ctx, key) |
| if errors.Is(err, os.ErrNotExist) { |
| - return Observed{Absent: true}, nil |
| + return Observed{Host: host, 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) |
| + c, err := decodeStrict(body) |
| + if err != nil { |
| + return Observed{}, err |
| } |
| if c.Host != host { |
| return Observed{}, fmt.Errorf("%w: host mismatch", ErrMalformed) |
| @@ -472,44 +561,51 @@ func Read(ctx context.Context, s release.Store, host string) (Observed, error) { |
| if err := c.Validate(); err != nil { |
| return Observed{}, err |
| } |
| - return Observed{Claim: &c, ETag: etag}, nil |
| + return Observed{Host: host, Claim: c, ETag: etag}, nil |
| } |
| func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error) { |
| - var next Claim |
| + key, err := Key(obs.Host) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + var next *Claim |
| + var oldGen uint64 |
| if obs.Absent { |
| - next = Claim{V: 1} |
| + next = &Claim{V: schemaVersion, Host: obs.Host} |
| } else { |
| + if obs.Claim == nil || obs.Claim.Host != obs.Host { |
| + return Observed{}, fmt.Errorf("%w: observation not bound to %s", ErrMalformed, obs.Host) |
| + } |
| raw, err := json.Marshal(obs.Claim) |
| if err != nil { |
| return Observed{}, err |
| } |
| - if err := json.Unmarshal(raw, &next); err != nil { |
| + if next, err = decodeStrict(raw); err != nil { |
| return Observed{}, err |
| } |
| + oldGen = obs.Claim.Gen |
| } |
| - host := next.Host |
| - if err := tr(&next); err != nil { |
| + if err := tr(next); err != nil { |
| return Observed{}, err |
| } |
| - if host != "" && next.Host != host { |
| - return Observed{}, fmt.Errorf("%w: transition changed host", ErrMalformed) |
| + if next.Host != obs.Host || next.Gen != oldGen { |
| + return Observed{}, fmt.Errorf("%w: a transition may not change host or gen", ErrMalformed) |
| } |
| - next.Gen++ |
| - if err := next.Validate(); err != nil { |
| - return Observed{}, err |
| + if oldGen == math.MaxUint64 { |
| + return Observed{}, fmt.Errorf("%w: gen overflow", ErrMalformed) |
| } |
| - key, err := Key(next.Host) |
| - if err != nil { |
| + next.Gen = oldGen + 1 |
| + if err := next.Validate(); err != nil { |
| return Observed{}, err |
| } |
| - body, err := json.Marshal(&next) |
| + body, err := json.Marshal(next) |
| if err != nil { |
| return Observed{}, err |
| } |
| ifMatch := obs.ETag |
| if obs.Absent { |
| - ifMatch = "" |
| + ifMatch = "" // the host's very first claim (C5a) |
| } |
| etag, err := s.Put(ctx, key, body, ifMatch) |
| if errors.Is(err, release.ErrCASConflict) { |
| @@ -518,12 +614,13 @@ func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) |
| if err != nil { |
| return Observed{}, err |
| } |
| - return Observed{Claim: &next, ETag: etag}, nil |
| + return Observed{Host: obs.Host, Claim: next, ETag: etag}, nil |
| } |
| ``` |
| -For an absent observation, the transition sets `Host`, as `setFree` |
| -does in the test. |
| +Note on the test's first write: a free claim (`v:2`, host, `gen:1`, empty |
| +op) is valid. That is the state a host has after `FreeAfterPark`, so a |
| +no-op transition on an absent observation is a legal first write. |
| - [ ] **Step 4: Run the test to verify it passes** |
| @@ -534,7 +631,7 @@ Expected: PASS. |
| ```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)" |
| +git commit -m "hostclaim: strict Read and the single Update (central gen, host-bound, never delete)" |
| ``` |
| --- |
| @@ -546,16 +643,11 @@ git commit -m "hostclaim: Read (strict, errors never absent) and Update (gen+1, |
| - Test: `internal/hostclaim/ops_test.go` |
| **Interfaces:** |
| -- Consumes: Task 1's `Claim` and `Obligation`. |
| +- Consumes: Task 1. |
| - 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. |
| + - `RequireOp(id string) func(*Claim) error`; |
| + - `StartGuard(c *Claim, regions, boxes []string) error`; |
| + - `ErrStaleOp`, `ErrWrongState` and `ErrPendingChore`. |
| - [ ] **Step 1: Write the failing test** |
| @@ -575,6 +667,9 @@ func TestRequireOpRefusesStale(t *testing.T) { |
| if err := RequireOp("op_new")(c); err != nil { |
| t.Fatalf("current op = %v", err) |
| } |
| + if err := RequireOp("")(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("empty id must never match: %v", err) |
| + } |
| } |
| func TestStartGuard(t *testing.T) { |
| @@ -615,25 +710,20 @@ var ( |
| func RequireOp(id string) func(*Claim) error { |
| return func(c *Claim) error { |
| - if c.Op.ID != id { |
| + if id == "" || c.Op.ID != id { |
| return fmt.Errorf("%w: have %q, want %q", ErrStaleOp, c.Op.ID, id) |
| } |
| return nil |
| } |
| } |
| +// StartGuard is §4: one operation at a time, started from serving, and |
| +// CC11: never over a chore in a region or box the operation will write. |
| func StartGuard(c *Claim, regions, boxes []string) error { |
| if c.Holder == nil || c.Op.Kind != KindServing { |
| return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| } |
| - inR := map[string]bool{} |
| - for _, r := range regions { |
| - inR[r] = true |
| - } |
| - inB := map[string]bool{} |
| - for _, b := range boxes { |
| - inB[b] = true |
| - } |
| + inR, inB := set(regions), set(boxes) |
| for _, o := range c.Obligations { |
| if (o.Region != "" && inR[o.Region]) || (o.Box != "" && inB[o.Box]) { |
| return fmt.Errorf("%w: %s %s%s", ErrPendingChore, o.Kind, o.Region, o.Box) |
| @@ -641,6 +731,14 @@ func StartGuard(c *Claim, regions, boxes []string) error { |
| } |
| return nil |
| } |
| + |
| +func set(xs []string) map[string]bool { |
| + m := make(map[string]bool, len(xs)) |
| + for _, x := range xs { |
| + m[x] = true |
| + } |
| + return m |
| +} |
| ``` |
| - [ ] **Step 4: Run the test to verify it passes** |
| @@ -664,27 +762,29 @@ git commit -m "hostclaim: op-id guard (C2) and the start guard (one op, CC11)" |
| - Test: `internal/hostclaim/create_test.go` |
| **Interfaces:** |
| -- Consumes: `Update`, `Observed`, `RequireOp`, `Incarnation`. |
| +- Consumes: Tasks 1 to 3. |
| - 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`; |
| + - `BeginCreate(nh NewHolder, recordBody []byte, opID, by string, now time.Time) func(*Claim) error`; |
| + - `CompleteCreate(opID, execBox string) func(*Claim) error`; |
| + - `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 |
| +at registration time (CC22). Any `ErrConflict` restarts 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`. |
| + |
| +`BeginCreate` does the following: |
| +- refuses unless the claim is free (or absent) and `nh.Host == c.Host`; |
| +- refuses unless `nh.Key` is a record key for that account, app and host; |
| +- refuses if the incarnation is retired; |
| +- persists the **record body** in `intent.record`, so recovery can roll |
| + forward (§5.1 step 5); |
| +- sets the holder, with `location = instances/<host>/<incarnation>`. |
| + |
| +`CompleteCreate` sets `op = {serving, exec}` and `holder.stamp` to the |
| +resulting gen. `CancelCreate` frees the claim, retires the incarnation and |
| +adds a `cancelled_creates` entry. |
| - [ ] **Step 1: Write the failing test** |
| @@ -706,44 +806,61 @@ func newHolder() NewHolder { |
| CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| } |
| +var record = []byte(`{"schema":1,"host":"shop.bab.oncarlos.com"}`) |
| + |
| 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 { |
| + reg, _ := Read(ctx, s, testHost) |
| + o, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now)) |
| + if err != nil || o.Claim.Op.Kind != KindCreating || o.Claim.Op.Intent["record"] != string(record) { |
| t.Fatalf("begin = %+v, %v", o, err) |
| } |
| if o.Claim.Holder.Location != "instances/"+testHost+"/"+o.Claim.Holder.Incarnation { |
| t.Fatalf("location = %q", o.Claim.Holder.Location) |
| } |
| o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")) |
| - if err != nil || o.Claim.Op.Kind != KindServing || o.Claim.Holder.Stamp != o.Claim.Gen { |
| + if err != nil || o.Claim.Op.Kind != KindServing || o.Claim.Op.Exec != "adi-knitting" || o.Claim.Holder.Stamp != o.Claim.Gen { |
| t.Fatalf("complete = %+v, %v", o.Claim, err) |
| } |
| - // 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) |
| + if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("replay = %v, want ErrStaleOp", 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) { |
| + if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c2", "console", now)); !errors.Is(err, ErrConflict) { |
| t.Fatalf("stale create = %v, want ErrConflict", err) |
| } |
| } |
| +func TestBeginCreateRefusesAnotherHostOrKey(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| + reg, _ := Read(ctx, s, testHost) |
| + other := newHolder() |
| + other.Host = "other.bab.oncarlos.com" |
| + if _, err := Update(ctx, s, reg, BeginCreate(other, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("another host's create on this claim = %v", err) |
| + } |
| + badKey := newHolder() |
| + badKey.Key = "apps/zzz/jam/instances/" + testHost + ".json" |
| + if _, err := Update(ctx, s, reg, BeginCreate(badKey, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("mismatched record key = %v", err) |
| + } |
| +} |
| + |
| 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)) |
| + o, _ := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now)) |
| inc := o.Claim.Holder.Incarnation |
| o, err := Update(ctx, s, o, CancelCreate("op_c1", now)) |
| if err != nil || !o.Claim.Free() || len(o.Claim.CancelledCreates) != 1 || o.Claim.RetiredIncarnations[0] != inc { |
| t.Fatalf("cancel = %+v, %v", o.Claim, err) |
| } |
| - if _, err := Update(ctx, s, o, BeginCreate(newHolder(), "op_c3", "console", now)); !errors.Is(err, ErrRetiredIncarnation) { |
| + if _, err := Update(ctx, s, o, BeginCreate(newHolder(), record, "op_c3", "console", now)); !errors.Is(err, ErrRetiredIncarnation) { |
| t.Fatalf("reuse = %v, want ErrRetiredIncarnation", err) |
| } |
| } |
| @@ -751,7 +868,7 @@ func TestCancelRetiresAndRefusesReuse(t *testing.T) { |
| - [ ] **Step 2: Run the test to verify it fails** |
| -Run: `go test ./internal/hostclaim/ -run 'TestCreate|TestCancel' -count=1` |
| +Run: `go test ./internal/hostclaim/ -run 'TestCreate|TestBeginCreate|TestCancel' -count=1` |
| Expected: FAIL, with "undefined: BeginCreate". |
| - [ ] **Step 3: Write the minimal implementation** |
| @@ -762,6 +879,7 @@ package hostclaim |
| import ( |
| "errors" |
| "fmt" |
| + "strings" |
| "time" |
| ) |
| @@ -772,6 +890,20 @@ type NewHolder struct { |
| CreatedAt time.Time |
| } |
| +// keyMatches checks the record key belongs to this account, app and host: |
| +// apps/<acct>/<app>/instances/<host>.json for instances, and |
| +// apps/<acct>/<app>/sidecars/<name>.json for sidecars. |
| +func keyMatches(nh NewHolder) bool { |
| + base := "apps/" + nh.Account + "/" + nh.App + "/" |
| + switch nh.Family { |
| + case "instance": |
| + return nh.Key == base+"instances/"+nh.Host+".json" |
| + case "sidecar": |
| + return nh.Name != "" && nh.Key == base+"sidecars/"+nh.Name+".json" && strings.HasPrefix(nh.Host, nh.Name+".") |
| + } |
| + return false |
| +} |
| + |
| func retire(c *Claim, inc string) { |
| c.RetiredIncarnations = append([]string{inc}, c.RetiredIncarnations...) |
| if len(c.RetiredIncarnations) > retiredCap { |
| @@ -779,16 +911,16 @@ func retire(c *Claim, inc string) { |
| } |
| } |
| -func BeginCreate(nh NewHolder, opID, by string, now time.Time) func(*Claim) error { |
| +func BeginCreate(nh NewHolder, recordBody []byte, opID, by string, now time.Time) func(*Claim) error { |
| return func(c *Claim) error { |
| - if c.Host == "" { |
| - c.Host = nh.Host |
| + if nh.Host != c.Host || !keyMatches(nh) { |
| + return fmt.Errorf("%w: create inputs are not this host's", ErrWrongState) |
| } |
| if !c.Free() { |
| return fmt.Errorf("%w: host is held", ErrWrongState) |
| } |
| - if nh.CreatedAt.IsZero() { |
| - return fmt.Errorf("%w: create needs a CreatedAt", ErrMalformed) |
| + if nh.CreatedAt.IsZero() || len(recordBody) == 0 || opID == "" { |
| + return fmt.Errorf("%w: create needs CreatedAt, a record body and an op id", ErrMalformed) |
| } |
| inc, err := Incarnation(nh.Account, nh.App, nh.Host, nh.CreatedAt) |
| if err != nil { |
| @@ -801,7 +933,8 @@ func BeginCreate(nh NewHolder, opID, by string, now time.Time) func(*Claim) erro |
| } |
| 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.Op = Op{Kind: KindCreating, ID: opID, By: by, StartedAt: now.UTC(), |
| + Intent: map[string]string{"key": nh.Key, "record": string(recordBody)}} |
| c.Touched = Touched{Complete: true, Regions: []string{nh.Region}} |
| return nil |
| } |
| @@ -812,11 +945,11 @@ func CompleteCreate(opID, execBox string) func(*Claim) error { |
| if err := RequireOp(opID)(c); err != nil { |
| return err |
| } |
| - if c.Op.Kind != KindCreating { |
| + if c.Op.Kind != KindCreating || execBox == "" { |
| 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) |
| + c.Op = Op{Kind: KindServing, Exec: execBox, Participants: []string{execBox}} |
| + c.Holder.Stamp = c.Gen + 1 // the resulting gen: Update sets gen = observed+1 |
| return nil |
| } |
| } |
| @@ -838,45 +971,55 @@ func CancelCreate(opID string, now time.Time) func(*Claim) error { |
| } |
| ``` |
| +The key shapes are confirmed against main: |
| +- `provision.RecordKey` gives `apps/<acct>/<app>/instances/<host>.json`; |
| +- `provision.SidecarRecordKey` gives `apps/<acct>/<app>/sidecars/<name>.json`; |
| +- a sidecar's host is `provision.SidecarHost(name, app, sqid)`, which is |
| + `<name>.<app>.<sqid>.sidecar.internal`. |
| + |
| +`hostclaim` doesn't import `provision`, to avoid a cycle once |
| +`provision` calls into it, so these shapes are restated here. |
| + |
| - [ ] **Step 4: Run the test to verify it passes** |
| -Run: `go test ./internal/hostclaim/ -run 'TestCreate|TestCancel' -count=1` |
| +Run: `go test ./internal/hostclaim/ -run 'TestCreate|TestBeginCreate|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)" |
| +git commit -m "hostclaim: create (record body in intent, host-bound), complete (stamp) and cancel" |
| ``` |
| --- |
| -### Task 5: Delete, parking, obligations and free (§5.2) |
| +### Task 5: Delete, parking, chores and free (§5.2, CC11) |
| **Files:** |
| - Create: `internal/hostclaim/delete.go` |
| - Test: `internal/hostclaim/delete_test.go` |
| **Interfaces:** |
| -- Consumes: `Update`, `RequireOp`, `StartGuard`, `retire`. |
| +- Consumes: Tasks 1 to 4. |
| - 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`. |
| + - `WriteSet(c *Claim, allRegions, allBoxes []string) (regions, boxes []string)`; |
| + - `BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string) func(*Claim) error`; |
| + - `ToParking(opID string, allRegions, allBoxes []string, newID func() string) func(*Claim) error`; |
| + - `CompleteObligation(obligationOp, obligationID string) func(*Claim) error`; |
| + - `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`. |
| +- `WriteSet` returns the touched set, or every region and box when |
| + `touched.complete` is false. |
| +- `BeginDelete` runs `StartGuard` over that set (CC11). So a delete |
| + waits for any pending chore there, including an adopted host's |
| + discovery. |
| +- It **keeps `op.exec`, `op.participants` and the stamp**. Revoking them |
| + needs stop evidence (Plan 3). |
| +- `CompleteObligation` matches the chore by **its own** op id and id. If |
| + the chore is the last of `touched.discovery_op`, the same write sets |
| + `touched.complete = true` and clears `discovery_op`. |
| - [ ] **Step 1: Write the failing test** |
| @@ -893,16 +1036,23 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +func counter() func() string { |
| + n := 0 |
| + return func() string { n++; return fmt.Sprintf("id_%d", n) } |
| +} |
| + |
| func servingClaim(t *testing.T, ctx context.Context, s release.Store) Observed { |
| t.Helper() |
| now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| reg, _ := Read(ctx, s, testHost) |
| - o, err := Update(ctx, s, reg, BeginCreate(newHolder(), "op_c1", "console", now)) |
| + o, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now)) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")) |
| - if err != nil { |
| + if o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if o, err = Update(ctx, s, o, func(c *Claim) error { c.Touched.Boxes = []string{"adi-knitting"}; return nil }); err != nil { |
| t.Fatal(err) |
| } |
| return o |
| @@ -913,18 +1063,15 @@ func TestDeleteParkFree(t *testing.T) { |
| 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)) |
| + inc, stamp := o.Claim.Holder.Incarnation, o.Claim.Holder.Stamp |
| + o, err := Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now, nil, nil)) |
| if err != nil || o.Claim.Op.Kind != KindDeleting || o.Claim.Op.Intent["etag"] != `"etag1"` { |
| t.Fatalf("begin delete = %+v, %v", o.Claim, err) |
| } |
| - 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 o.Claim.Op.Exec != "adi-knitting" || o.Claim.Holder.Stamp != stamp { |
| + t.Fatalf("delete dropped execution authority: exec %q stamp %d", o.Claim.Op.Exec, o.Claim.Holder.Stamp) |
| + } |
| + o, err = Update(ctx, s, o, ToParking("op_d1", nil, nil, counter())) |
| if err != nil || o.Claim.Op.Kind != KindParking || len(o.Claim.Obligations) != 2 { |
| t.Fatalf("to parking = %+v, %v", o.Claim, err) |
| } |
| @@ -932,10 +1079,13 @@ func TestDeleteParkFree(t *testing.T) { |
| 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 { |
| + if o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)); err != nil { |
| t.Fatal(err) |
| } |
| } |
| + if _, err := Update(ctx, s, o, CompleteObligation("op_d1", "id_1")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("completing a chore twice = %v", err) |
| + } |
| o, err = Update(ctx, s, o, FreeAfterPark("op_d1")) |
| if err != nil || !o.Claim.Free() || o.Claim.RetiredIncarnations[0] != inc { |
| t.Fatalf("free = %+v, %v", o.Claim, err) |
| @@ -948,10 +1098,9 @@ func TestIncompleteTouchedParksEverywhere(t *testing.T) { |
| 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)) |
| + regions, boxes := []string{"ie", "uk", "us"}, []string{"b1", "b2"} |
| + o, _ = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes)) |
| + o, err := Update(ctx, s, o, ToParking("op_d1", regions, boxes, counter())) |
| 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) |
| } |
| @@ -983,12 +1132,26 @@ import ( |
| "time" |
| ) |
| -func BeginDelete(opID, by, recordETag string, now time.Time) func(*Claim) error { |
| +// WriteSet is where a delete parks: the touched set, or the whole |
| +// deployment while discovery is incomplete (§5.2 step 3, §9). |
| +func WriteSet(c *Claim, allRegions, allBoxes []string) (regions, boxes []string) { |
| + if c.Touched.Complete { |
| + return c.Touched.Regions, c.Touched.Boxes |
| + } |
| + return allRegions, allBoxes |
| +} |
| + |
| +func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string) func(*Claim) error { |
| return func(c *Claim) error { |
| - if err := StartGuard(c, nil, nil); err != nil { |
| + regions, boxes := WriteSet(c, allRegions, allBoxes) |
| + if err := StartGuard(c, regions, boxes); err != nil { |
| return err |
| } |
| - c.Op = Op{Kind: KindDeleting, ID: opID, By: by, StartedAt: now.UTC(), |
| + if opID == "" || recordETag == "" { |
| + return fmt.Errorf("%w: delete needs an op id and the record's etag", ErrMalformed) |
| + } |
| + exec, parts := c.Op.Exec, c.Op.Participants // execution authority is kept (R6-4) |
| + c.Op = Op{Kind: KindDeleting, ID: opID, By: by, StartedAt: now.UTC(), Exec: exec, Participants: parts, |
| Intent: map[string]string{"key": c.Holder.Key, "etag": recordETag}} |
| return nil |
| } |
| @@ -999,18 +1162,17 @@ func ToParking(opID string, allRegions, allBoxes []string, newID func() string) |
| if err := RequireOp(opID)(c); err != nil { |
| return err |
| } |
| + // purging: §5.3 step 4 retires records under the purge's own claim |
| + // (R4-4); Plan 5 adds the purge journal's preconditions around it. |
| if c.Op.Kind != KindDeleting && c.Op.Kind != KindPurging { |
| return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| } |
| - regions, boxes := c.Touched.Regions, c.Touched.Boxes |
| - if !c.Touched.Complete { |
| - regions, boxes = allRegions, allBoxes |
| - } |
| + regions, boxes := WriteSet(c, allRegions, allBoxes) |
| for _, r := range regions { |
| - c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID()}) |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID(), Owner: "region:" + r}) |
| } |
| for _, b := range boxes { |
| - c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID()}) |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID(), Owner: "box:" + b}) |
| } |
| retire(c, c.Holder.Incarnation) |
| c.Op.Kind = KindParking |
| @@ -1018,18 +1180,30 @@ func ToParking(opID string, allRegions, allBoxes []string, newID func() string) |
| } |
| } |
| -func CompleteObligation(opID, obligationID string) func(*Claim) error { |
| +// CompleteObligation removes one chore, matched by its OWN op id and id. |
| +// Discovery chores outlive the adoption that made them (§9). |
| +func CompleteObligation(obligationOp, obligationID string) func(*Claim) error { |
| return func(c *Claim) error { |
| - if err := RequireOp(opID)(c); err != nil { |
| - return err |
| - } |
| + idx := -1 |
| for i, o := range c.Obligations { |
| - if o.ID == obligationID { |
| - c.Obligations = append(c.Obligations[:i], c.Obligations[i+1:]...) |
| - return nil |
| + if o.ID == obligationID && o.Op == obligationOp { |
| + idx = i |
| + break |
| + } |
| + } |
| + if idx < 0 { |
| + return fmt.Errorf("%w: no obligation %q of op %q", ErrWrongState, obligationID, obligationOp) |
| + } |
| + c.Obligations = append(c.Obligations[:idx], c.Obligations[idx+1:]...) |
| + if c.Touched.DiscoveryOp != "" && c.Touched.DiscoveryOp == obligationOp { |
| + for _, o := range c.Obligations { |
| + if o.Op == obligationOp { |
| + return nil |
| + } |
| } |
| + c.Touched.Complete, c.Touched.DiscoveryOp = true, "" |
| } |
| - return fmt.Errorf("%w: no obligation %q", ErrWrongState, obligationID) |
| + return nil |
| } |
| } |
| @@ -1047,10 +1221,6 @@ func FreeAfterPark(opID string) func(*Claim) error { |
| } |
| ``` |
| -`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` |
| @@ -1060,33 +1230,30 @@ Expected: PASS. |
| ```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)" |
| +git commit -m "hostclaim: delete (CC11 over its write set, keeps exec), parking chores, completion and free" |
| ``` |
| --- |
| -### Task 6: Adoption (§9, CC4, CC8) |
| +### Task 6: Adoption and discovery completion (§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. |
| +- Consumes: Tasks 1 to 5. |
| +- Produces: `Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, opID string, newID func() string) func(*Claim) error`. |
| + |
| +`Adopt` runs only against an **absent** observation (C5a: a host that |
| +never had a claim). It writes: |
| +- the holder, with the legacy `location = instances/<host>` and |
| + `stamp` = the resulting gen; |
| +- `op = {serving, exec}`; |
| +- `touched = {complete: false, regions: [record region], boxes: [servingBox], discovery_op: opID}`; |
| +- one discovery chore under `opID` for each other region and box. |
| + |
| +With no other regions or boxes, discovery is complete at once. |
| - [ ] **Step 1: Write the failing test** |
| @@ -1096,29 +1263,39 @@ package hostclaim |
| import ( |
| "context" |
| "errors" |
| - "fmt" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -func TestAdoptLegacy(t *testing.T) { |
| +func TestAdoptAndDiscoveryCompletion(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)) |
| + o, err := Update(ctx, s, obs, Adopt(nh, "adi-knitting", []string{"uk"}, []string{"b2"}, "op_adopt", counter())) |
| if err != nil { |
| t.Fatal(err) |
| } |
| c := o.Claim |
| - if c.Holder.Location != "instances/"+testHost || c.Touched.Complete || c.Holder.Stamp != c.Gen || len(c.Obligations) != 3 { |
| + if c.Holder.Location != "instances/"+testHost || c.Touched.Complete || c.Touched.DiscoveryOp != "op_adopt" || |
| + c.Holder.Stamp != c.Gen || c.Op.Exec != "adi-knitting" || len(c.Obligations) != 2 { |
| t.Fatalf("adopt = %+v", c) |
| } |
| - // 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) { |
| + // A delete is refused while discovery chores are pending in its write set (CC11). |
| + if _, err := Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"})); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("delete during discovery = %v", err) |
| + } |
| + for _, ob := range c.Obligations { |
| + if o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if !o.Claim.Touched.Complete || o.Claim.Touched.DiscoveryOp != "" { |
| + t.Fatalf("discovery not completed: %+v", o.Claim.Touched) |
| + } |
| + if _, err := Update(ctx, s, o, Adopt(nh, "adi-knitting", nil, nil, "op_adopt2", counter())); !errors.Is(err, ErrWrongState) { |
| t.Fatalf("re-adopt = %v", err) |
| } |
| } |
| @@ -1136,35 +1313,40 @@ package hostclaim |
| import "fmt" |
| +// Adopt is the one adoption routine (CC8) for a host that has never had a |
| +// claim. It writes discovery chores for every other region and box (CC4). |
| func Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, opID string, newID func() string) func(*Claim) error { |
| return func(c *Claim) error { |
| - if c.Host != "" || c.Gen != 0 { |
| + if c.Gen != 0 || c.Holder != nil { |
| return fmt.Errorf("%w: adoption is only for a host that never had a claim", ErrWrongState) |
| } |
| - c.Host = nh.Host |
| + if nh.Host != c.Host || !keyMatches(nh) || servingBox == "" || opID == "" { |
| + return fmt.Errorf("%w: adoption inputs are not this host's", ErrWrongState) |
| + } |
| inc, err := Incarnation(nh.Account, nh.App, nh.Host, nh.CreatedAt) |
| if err != nil { |
| return err |
| } |
| c.Holder = &Holder{Family: nh.Family, Key: nh.Key, Account: nh.Account, App: nh.App, Name: nh.Name, |
| Incarnation: inc, CreatedAt: nh.CreatedAt, Location: "instances/" + nh.Host, Stamp: c.Gen + 1} |
| - c.Op = Op{Kind: KindServing, Exec: servingBox} |
| - c.Touched = Touched{Complete: false, Regions: []string{nh.Region}, Boxes: []string{servingBox}} |
| + c.Op = Op{Kind: KindServing, Exec: servingBox, Participants: []string{servingBox}} |
| + c.Touched = Touched{Complete: true, 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()}) |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID(), Owner: "region:" + r}) |
| } |
| for _, b := range otherBoxes { |
| - c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID()}) |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID(), Owner: "box:" + b}) |
| + } |
| + if len(c.Obligations) > 0 { |
| + c.Touched.Complete, c.Touched.DiscoveryOp = false, opID |
| } |
| 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. |
| +`c.Gen` is 0 here because `Update` starts an absent observation from a |
| +fresh claim. So `Stamp: c.Gen + 1` is the resulting gen, 1. |
| - [ ] **Step 4: Run the test to verify it passes** |
| @@ -1175,7 +1357,7 @@ Expected: PASS. |
| ```bash |
| git add internal/hostclaim/adopt.go internal/hostclaim/adopt_test.go |
| -git commit -m "hostclaim: one adoption routine with discovery obligations (CC4, CC8)" |
| +git commit -m "hostclaim: one adoption routine, discovery chores and their completion (CC4, CC8)" |
| ``` |
| --- |
| @@ -1187,13 +1369,8 @@ git commit -m "hostclaim: one adoption routine with discovery obligations (CC4, |
| - 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). |
| +- Consumes: `Observed`. |
| +- Produces: `IsOrphan(recordInc string, claimReadAfterRecord Observed) bool`. |
| - [ ] **Step 1: Write the failing test** |
| @@ -1237,8 +1414,9 @@ package hostclaim |
| // 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. |
| +// become the holder. A creating holder is named by Holder, so it is |
| +// covered. An absent claim is a legacy host (Q2), never an orphan. The |
| +// caller deletes only with DeleteIfMatch on the ETag it read. |
| func IsOrphan(recordInc string, claimReadAfterRecord Observed) bool { |
| if claimReadAfterRecord.Absent || claimReadAfterRecord.Claim == nil { |
| return false |
| @@ -1248,13 +1426,10 @@ func IsOrphan(recordInc string, claimReadAfterRecord Observed) bool { |
| } |
| ``` |
| -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** |
| +- [ ] **Step 4: Run the package tests** |
| -Run: `go test ./internal/hostclaim/ -count=1` |
| -Expected: PASS (the whole package). |
| +Run: `go test ./internal/hostclaim/ -count=1 -race` |
| +Expected: PASS. |
| - [ ] **Step 5: Commit** |
| @@ -1265,50 +1440,56 @@ git commit -m "hostclaim: the orphan predicate (record first, then claim; no cla |
| --- |
| -### 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. |
| +### Task 8: The repository gate |
| -- [ ] **Step 1: Run the full gate** |
| +- [ ] **Step 1: Run the full gate (AGENTS.md)** |
| ```bash |
| -export GOCACHE=$TMPDIR/gocache |
| -go build ./... && go vet ./internal/hostclaim/ && gofmt -l internal/hostclaim/ && go test ./internal/hostclaim/ -count=1 -race |
| +go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1 |
| ``` |
| -Expected: build and vet clean, `gofmt -l` prints nothing, and the tests |
| -PASS under `-race`. |
| +Expected: build and vet clean, `gofmt -l .` prints nothing, and every |
| +package PASSES. If a package fails only in a sandbox (AGENTS.md lists |
| +the known ones, such as `TestControlPlane*`), push and confirm it with |
| +`amadan ci status carlos/platform -branch <branch>`. Never skip it. |
| -- [ ] **Step 2: Commit** |
| +- [ ] **Step 2: Commit any formatting fixes** |
| ```bash |
| -git add -A internal/hostclaim AGENTS.md |
| -git commit -m "hostclaim: package gate" |
| +git add -A internal/hostclaim |
| +git commit -m "hostclaim: repository gate" || true |
| ``` |
| --- |
| ## 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. |
| +**Spec coverage (this plan's scope):** |
| + |
| +- §2 schema v2: Task 1. |
| +- C2, C5a and C6: Tasks 2 and 3. |
| +- §4 and CC11: Tasks 3 and 5. |
| +- §5.1, CC22 and R3-12, with the record body in intent: Task 4. |
| +- §5.2, keeping exec: Task 5. |
| +- §9, CC4 and CC8, with discovery completion: Tasks 5 and 6. |
| +- The orphan rule: Task 7. |
| +- `holder.stamp` at create and adoption: Tasks 4 and 6. |
| + |
| +**Astra's rev 1 findings:** |
| + |
| +| Finding | Where it's fixed | |
| +|---|---| |
| +| Create intent (P1) | Task 4 | |
| +| Adoption chores (P1) | Tasks 5 and 6 | |
| +| Delete keeps exec (P1) | Task 5 | |
| +| CC11 on delete (P1) | Task 5 | |
| +| Host binding (P1) | Tasks 2 and 4 | |
| +| Schema v2, inventory and owner (P2) | Task 1 | |
| +| Strict decode, kinds and identity (P2) | Tasks 1 and 2 | |
| +| Central gen (P2) | Task 2 | |
| +| Roadmap dependencies and activation boundary (P2) | Roadmap | |
| +| Repository gate (P3) | Task 8 | |
| + |
| +**Types used across tasks:** `Observed{Host,…}`, `Update`, `RequireOp`, |
| +`StartGuard`, `WriteSet`, `NewHolder`, `keyMatches`, `retire` and |
| +`counter` (test helper, Task 5, also used in Task 6). `record` and |
| +`newHolder` are defined in Task 4's test and used by Tasks 5 and 6. |
diff --git a/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md b/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md| index 4c460dc2f..64b4051d0 100644 |
| --- a/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md |
| +++ b/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md |
| @@ -944,6 +944,14 @@ that needs one. That claim is written create-only, with: |
| - `touched.regions` set to the record's region; |
| - `touched.boxes` set to the box, if it has local state. |
| +**Discovery completion (plan review, 2026-10-04).** The adoption CAS |
| +records its op id as `touched.discovery_op`. Each discovery obligation |
| +carries that op id. When the last obligation with that op id is |
| +completed, the same CAS sets `touched.complete = true` and clears |
| +`discovery_op`. Discovery obligations outlive the adoption itself, which |
| +returns straight to `serving`. They are matched by their own op id, not |
| +by the claim's current op. |
| + |
| **Discovery at adoption (CC4, J2 N5 note).** The adoption CAS also adds |
| a `park-replica` obligation for every deployment region other than the |
| record's, **and a `park-local` obligation for every deployment box other |