carlos / platform Public

spec+plan: fleet API surface — the deployment bucket behind the edge (#73)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9361ff0f17bcfedb35178e3edfc6576314eb2ccc parent 79f6fc2
2 files changed, +208 −0
  • docs/superpowers/plans/2026-08-11-fleet-api-surface.md +97 −0
  • docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md +111 −0
diff --git a/docs/superpowers/plans/2026-08-11-fleet-api-surface.md b/docs/superpowers/plans/2026-08-11-fleet-api-surface.md
new file mode 100644
index 000000000..ff4d3a856
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-11-fleet-api-surface.md
@@ -0,0 +1,97 @@
+# Fleet API Surface Implementation Plan (issue #73)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fleet boxes lose direct platform-bucket S3: a `release.Store` spoken over HTTPS through the edge's reserved hostname, fleet-token-authed, account-scoped server-side — per `docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md` (F1–F8).
+
+**Architecture:** New edge handler beside the tunnel `/register` reusing the dynamic-auth table; new `internal/fleetapi` client implementing `release.Store`; env-driven store selection on the box (bucket set wins = fallback). No loop changes.
+
+**Tech Stack:** Go stdlib HTTP; no new deps.
+
+## Global Constraints
+
+- Branch `worktree-fleet-api` from main 79f6fc2. Gate per commit batch: `go build ./... && go vet ./... && go test ./... -count=1` (all packages), `gofmt -l` clean on touched files, `GOOS=darwin go build ./...`. Env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; sandbox off only on "read-only file system". Known flake: internal/activator TestUnitTenantFailedHealthStopsUnit — re-run once.
+- Security invariants from the spec §Security are binding on every task: byte-identical 404s (no oracle), constant-time token compare, static names never authenticate the door, canonicalization before ACL, no token logging.
+- Edge test suite runs -race in CI: run `go test ./internal/edge/ -count=1 -race` in any task touching internal/edge.
+- Wire shape additive; platform boxes' S3 behavior byte-identical (regression-pinned in Task 4).
+
+---
+
+### Task 1: `TunnelHub.VerifyDynamic` — auth extraction
+
+**Files:** Modify `internal/edge/tunnel.go` (ServeHTTP dynamic branch ~L129-138); Test `internal/edge/tunnel_test.go`.
+
+**Interfaces — Produces:** `func (h *TunnelHub) VerifyDynamic(name, token string) (fleet string, ok bool)` — ok only when `name` contains "/" AND is present in h.dynamicAuth AND the sha256-hex of token constant-time-matches; fleet = the segment before the first "/". ServeHTTP's dynamic branch calls it (behavior byte-identical: same single-401, viaDynamic = ok).
+
+Steps (TDD): failing tests — static name refused even with correct plaintext in h.clients; unknown dynamic name refused; wrong token refused; right token → (fleet, true); after SetDynamicAuth swap removing the name, refused. Then extract, re-run edge suite with -race, commit `edge: VerifyDynamic — the dynamic-auth check as a reusable gate`.
+
+### Task 2: the store door — edge handler + canonicalization + ACL
+
+**Files:** Create `internal/edge/storedoor.go`, `internal/edge/storedoor_test.go`. Modify `internal/edge/edge.go` (reserved-host routing: today it hands the reserved host to TunnelHub — route `/store/` paths to the new handler first, `/register` to the hub unchanged) and `internal/edge/tunnel.go` only if the hub needs a getter for the door.
+
+**Interfaces — Consumes:** Task 1's `VerifyDynamic`; the edge's existing deployment store handle (find how edge.go builds/holds its release store — reuse, do not open a second S3 client) and its registry handle (`e.reg.GetFleet(name)` for fleet→account sqid). **Produces:**
+
+```go
+// storeDoor serves release.Store over HTTPS for fleet boxes (spec F1-F4).
+type storeDoor struct {
+ hub *TunnelHub // VerifyDynamic
+ store release.Store // the deployment bucket, edge's own handle
+ fleetAccount func(fleet string) (sqid string, ok bool) // registry lookup, cached per reload like FleetChannelAuth consumers
+}
+// Routes (reserved host only):
+// GET /store/o/<key> → Get (200 body + ETag; 404)
+// PUT /store/o/<key> → Put (If-Match or If-None-Match:*; 200 new ETag; 412)
+// DELETE /store/o/<key> → Delete (204 always for in-scope keys)
+// GET /store/list?prefix=<p> → List (200 JSON array of keys)
+// GET /store/dirs?prefix=<p> → ListDirs (200 JSON array)
+func canonicalKey(raw string) (string, bool) // refuses "..", "//", leading "/", empty, >1024
+func aclRead(sqid, fleet, key string) bool // control/serial | bootstrap/carlos | accounts/<sqid>.json | apps/<sqid>/**
+func aclWrite(fleet, key string) bool // fleets/<fleet>/** only (write AND delete)
+func aclListScope(sqid, fleet, prefix string) (string, bool) // maps "apps/"→"apps/<sqid>/", "accounts/"→allowed-with-filter, "fleets/<fleet>/..."→itself; else false
+```
+
+Every refusal — bad auth, bad key, out-of-scope, missing object — is `http.NotFound(w, r)` after auth ran; auth runs exactly once per request; the handler on any NON-reserved host is unreachable (assert in test via the edge's routing, mirroring how tunnel registration is host-gated).
+
+Steps (TDD): the ACL matrix table test FIRST (every inventory key class from the spec × op × own/other → allow/404 — write ~25 rows, exact keys like `apps/ab12/app/channels/stable.json`, `apps/OTHER/app/...`, `fleets/pi/bindings/h.json`, `fleets/other/...`, `control/serial`, `bootstrap/carlos`, `accounts/ab12.json`, `accounts/other.json`, `../etc`, `apps/ab12/../OTHER/x`); handler tests over httptest with a DirStore: CAS round-trip (Put no-precondition → 400; If-Match stale → 412; fresh → 200 new etag), Get missing → 404 byte-identical to out-of-scope 404, list scoping strips other accounts, static-name auth → 404s identical too. Then implement; `-race` suite; commit `edge: the store door — release.Store over HTTPS, account-scoped (spec F1-F4)`.
+
+### Task 3: `internal/fleetapi` — the client store
+
+**Files:** Create `internal/fleetapi/fleetapi.go`, `internal/fleetapi/fleetapi_test.go`.
+
+**Interfaces — Consumes:** Task 2's wire (test against the real storeDoor over httptest, not a hand-rolled fake). **Produces:**
+
+```go
+// New builds a release.Store speaking the edge store door.
+// base = CARLOS_TUNNEL_URL's origin, name/token = the box's channel identity.
+func New(base, name, token string) (release.Store, error)
+```
+
+Semantics: Get returns (body, etag); Put sends If-Match (or If-None-Match:* when ifMatch==""... CHECK: read release.Store's Put contract for the empty-ifMatch meaning in DirStore/S3Store first and mirror it EXACTLY); 412 → `release.ErrCASConflict`; 404 on Get → the same not-found error S3Store returns (`os.ErrNotExist`-wrapped — check and mirror); Delete idempotent; List/ListDirs decode. Timeouts: 30s per call; artifact GETs must stream (return the body reader... CHECK Store.Get returns []byte — it does; keep []byte, buffered — fine for binaries the box already downloads whole today). No token in error strings.
+
+Steps: failing round-trip tests through a real storeDoor+DirStore; implement; commit `fleetapi: release.Store over the edge store door`.
+
+### Task 4: box-side selection + retention skip
+
+**Files:** Modify `internal/host/agent.go` (`openDeployStore` ~L1926; status-bucket resolution `resolveStatusBucket` consumers in statusLoop ~L1582 and metrics_loop.go ~L748; retentionLoop arming ~L276/1456); Test `internal/host/agent_test.go` (+ existing fixtures).
+
+**Interfaces — Consumes:** `fleetapi.New`, `resolveFleetIdentity` (fleetidentity.go). **Produces:** selection rule (spec F5): when `resolveFleetIdentity` succeeds AND the relevant bucket env (`CARLOS_DEPLOYMENT_BUCKET`/`_DIR` for openDeployStore; `CARLOS_STATUS_BUCKET` else deployment for status/metrics) is UNSET → `fleetapi.New(os.Getenv("CARLOS_TUNNEL_URL"), identity string, token)`; bucket set → S3/dir exactly as today. retentionLoop: fleet identity present → log once `agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)` and return.
+
+Tests: table over env permutations pinning which store type each path builds (export a tiny seam if needed — prefer a package-level `deployStoreForTest` accessor pattern ONLY if an existing test seam convention exists; otherwise test via behavior: fake edge httptest server + env pointing at it, assert a serial read hits the door). Regression pin: bucket-set env yields S3-backed behavior with zero requests to the door. Retention skip test. Commit `host: fleet boxes speak the store door; retention stays a platform job (spec F5/F6)`.
+
+### Task 5: e2e — the fleet loop through the door
+
+**Files:** Modify/extend `internal/edge/fleet_e2e_test.go` (or a new `fleetapi_e2e_test.go` beside it — read the existing harness first and extend, don't duplicate).
+
+Re-run the existing two-fake-box fleet e2e with the boxes' deployment store swapped for `fleetapi.New` pointed at the test edge: adoption converges, serial poll works, heartbeat lands under `fleets/<name>/status/...` via the door, binding takeover works, and the ACL blocks box A's fleet from reading a second account's keys planted in the store. Commit `e2e: fleet boxes converge with no bucket credentials (spec proof)`.
+
+### Task 6: docs + module + BYO contract
+
+**Files:** Modify `internal/console/templates/fleet_byo.html` (env contract: the no-bucket form becomes primary — CARLOS_TUNNEL_URL/NAME/TOKEN + hibernate bucket creds only; deployment-bucket form demoted to a "legacy direct-S3" note), its test assertion in `internal/console/accountfleet_test.go` if strings pinned; `infra/dev/region-pi.md` (flip note); `infra/modules/region-remote-host/{variables.tf,main.tf,README.md}` (`deployment_bucket_arn` optional — default `""`, IAM statement emitted only when non-empty; README reframes the customer artifact); spec status line.
+
+Gate + commit `docs+module: fleet boxes without platform-bucket credentials`.
+
+---
+
+## Self-review notes
+
+Spec coverage: F1→T2 routing; F2→T1; F3→T2 ACL; F4→T2+T3 CAS both ends; F5→T4; F6→T4; F7→T2 allowlist row; F8 is a non-feature (streaming = []byte Get, noted T3). Security §: every invariant appears as a named test in T1/T2. Inventory classes all appear in the T2 matrix. Types: `VerifyDynamic` (T1) consumed T2; `storeDoor` internal; `fleetapi.New` (T3) consumed T4/T5. Open checks flagged inline (Put empty-ifMatch contract, not-found error shape) — implementers resolve against DirStore/S3Store as instructed, never invent.
diff --git a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
new file mode 100644
index 000000000..270e39971
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
@@ -0,0 +1,111 @@
+# Fleet API Surface: the deployment bucket behind the edge (issue #73)
+
+**Date:** 2026-08-11
+**Status:** Draft — designed autonomously in the unattended fleets session;
+Paul pre-approved the direction in issue #73 (2026-08-07: "don't paper over
+it with a bucket policy — abstract the bucket behind the carlos API").
+**Depends on:** customer fleets v1+v2 (merged, main 00c3344); the tunnel
+dynamic-auth table (edge/tunnel.go).
+**Closes:** #73. **Unblocks:** untrusted-customer fleets, the Amadan
+cross-account fleet, the Eleven→Carloku flip without platform-bucket creds.
+
+## The problem (from the #73 inventory, verified at 00c3344)
+
+A fleet box holds AWS credentials with RW on the PLATFORM's deployment
+bucket. Every consumer flows through `release.Store` handles built in
+exactly three places on the box: `openDeployStore` (agent.go, 10 call
+sites), and `release.NewS3Store` in statusLoop + metricsLoop (status-bucket
+resolution). What those handles actually touch, complete inventory:
+
+- **Reads**: `control/serial` (every 2s); `apps/<sqid>/<app>/…` channel
+ pointers, production flags, release manifests + artifacts, restart-gen,
+ instance records, config bundles + environments + config-gen, trash
+ markers; `accounts/` list + records; `bootstrap/carlos` has NO in-repo
+ fetch path today (pinfra pushes over SSM — a customer box has no update
+ path at all).
+- **Writes (CAS)**: heartbeats/metrics/alerts under the
+ `fleets/<name>/…` prefix rebase; fleet bindings at
+ `fleets/<name>/bindings/<host>.json` (unprefixed handle, same keyspace).
+- **Deletes**: metrics 48h prune under own prefix; `retentionLoop` would
+ delete RELEASE artifacts platform-wide — armed on any box with the
+ pubkey, which is exactly the tamper surface to close.
+- **Not on fleet boxes** (verified gates): status-lease election
+ (`shouldSkipPublisherElection`), `ReconcileFleetBindings`, domainsLoop
+ (no `PLATFORM_APPS_DOMAIN`). Instance leases + litestream live entirely
+ in the customer's own hibernate bucket — untouched by this spec.
+
+## Design: `release.Store` over HTTPS, ACL'd server-side
+
+One new mechanism, not N endpoints: an edge-hosted **store door** that
+speaks the five `release.Store` operations over plain HTTPS, authenticated
+per-request by the credential the box already holds (its fleet token), with
+a server-side ACL derived from the fleet's identity. The box swaps its S3
+store handle for an HTTP one; every loop — adoption, fast-converge,
+reconcile, config, status, metrics, bindings — runs unchanged.
+
+### Decisions
+
+| # | Decision | Why |
+|---|---|---|
+| F1 | **Transport is the edge's reserved tunnel hostname**, new paths beside `/register`: `GET/PUT/DELETE /store/o/<key>`, `GET /store/list?prefix=`, `GET /store/dirs?prefix=`. Same headers as the tunnel dial: `Authorization: Bearer <token>` + `Carlos-Tunnel-Name: <fleet>/<label>`. | The reserved host is already TLS'd, already routed, already carries the only box-shaped credential the platform can verify. No new hostname, cert, or credential mint. |
+| F2 | **Auth = the dynamic-auth table**, via a new `TunnelHub.VerifyDynamic(name, token) (fleet string, ok bool)` refactored from ServeHTTP's dynamic branch (constant-time hash compare, single-401 shape preserved; static `CARLOS_TUNNEL_CLIENTS` names never authenticate here — this door is for fleet boxes only). | One credential, one hash table, one revocation path: detach/rotate already converge to the edge in seconds and now revoke API access with the same motion. |
+| F3 | **ACL is account-scoped + own-prefix**, derived server-side from the authenticated fleet's registry row (`fleets.account` → sqid): READ allows `control/serial`, `bootstrap/carlos`, `accounts/<own-sqid>.json`, and `apps/<own-sqid>/**`; list of `accounts/` and `apps/` answers only the own-account subset. WRITE/DELETE allow `fleets/<own-fleet>/**` only. Everything else: byte-identical 404 (never 403 — the keyspace must not be an oracle). | This is the issue's "account-scoped surface" literally: a fleet reads only its owning account's estate and writes only its own corner. A compromised customer box can no longer read other tenants or touch platform releases. |
+| F4 | **CAS passes through**: Get returns the backing etag in `ETag`; Put requires `If-Match` (or `If-None-Match: *` for create), maps conflict to 412; the client maps 412 back to `release.ErrCASConflict`. Deletes are idempotent 204. | The lease-adjacent writers (heartbeat CAS, binding takeover guard) keep their exact semantics through the proxy. |
+| F5 | **Client = `internal/fleetapi.Store`**, implementing `release.Store` over the F1 wire with the box's existing `CARLOS_TUNNEL_URL`/`_NAME`/`_TOKEN` env. Selection: `openDeployStore` (and the status/metrics bucket resolution) returns the API store **when fleet identity resolves AND `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_STATUS_BUCKET` is unset**; a bucket still set wins (the issue's migration fallback — platform boxes and grandfathered fleet boxes keep direct S3, byte-for-byte unchanged). | Env-driven cutover, box by box, no flag day: drop the bucket var + AWS creds from a box's env and it is on the API; put them back and it is off. |
+| F6 | **retentionLoop does not arm on a fleet box** (fleet identity present → skip with one log line). | A customer box scheduling deletes of platform-bucket releases was wrong under direct S3 too; the ACL would refuse it anyway — skipping avoids an hourly error loop. Platform boxes keep retention. |
+| F7 | **`bootstrap/carlos` is readable through the door** (F3 allowlist). | Gives customer boxes their first sanctioned self-update path (#73's §9 gap): customer tooling can fetch the platform binary with the fleet token; sha256 discipline stays the operator's, as with pinfra's script. |
+| F8 | **Artifact reads stream** (no presigned-URL redirect in v1). | Presigning re-exposes the bucket identity this spec exists to hide; edge egress for one fleet's artifacts is small, and the edge already streams tunnel traffic. Revisit only if a real fleet's adoption traffic hurts. |
+
+### What this deletes (when a box flips)
+
+`CARLOS_DEPLOYMENT_BUCKET` and the platform-bucket half of
+`/etc/carlos/credentials` on fleet boxes; the cross-account bucket-policy
+requirement (Amadan); the "trusted customers only" caveat on fleets. The
+`region-remote-host` module's `deployment_bucket_arn` input becomes
+optional (omitted = fleet-API consumer; the grant is not emitted).
+
+## Security invariants (test-pinned)
+
+- The door answers ONLY on the reserved hostname; every other host 404s it.
+- Auth failures, unknown keys, and out-of-scope keys are byte-identical
+ 404s after a single constant-time verify; no response distinguishes
+ "wrong token" / "no such key" / "someone else's key".
+- Static tunnel names (no `/`) never authenticate; a detached box's token
+ fails from the moment `SetDynamicAuth` swaps the table (same bound as
+ tunnel revocation).
+- Key canonicalization refuses `..`, empty segments, leading `/`, and
+ keys >1024 bytes BEFORE the ACL runs; the ACL is prefix-string exact.
+- The proxy never logs tokens, and never forwards client-supplied AWS
+ headers; it holds the deployment store handle the edge already has.
+
+## Testing
+
+Unit: ACL matrix table (every §inventory key class × read/write/delete ×
+own/other account → allow/404); VerifyDynamic refutes static names, wrong
+tokens, detached boxes; canonicalization rejects. Integration: fleetapi
+client against a real edge handler over `httptest` backed by a DirStore —
+Get/Put CAS round-trip incl. 412→ErrCASConflict, List/ListDirs prefix
+strip, artifact streaming; then the FULL existing fleet e2e (two fake
+boxes) re-run with the boxes' deploy store swapped to the API client —
+adoption, serial poll, reconcile, heartbeat, binding takeover all through
+the door. Fallback: bucket-set env still builds S3 stores (platform boxes
+regression-pinned).
+
+## Rollout
+
+1. This branch: edge door + client + env switch + F6 + docs (BYO page env
+ contract gains the no-bucket form; region-pi.md notes the flip).
+2. Platform binary roll (edge gains the door; no behavior change for any
+ existing box).
+3. Pi fleet flips first (drop bucket env, keep hibernate creds), then
+ Amadan joins API-native (no cross-account policy ever), then Eleven per
+ its runbook.
+4. `region-remote-host` module: `deployment_bucket_arn` optional, README
+ reframed (customer artifact = hibernate-bucket-only IAM).
+
+## Out of scope
+
+Presigned redirects (F8); per-app (finer than account) ACL; device tokens
+for non-fleet boxes; moving platform boxes off S3; hibernate-bucket
+abstraction (the customer owns that bucket by design); revoking the pi
+fleet's existing IAM user (operator step after its flip).