carlos / platform Public

plan: Task 3 re-ruled — housekeeper reuses lease.Acquire (Paul 2026-08-20), store-CAS sketch void

Paul Campbell pushed by paul@keymail.dev 5c1ab413846a576fa863a3deb96906983e518ae3 parent a4bb6d6
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"
```
---