plan: host lifecycle claim Plan 1 rev 3, after Astra's round 2
Exact sidecar host identity (shared holderKeyMatches); Validate checks key, incarnation and free-has-no-chores; create checks the record body's identity; ToParking refuses a holderless purge fence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+95
−20
docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md+95 −20
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 c12b050bb..f29f80810 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 |
| @@ -185,10 +185,11 @@ func TestIncarnationStableAndLegacyDistinct(t *testing.T) { |
| } |
| func TestValidate(t *testing.T) { |
| - inc := strings.Repeat("a", 64) |
| + created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| + inc, _ := Incarnation("bab", "jam", testHost, created) |
| 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}, |
| + Incarnation: inc, CreatedAt: created, Location: "instances/" + testHost + "/" + inc}, |
| Op: Op{Kind: KindServing}} |
| if err := ok.Validate(); err != nil { |
| t.Fatalf("valid claim: %v", err) |
| @@ -198,7 +199,13 @@ func TestValidate(t *testing.T) { |
| "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 }, |
| + "wrong incarnation": func(c *Claim) { c.Holder.Incarnation = strings.Repeat("a", 64) }, |
| + "foreign key": func(c *Claim) { c.Holder.Key = "apps/zzz/jam/instances/" + testHost + ".json" }, |
| + "free with chores": func(c *Claim) { |
| + c.Holder, c.Op = nil, Op{} |
| + c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-local", Box: "b"}} |
| + }, |
| + "old schema": func(c *Claim) { c.V = 1 }, |
| } |
| for name, mut := range bad { |
| raw, _ := json.Marshal(ok) |
| @@ -358,6 +365,26 @@ func Incarnation(account, app, host string, created time.Time) (string, error) { |
| return maintenanceguard.RecordIdentity(account, app, host, created) |
| } |
| +// sidecarSuffix restates provision.SidecarSuffix (no import: avoids a cycle). |
| +const sidecarSuffix = ".sidecar.internal" |
| + |
| +// holderKeyMatches is the one identity check for a holder's record key and |
| +// host, shared by Validate, BeginCreate and Adopt. An instance's key is |
| +// exactly apps/<acct>/<app>/instances/<host>.json. A sidecar's key is |
| +// apps/<acct>/<app>/sidecars/<name>.json, and its host is exactly |
| +// <name>.<app>.<acct>.sidecar.internal (provision.SidecarHost). |
| +func holderKeyMatches(family, key, account, app, name, host string) bool { |
| + base := "apps/" + account + "/" + app + "/" |
| + switch family { |
| + case "instance": |
| + return name == "" && key == base+"instances/"+host+".json" |
| + case "sidecar": |
| + return controlname.ValidLabel(name) && key == base+"sidecars/"+name+".json" && |
| + host == name+"."+app+"."+account+sidecarSuffix |
| + } |
| + return false |
| +} |
| + |
| func isHex64(s string) bool { |
| if len(s) != 64 { |
| return false |
| @@ -380,12 +407,21 @@ func (c *Claim) Validate() error { |
| 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) |
| } |
| + if len(c.Obligations) > 0 { |
| + return fmt.Errorf("%w: a free claim with outstanding obligations", ErrMalformed) |
| + } |
| } else { |
| h := c.Holder |
| 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) |
| } |
| + if !holderKeyMatches(h.Family, h.Key, h.Account, h.App, h.Name, c.Host) { |
| + return fmt.Errorf("%w: holder key %q is not this host's", ErrMalformed, h.Key) |
| + } |
| + if want, err := Incarnation(h.Account, h.App, c.Host, h.CreatedAt); err != nil || want != h.Incarnation { |
| + return fmt.Errorf("%w: incarnation does not match the holder's creation fields", 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) |
| @@ -455,6 +491,8 @@ func TestReadRefusesMalformed(t *testing.T) { |
| "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}}`, |
| + "free with chores": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},` + |
| + `"obligations":[{"id":"o","op":"x","kind":"park-local","box":"b","retirement":"r","owner":"box:b"}]}`, |
| } { |
| s2 := release.NewDirStore(t.TempDir()) |
| if _, err := s2.Put(ctx, key, []byte(body), ""); err != nil { |
| @@ -806,7 +844,7 @@ 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"}`) |
| +var record = []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","created_at":"2026-10-04T01:00:00Z"}`) |
| func TestCreateLifecycle(t *testing.T) { |
| ctx := context.Background() |
| @@ -847,6 +885,19 @@ func TestBeginCreateRefusesAnotherHostOrKey(t *testing.T) { |
| if _, err := Update(ctx, s, reg, BeginCreate(badKey, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| t.Fatalf("mismatched record key = %v", err) |
| } |
| + other2 := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","created_at":"2026-10-04T09:00:00Z"}`) |
| + if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), other2, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("record body with another CreatedAt = %v", err) |
| + } |
| + if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), []byte("not json"), "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("record body not JSON = %v", err) |
| + } |
| + // A sidecar key cannot claim an instance-shaped host (P1, plan review round 2). |
| + sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/shop.json", Account: "bab", App: "jam", Name: "shop", |
| + Host: testHost, Region: "ie", CreatedAt: newHolder().CreatedAt} |
| + if _, err := Update(ctx, s, reg, BeginCreate(sc, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("sidecar key on a non-sidecar host = %v", err) |
| + } |
| } |
| func TestCancelRetiresAndRefusesReuse(t *testing.T) { |
| @@ -877,9 +928,9 @@ Expected: FAIL, with "undefined: BeginCreate". |
| package hostclaim |
| import ( |
| + "encoding/json" |
| "errors" |
| "fmt" |
| - "strings" |
| "time" |
| ) |
| @@ -890,18 +941,24 @@ 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 holderKeyMatches(nh.Family, nh.Key, nh.Account, nh.App, nh.Name, nh.Host) |
| +} |
| + |
| +// recordMatches checks the record body that create persists as its |
| +// reconstruction intent is this holder's record: valid JSON naming the |
| +// same host, account and app, and the same CreatedAt. |
| +func recordMatches(nh NewHolder, body []byte) bool { |
| + var r struct { |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + CreatedAt time.Time `json:"created_at"` |
| + } |
| + if err := json.Unmarshal(body, &r); err != nil { |
| + return false |
| } |
| - return false |
| + return r.Host == nh.Host && r.Account == nh.Account && r.App == nh.App && r.CreatedAt.Equal(nh.CreatedAt) |
| } |
| func retire(c *Claim, inc string) { |
| @@ -919,8 +976,11 @@ func BeginCreate(nh NewHolder, recordBody []byte, opID, by string, now time.Time |
| if !c.Free() { |
| return fmt.Errorf("%w: host is held", ErrWrongState) |
| } |
| - if nh.CreatedAt.IsZero() || len(recordBody) == 0 || opID == "" { |
| - return fmt.Errorf("%w: create needs CreatedAt, a record body and an op id", ErrMalformed) |
| + if nh.CreatedAt.IsZero() || opID == "" { |
| + return fmt.Errorf("%w: create needs CreatedAt and an op id", ErrMalformed) |
| + } |
| + if !recordMatches(nh, recordBody) { |
| + return fmt.Errorf("%w: the record body is not this holder's record", ErrWrongState) |
| } |
| inc, err := Incarnation(nh.Account, nh.App, nh.Host, nh.CreatedAt) |
| if err != nil { |
| @@ -971,7 +1031,8 @@ func CancelCreate(opID string, now time.Time) func(*Claim) error { |
| } |
| ``` |
| -The key shapes are confirmed against main: |
| +`holderKeyMatches` (Task 1) is the shared identity check. 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 |
| @@ -1106,6 +1167,13 @@ func TestIncompleteTouchedParksEverywhere(t *testing.T) { |
| } |
| } |
| +func TestToParkingRefusesAPurgeFence(t *testing.T) { |
| + fence := &Claim{V: 2, Host: testHost, Gen: 3, Op: Op{Kind: KindPurging, ID: "op_p"}} |
| + if err := ToParking("op_p", nil, nil, counter())(fence); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("purge fence = %v, want ErrWrongState", err) |
| + } |
| +} |
| + |
| func TestRetiredCapKeepsNewest32(t *testing.T) { |
| c := &Claim{} |
| for i := 0; i < 33; i++ { |
| @@ -1119,7 +1187,7 @@ func TestRetiredCapKeepsNewest32(t *testing.T) { |
| - [ ] **Step 2: Run the test to verify it fails** |
| -Run: `go test ./internal/hostclaim/ -run 'TestDelete|TestIncomplete|TestRetiredCap' -count=1` |
| +Run: `go test ./internal/hostclaim/ -run 'TestDelete|TestIncomplete|TestToParking|TestRetiredCap' -count=1` |
| Expected: FAIL, with "undefined: BeginDelete". |
| - [ ] **Step 3: Write the minimal implementation** |
| @@ -1167,6 +1235,9 @@ func ToParking(opID string, allRegions, allBoxes []string, newID func() string) |
| if c.Op.Kind != KindDeleting && c.Op.Kind != KindPurging { |
| return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| } |
| + if c.Holder == nil { // a holderless purge fence (CC21) has nothing to park |
| + return fmt.Errorf("%w: no holder to park", ErrWrongState) |
| + } |
| 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(), Owner: "region:" + r}) |
| @@ -1223,7 +1294,7 @@ func FreeAfterPark(opID string) func(*Claim) error { |
| - [ ] **Step 4: Run the test to verify it passes** |
| -Run: `go test ./internal/hostclaim/ -run 'TestDelete|TestIncomplete|TestRetiredCap' -count=1` |
| +Run: `go test ./internal/hostclaim/ -run 'TestDelete|TestIncomplete|TestToParking|TestRetiredCap' -count=1` |
| Expected: PASS. |
| - [ ] **Step 5: Commit** |
| @@ -1488,6 +1559,10 @@ git commit -m "hostclaim: repository gate" || true |
| | Central gen (P2) | Task 2 | |
| | Roadmap dependencies and activation boundary (P2) | Roadmap | |
| | Repository gate (P3) | Task 8 | |
| +| Round 2: exact sidecar host check (P1) | Task 1 (`holderKeyMatches`), tested in Task 4 | |
| +| Round 2: semantic Validate (P2) | Task 1, plus malformed reads in Task 2 | |
| +| Round 2: record body identity (P2) | Task 4 | |
| +| Round 2: purge fence in ToParking (P2) | Task 5 | |
| **Types used across tasks:** `Observed{Host,…}`, `Update`, `RequireOp`, |
| `StartGuard`, `WriteSet`, `NewHolder`, `keyMatches`, `retire` and |