plan: host lifecycle claim Plan 1 rev 4, after Astra's round 3
CompleteCreate records the executor in touched.boxes; the record body must agree on sidecar name and region; Validate checks obligation kind, ids, location, owner and uniqueness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+76
−8
docs/superpowers/plans/2026-10-04-host-lifecycle-claim-1-library.md+76 −8
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 f29f80810..663e36152 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 |
| @@ -205,6 +205,19 @@ func TestValidate(t *testing.T) { |
| c.Holder, c.Op = nil, Op{} |
| c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-local", Box: "b"}} |
| }, |
| + "unknown chore kind": func(c *Claim) { |
| + c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "teleport", Region: "ie", Retirement: "r", Owner: "region:ie"}} |
| + }, |
| + "locationless chore": func(c *Claim) { |
| + c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-replica", Retirement: "r", Owner: "region:"}} |
| + }, |
| + "owner mismatch": func(c *Claim) { |
| + c.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-local", Box: "b", Retirement: "r", Owner: "box:other"}} |
| + }, |
| + "duplicate chore id": func(c *Claim) { |
| + ob := Obligation{ID: "o", Op: "x", Kind: "park-local", Box: "b", Retirement: "r", Owner: "box:b"} |
| + c.Obligations = []Obligation{ob, ob} |
| + }, |
| "old schema": func(c *Claim) { c.V = 1 }, |
| } |
| for name, mut := range bad { |
| @@ -433,6 +446,25 @@ func (c *Claim) Validate() error { |
| if len(c.RetiredIncarnations) > retiredCap { |
| return fmt.Errorf("%w: retired_incarnations over cap", ErrMalformed) |
| } |
| + seen := map[string]bool{} |
| + for _, o := range c.Obligations { |
| + if o.ID == "" || o.Op == "" || o.Retirement == "" || seen[o.ID] { |
| + return fmt.Errorf("%w: obligation ids", ErrMalformed) |
| + } |
| + seen[o.ID] = true |
| + switch o.Kind { |
| + case "park-replica": |
| + if o.Region == "" || o.Box != "" || o.Owner != "region:"+o.Region { |
| + return fmt.Errorf("%w: park-replica %q location/owner", ErrMalformed, o.ID) |
| + } |
| + case "park-local": |
| + if o.Box == "" || o.Region != "" || o.Owner != "box:"+o.Box { |
| + return fmt.Errorf("%w: park-local %q location/owner", ErrMalformed, o.ID) |
| + } |
| + default: |
| + return fmt.Errorf("%w: obligation kind %q", ErrMalformed, o.Kind) |
| + } |
| + } |
| return nil |
| } |
| ``` |
| @@ -493,6 +525,8 @@ func TestReadRefusesMalformed(t *testing.T) { |
| "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"}]}`, |
| + "unknown chore kind": `{"v":2,"host":"` + testHost + `","gen":1,"op":{"kind":"purging","id":"p"},"touched":{"complete":false},` + |
| + `"obligations":[{"id":"o","op":"x","kind":"teleport","region":"ie","retirement":"r","owner":"region:ie"}]}`, |
| } { |
| s2 := release.NewDirStore(t.TempDir()) |
| if _, err := s2.Put(ctx, key, []byte(body), ""); err != nil { |
| @@ -844,7 +878,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","account":"bab","app":"jam","created_at":"2026-10-04T01:00:00Z"}`) |
| +var record = []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| func TestCreateLifecycle(t *testing.T) { |
| ctx := context.Background() |
| @@ -862,6 +896,9 @@ func TestCreateLifecycle(t *testing.T) { |
| 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) |
| } |
| + if len(o.Claim.Touched.Boxes) != 1 || o.Claim.Touched.Boxes[0] != "adi-knitting" { |
| + t.Fatalf("executor not recorded as touched: %+v", o.Claim.Touched) |
| + } |
| if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); !errors.Is(err, ErrStaleOp) { |
| t.Fatalf("replay = %v, want ErrStaleOp", err) |
| } |
| @@ -885,13 +922,17 @@ 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"}`) |
| + other2 := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","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) |
| } |
| + wrongRegion := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"uk","created_at":"2026-10-04T01:00:00Z"}`) |
| + if _, err := Update(ctx, s, reg, BeginCreate(newHolder(), wrongRegion, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("record body in another region = %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} |
| @@ -900,6 +941,26 @@ func TestBeginCreateRefusesAnotherHostOrKey(t *testing.T) { |
| } |
| } |
| +func TestBeginCreateSidecar(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| + host := "bot.jam.bab.sidecar.internal" |
| + sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/bot.json", Account: "bab", App: "jam", Name: "bot", |
| + Host: host, Region: "ie", CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| + body := []byte(`{"name":"bot","host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| + reg, _ := Read(ctx, s, host) |
| + if _, err := Update(ctx, s, reg, BeginCreate(sc, body, "op_s1", "console", now)); err != nil { |
| + t.Fatalf("sidecar create = %v", err) |
| + } |
| + s2 := release.NewDirStore(t.TempDir()) |
| + reg2, _ := Read(ctx, s2, host) |
| + wrongName := []byte(`{"name":"other","host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| + if _, err := Update(ctx, s2, reg2, BeginCreate(sc, wrongName, "op_s1", "console", now)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("sidecar body with another name = %v", err) |
| + } |
| +} |
| + |
| func TestCancelRetiresAndRefusesReuse(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
| @@ -947,18 +1008,22 @@ func keyMatches(nh NewHolder) bool { |
| // 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. |
| +// same host, account, app, sidecar name (empty for instances) and region, |
| +// and the same CreatedAt. The region seeds touched.regions, so it must agree. |
| func recordMatches(nh NewHolder, body []byte) bool { |
| var r struct { |
| + Name string `json:"name"` // sidecar records only |
| Host string `json:"host"` |
| Account string `json:"account"` |
| App string `json:"app"` |
| + Region string `json:"region"` |
| CreatedAt time.Time `json:"created_at"` |
| } |
| if err := json.Unmarshal(body, &r); err != nil { |
| return false |
| } |
| - return r.Host == nh.Host && r.Account == nh.Account && r.App == nh.App && r.CreatedAt.Equal(nh.CreatedAt) |
| + return r.Host == nh.Host && r.Account == nh.Account && r.App == nh.App && r.Name == nh.Name && |
| + r.Region == nh.Region && r.CreatedAt.Equal(nh.CreatedAt) |
| } |
| func retire(c *Claim, inc string) { |
| @@ -1010,6 +1075,9 @@ func CompleteCreate(opID, execBox string) func(*Claim) error { |
| } |
| c.Op = Op{Kind: KindServing, Exec: execBox, Participants: []string{execBox}} |
| c.Holder.Stamp = c.Gen + 1 // the resulting gen: Update sets gen = observed+1 |
| + if !set(c.Touched.Boxes)[execBox] { // the executor will hold local state (§2 touched) |
| + c.Touched.Boxes = append(c.Touched.Boxes, execBox) |
| + } |
| return nil |
| } |
| } |
| @@ -1113,10 +1181,7 @@ func servingClaim(t *testing.T, ctx context.Context, s release.Store) Observed { |
| 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 |
| + return o // no fixture repair: CompleteCreate itself records adi-knitting in touched.boxes |
| } |
| func TestDeleteParkFree(t *testing.T) { |
| @@ -1563,6 +1628,9 @@ git commit -m "hostclaim: repository gate" || true |
| | 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 | |
| +| Round 3: executor recorded as touched (P1) | Task 4 `CompleteCreate`; Task 5's fixture no longer repairs it | |
| +| Round 3: sidecar name and region in the record body (P2) | Task 4 `recordMatches` and `TestBeginCreateSidecar` | |
| +| Round 3: obligation validation (P2) | Task 1 `Validate`, with malformed reads in Task 2 | |
| **Types used across tasks:** `Observed{Host,…}`, `Update`, `RequireOp`, |
| `StartGuard`, `WriteSet`, `NewHolder`, `keyMatches`, `retire` and |