plan: claim Plan 8b rev 5, legacy updates need both floor objects absent (review round 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+28
−4
docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md+28 −4
CI cancelled — superseded by a newer push — run details
diff --git a/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md b/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md| index fb44ac457..be20b2aed 100644 |
| --- a/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md |
| +++ b/docs/superpowers/plans/2026-10-06-host-lifecycle-claim-8b-floor-roster.md |
| @@ -1,6 +1,6 @@ |
| # Host lifecycle claim, Plan 8b: the floor, the roster and live fencing |
| -Rev 4 answers review round 3 (three P2). |
| +Rev 4 answers review round 3 (three P2). Rev 5 (controller) requires both the floor and its authorization to be absent before the legacy update path (review round 4); the Task 5 test step must cover both incomplete pairs (floor without authorization, authorization without floor), each refusing with hostfloor.ErrFloor. |
| Implementation plan, 2026-10-06. Branch to implement: `feat/hostclaim-floor`, |
| from `feat/hostclaim-leases` at |
| @@ -5957,6 +5957,26 @@ func activationFixture(t *testing.T) (*Server, hostactors.Directory, release.Sys |
| return s, hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, next |
| } |
| +// Legacy promotion needs both floor objects absent; either half alone is |
| +// an unfinished publication (review round 4). |
| +func TestHostUpdateReservationRefusesIncompleteFloorPair(t *testing.T) { |
| + for _, drop := range []string{hostfloor.Key, hostfloor.AuthKey} { |
| + t.Run(drop, func(t *testing.T) { |
| + s, _, target := activationFixture(t) |
| + etag := "" |
| + if _, e, err := s.cfg.Releases.Get(t.Context(), drop); err == nil { |
| + etag = e |
| + } |
| + if err := s.cfg.Releases.Delete(t.Context(), drop, etag); err != nil { |
| + t.Skipf("store cannot delete %s in this fixture: %v", drop, err) |
| + } |
| + if id, err := s.reserveHostUpdate(t.Context(), &target, "stable", "promote", release.SystemPromoteOptions{}); !errors.Is(err, hostfloor.ErrFloor) || id != "" { |
| + t.Fatalf("incomplete pair without %s = %q, %v; want ErrFloor", drop, id, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestHostUpdateReservationRetriesPublication(t *testing.T) { |
| for _, fault := range []string{"crash", "failed", "committed", "unreadable"} { |
| t.Run(fault, func(t *testing.T) { |
| @@ -7141,9 +7161,13 @@ func (s *Server) reserveHostUpdate(ctx context.Context, rel *release.SystemRelea |
| pub := release.RootSigner(s.cfg.Key).RootPublicKey() |
| floor, err := hostfloor.Read(ctx, s.cfg.Releases, pub, s.cfg.Bucket) |
| if errors.Is(err, os.ErrNotExist) { |
| - // An incomplete publication is not the old deployment mode. |
| - if _, _, e := s.cfg.Releases.Get(ctx, hostfloor.AuthKey); !errors.Is(e, os.ErrNotExist) { |
| - return "", hostfloor.ErrFloor |
| + // Legacy mode only when BOTH the floor and its authorization are |
| + // absent; any incomplete pair is an unfinished publication, never |
| + // the old deployment mode (review round 4). |
| + for _, key := range []string{hostfloor.Key, hostfloor.AuthKey} { |
| + if _, _, e := s.cfg.Releases.Get(ctx, key); !errors.Is(e, os.ErrNotExist) { |
| + return "", hostfloor.ErrFloor |
| + } |
| } |
| return "", nil |
| } |