plan: Task 3 re-ruled — housekeeper reuses lease.Acquire (Paul 2026-08-20), store-CAS sketch void
1 file changed,
+53
−94
docs/superpowers/plans/2026-08-20-multi-region-writer-map-and-certs.md+53 −94
diff --git a/docs/superpowers/plans/2026-08-20-multi-region-writer-map-and-certs.md b/docs/superpowers/plans/2026-08-20-multi-region-writer-map-and-certs.md| index 046849599..4298c70a1 100644 |
| --- a/docs/superpowers/plans/2026-08-20-multi-region-writer-map-and-certs.md |
| +++ b/docs/superpowers/plans/2026-08-20-multi-region-writer-map-and-certs.md |
| @@ -220,6 +220,16 @@ git commit -m "docs: bucket-writer audit — every write call site ruled for N b |
| ### Task 3: Housekeeper lease; releases prune moves behind it |
| +**RULED (Paul, 2026-08-20, superseding the original sketch):** the |
| +housekeeper reuses `internal/lease.Acquire` — the SAME mechanism the |
| +status publisher elects with (audit row: lss3.Leaser lock at |
| +`<status-prefix>/status-lease/lock.json`) — on its own path. No |
| +store-CAS lease, no `control/housekeeper.json`, no 90s TTL: the lease |
| +package's fixed 15-minute TTL is accepted (a dead housekeeper hands over |
| +in <=15min; renewals run ~30 days early and the prune is idempotent, so |
| +nothing cares). The original sketch below this ruling is VOID where it |
| +conflicts. |
| + |
| **Files:** |
| - Create: `internal/host/housekeeper.go` |
| - Test: `internal/host/housekeeper_test.go` |
| @@ -228,104 +238,53 @@ git commit -m "docs: bucket-writer audit — every write call site ruled for N b |
| **Interfaces:** |
| - Consumes: `retentionPass(ctx, store, pub, reg)` from |
| - `internal/host/retention.go:26` (unchanged signature); the status |
| - publisher's election in `agent.go`'s statusLoop as the pattern to |
| - mirror (read it first — reuse its store client and clock idioms, and |
| - if a helper can be shared without contortion, share it). |
| -- Produces: `acquireHousekeeper(ctx context.Context, s release.Store, |
| - box string, now time.Time) (held bool, err error)` — Task 4 gates |
| - issuance on it. |
| - |
| -- [ ] **Step 1: Failing tests** — CAS contention and expiry: |
| - |
| -```go |
| -func TestHousekeeperSingleHolder(t *testing.T) { |
| - s := newFakeStore(t) |
| - now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) |
| - heldA, err := acquireHousekeeper(context.Background(), s, "annie", now) |
| - if err != nil || !heldA { |
| - t.Fatalf("annie should acquire: held=%v err=%v", heldA, err) |
| - } |
| - heldB, err := acquireHousekeeper(context.Background(), s, "betty", now.Add(10*time.Second)) |
| - if err != nil || heldB { |
| - t.Fatalf("betty must NOT hold while annie's lease is live: held=%v err=%v", heldB, err) |
| - } |
| - // After expiry betty takes over. |
| - heldB, err = acquireHousekeeper(context.Background(), s, "betty", now.Add(housekeeperTTL+time.Second)) |
| - if err != nil || !heldB { |
| - t.Fatalf("betty should acquire after expiry: held=%v err=%v", heldB, err) |
| - } |
| -} |
| - |
| -func TestHousekeeperRenewsOwnLease(t *testing.T) { |
| - s := newFakeStore(t) |
| - now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) |
| - if held, _ := acquireHousekeeper(context.Background(), s, "annie", now); !held { |
| - t.Fatal("first acquire") |
| - } |
| - // Same box re-acquiring mid-TTL renews rather than being locked out. |
| - if held, _ := acquireHousekeeper(context.Background(), s, "annie", now.Add(housekeeperTTL/2)); !held { |
| - t.Fatal("holder must renew its own live lease") |
| - } |
| -} |
| -``` |
| + `internal/host/retention.go:26` (unchanged signature); |
| + `lease.Acquire` exactly as the status publisher consumes it — read |
| + `internal/host/agent.go:1895` and its surrounding |
| + acquire/hold/release/skip structure FIRST and mirror it, including |
| + `shouldSkipPublisherElection`-style fleet-box short-circuiting if the |
| + status path has one. |
| +- Produces: `housekeeperHeld() bool` — a cheap, non-blocking check the |
| + agent tick (and Task 4's edge issuance gate) can consult; backed by a |
| + long-lived acquire/renew loop like the publisher lease, at prefix |
| + `control/housekeeper-lease` in the DEPLOYMENT bucket (lock lands at |
| + `control/housekeeper-lease/lock.json`). |
| + |
| +- [ ] **Step 1: Read the publisher's election end-to-end** — |
| + `agent.go:1895` region, `internal/lease` `Acquire`(:187)/`Held.Release`(:233)/ |
| + `startRenewer`(:109), and how the status loop degrades when NOT held. |
| + Your housekeeper must degrade the same way: not-held is a normal, |
| + quiet state (debug-level at most), never an error loop. |
| + |
| +- [ ] **Step 2: Failing test.** Test what is unit-testable without a |
| + live S3: the gating. Structure `housekeeper.go` so the |
| + election-transport is injected the way the status path does it (or via |
| + a small seam), and write tests proving: (a) when the seam reports |
| + held, `runHousekeeperDuties` invokes the prune callback exactly once |
| + per tick; (b) when not held, the callback is never invoked and no |
| + error is logged; (c) a fleet-shaped box (the publisher's skip |
| + condition) never even attempts acquisition. If the existing lease |
| + package has a test harness for lock behaviour (check |
| + `internal/lease/*_test.go`), reuse it for one acquire/contend test; |
| + if it only tests against real S3-compatible backends, do NOT invent a |
| + fake S3 — the gating tests plus the shared mechanism's own coverage |
| + suffice, and you note that boundary in your report. |
| + |
| +- [ ] **Step 3: Implement** `housekeeper.go`: a `startHousekeeper` |
| + mirroring the publisher's acquire/renew/release lifecycle on |
| + `control/housekeeper-lease`, exposing `housekeeperHeld() bool` |
| + (atomic flag maintained by the loop), plus `runHousekeeperDuties` |
| + taking the duty callbacks. Gate `retentionPass` in `agent.go` behind |
| + it: held → prune as today; not held → skip quietly. Single-box |
| + deployments always hold within one acquire round — provably inert. |
| + |
| +- [ ] **Step 4: Full package test** `go test ./internal/host/ -count=1`. |
| -- [ ] **Step 2: Verify failure** — `go test ./internal/host/ -run |
| - Housekeeper -count=1` → FAIL, undefined. |
| - |
| -- [ ] **Step 3: Implement `housekeeper.go`:** |
| - |
| -```go |
| -// Package-internal single election for deployment-global duties (spec |
| -// 2026-08-20 Part 1): releases retention prune (this plan) and cert |
| -// issuance (Task 4). ONE lease, several duties — never a second election. |
| -const ( |
| - housekeeperKey = "control/housekeeper.json" |
| - housekeeperTTL = 90 * time.Second |
| -) |
| - |
| -type housekeeperLease struct { |
| - Box string `json:"box"` |
| - Expires time.Time `json:"expires"` |
| -} |
| - |
| -// acquireHousekeeper CAS-writes the lease if absent, expired, or already |
| -// ours (renewal). It never blocks; a false return means another box |
| -// holds a live lease and the caller simply skips housekeeper duties this |
| -// tick — duties are idempotent and re-run within a TTL of any handover. |
| -func acquireHousekeeper(ctx context.Context, s release.Store, box string, now time.Time) (bool, error) |
| -``` |
| - |
| -Implementation mirrors the status publisher election: `Get` the key; if |
| -present, unmarshal, and return true early only when `Box == box || |
| -now.After(Expires)` fails the other way — i.e. contend only when absent, |
| -expired, or self-owned; write with the store's conditional-PUT (the same |
| -CAS the publisher election and `putReplacing` use — reuse, don't |
| -duplicate; if extraction of a shared helper from the statusLoop election |
| -is clean, do it and note it in the report). Losing the CAS race returns |
| -`(false, nil)`. |
| - |
| -- [ ] **Step 4: Gate the prune.** In `agent.go`, wrap the existing |
| - `retentionPass` call: |
| - |
| -```go |
| -held, err := acquireHousekeeper(ctx, store, boxName, time.Now()) |
| -if err != nil { |
| - log.Printf("housekeeper: election error (prune skipped this tick): %v", err) |
| -} else if held { |
| - if err := retentionPass(ctx, store, pub, reg); err != nil { /* existing handling */ } |
| -} |
| -``` |
| - |
| -(Exact surrounding names come from the call site — keep its logging |
| -style. On a single-box deployment the box always holds: provably inert.) |
| - |
| -- [ ] **Step 5: Package gate + commit** |
| +- [ ] **Step 5: Commit** |
| ```bash |
| -go test ./internal/host/ -count=1 |
| git add internal/host/housekeeper.go internal/host/housekeeper_test.go internal/host/agent.go |
| -git commit -m "host: housekeeper lease; releases prune single-holder (spec Part 1)" |
| +git commit -m "host: housekeeper via the one lease mechanism; prune single-holder" |
| ``` |
| --- |