carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 1acaeb3f2e3748c716741a791dd28a4b68b33924 parent 066b31c
2 files changed, +557 −368
  • docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md +549 −368
  • docs/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