docs: bucket-writer audit — every write call site ruled for N boxes
1 file changed,
+279
−0
docs/superpowers/specs/2026-08-20-bucket-writer-audit.md+279 −0
diff --git a/docs/superpowers/specs/2026-08-20-bucket-writer-audit.md b/docs/superpowers/specs/2026-08-20-bucket-writer-audit.md| new file mode 100644 |
| index 000000000..b5c7d361c |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-20-bucket-writer-audit.md |
| @@ -0,0 +1,279 @@ |
| +# Bucket-writer audit — every deployment-bucket write call site, ruled for N boxes (#238 stage 1) |
| + |
| +Status: stage-1 audit deliverable for |
| +`2026-08-20-multi-region-ingress-design.md` Part 1 (the writer map). Surveys |
| +the CURRENT tree (multi-region-writer-map-certs branch, after Task 1's |
| +per-box snapshot keys, commits b466af4+02f449a). |
| + |
| +**Recipe** (task-2-brief.md, verbatim): |
| + |
| +``` |
| +grep -rn "\.Put(\|\.Delete(\|putReplacing(" internal/ --include='*.go' | grep -v _test |
| +``` |
| + |
| +79 matches. Swept beyond that recipe for other store-write idioms: |
| +`s3.PutObject`, `PutObjectInput`, `DeleteObject(s)`, `.Upload(`, and a |
| +`.Write(` scan on store-shaped receivers (see Self-review). Grouped below by |
| +function/key, one row per distinct write site — where a CAS retry pattern |
| +puts the same key twice (a create attempt + one retry), both grep line |
| +numbers are cited on one row, matching the brief's own citation style |
| +(`internal/usage/edge.go:40` for a Put that's actually on line 93). |
| + |
| +**Ruling vocabulary**: `console-only` | `per-box keys` | `housekeeper` | |
| +`elected already` | `NEEDS RULING`, plus two escapes this sweep needed and |
| +used sparingly (see notes on each row): `N/A` (matched the grep but isn't a |
| +deployment-bucket write at all) and a spelled-out ruling where the site is |
| +safe but doesn't fit the five-word vocabulary cleanly. |
| + |
| +## Table |
| + |
| +| File:line | Key / prefix | Runner | Ruling | |
| +|---|---|---|---| |
| +| `internal/status/fleet.go:572,580` (`PublishFleet`→`putOverwrite`) | `status/fleet.json` | lease-elected publisher | elected already | |
| +| `internal/status/snapshot.go:540,548` (`WriteBox`) | `status/boxes/<label>.json` | every box, own key | per-box keys (already) | |
| +| `internal/appconfig/store.go:187` (`applyAt`, called by `Apply`/`SetEnv`) | `apps/<acct>/<app>/config.json` or `.../environments/<env>.json` | console (`apiconfig.go`) | console-only | |
| +| `internal/appconfig/store.go:288` (`SetExecDeliveryAt`) | same bundle keys | console (`apiconfig.go`) | console-only | |
| +| `internal/appconfig/generation.go:92` (`TouchGeneration`) | `apps/<acct>/<app>/config-gen.json` | console (`apiconfig.go`) **+ CLI direct** (`carlos env sync`, `openStore()`) | console-only (CLI direct-bucket path is CAS create-or-replace-only and idempotent; see "CLI direct-bucket" note below) | |
| +| `internal/usage/edge.go:93` (`UpsertEdgeDay`) | `usage/edge/<box>/days/<date>.json` | every box (`host/usage_loop.go`) | per-box keys (already) — matches brief's known-expected row | |
| +| `internal/ledger/manifest.go:88` (`UpdateManifest`) | `apps/<acct>/<app>/ledgers/<name>/manifest.json` | console (`apiledger.go`) | console-only | |
| +| `internal/ledger/blob.go:19` (`PutBlob`) | content-addressed blob key under `apps/<acct>/<app>/ledgers/<name>/` | console (`apiledger.go`) | console-only | |
| +| `internal/ledger/append.go:76,95` (`Append`, entry + head) | ledger entry key + `.../head.json` | console (`apiledger.go`) | console-only | |
| +| `internal/host/logship.go:128` (`logshipFlush`) | `applog.ChunkKey(acct, app, box, hour, seq, size)` — box embedded in the key | every box | per-box keys (already) | |
| +| `internal/host/logship.go:204` (`logshipPrune`) | deletes under `applog.BoxPrefix(acct, app, box)` — this box's own subtree only | every box | per-box keys (already) | |
| +| `internal/host/fleetbind.go:156,171` (`WriteFleetBinding`) | `fleetBindingKey(fleet, host)` — no platform-box component | every box (activator `OnWake` / agent startup) | **not lease, not per-box; already CAS+`takeover`-guard-arbitrated for concurrent writers by design** — see note below, not literally one of the five rulings | |
| +| `internal/host/metrics_loop.go:435` (`pruneStaleRaw`) | `metrics/boxes/<box>/raw/*` — this box's own | every box | per-box keys (already) | |
| +| `internal/host/metrics_loop.go:572,580` (`putAlertState`) | `metrics.AlertKey(box)` = `alerts/boxes/<box>.json` | every box | per-box keys (already) | |
| +| `internal/metrics/chunk.go:60,68` (`WriteRaw`) | `metrics/boxes/<box>/raw/<hour>.json` | every box | per-box keys (already) | |
| +| `internal/metrics/rollup.go:145,158` (`UpsertRollup`) | `metrics/boxes/<box>/days/<date>.json` | every box | per-box keys (already) | |
| +| `internal/host/snapshot_loop.go:105,126,134,144,230` | `control/registry-snapshot(s)/<box>.{db,json}` + generations | every box | per-box keys — **Task 1, done** (b466af4+02f449a) | |
| +| `internal/edge/storedoor.go:190` (PUT) | `fleets/<fleet>/...` or `apps/<sqid>/<app>/logs/...` (ACL-scoped: `aclWrite`) | every box serving the store door, **proxying** the connecting fleet member's write | every box, but the write identity is the fleet member and CAS is enforced by the same shared `release.Store` regardless of which box proxies — safe as-is, no per-box key needed | |
| +| `internal/edge/storedoor.go:211` (DELETE) | same ACL scope | every box (proxy) | same as above | |
| +| `internal/dbverify/dbverify.go:144` (`Publish`) | `control/restore-verify.json` — **one fixed key, no box component** | every box, via `carlos-hibernate-verify.timer` (systemd timer, every box — see `internal/host/units/carlos-hibernate-verify.timer`) | **NEEDS RULING** — see Findings §1 | |
| +| `internal/release/prune.go:129,136` (`DeleteVersion`) | `releases/<acct>/<app>/<version>/*` | every box (`host/retention.go` `retentionPass`) | **housekeeper (Task 3 pending)** — matches brief's known-expected row | |
| +| `internal/domains/reconcile.go` → `internal/release/trash.go:237,241` (`PurgeApp`) | `releases/<acct>/<app>/*` (whole trashed app) + `apps/<acct>/<app>/deleted.json` marker | **every box** (`host/agent.go` → `domains.Reconcile`'s auto-purge step) **and** console (`ownership.go`) | doc-asserted race-proof for N boxes already (see Findings §2) — ruled safe, flagged for controller confirmation, not literally NEEDS RULING | |
| +| `internal/release/serial.go:63` (`BumpSerial`) | `control/serial` | console only (`internal/console/serialbump.go`) | console-only | |
| +| `internal/release/release.go:598` (`putImmutable`, called by `Ship`) | `releases/<acct>/<app>/<version>/manifest.json` (content-addressed, create-only) | console (`apicli.go`, `server.go`) **+ CLI direct** (`carlos ship`, `openStore()`, `cli.go:1883`) | console-only (CLI direct path is create-only/idempotent-on-identical-bytes; see CLI direct-bucket note) | |
| +| `internal/release/release.go:722` (`Promote`) | `apps/<acct>/<app>/channels/<channel>.json` | console (`apicli.go`, `server.go`) **+ CLI direct** (`carlos promote`, `PromoteOnLadder`, `cli.go:2083`) | console-only (+ CLI direct, CAS read-etag-then-Put) | |
| +| `internal/release/release.go:989` (`Rollback`) | same channel-pointer key | console (`apicli.go`, `server.go`) **+ CLI direct** (`carlos rollback`, `cli.go:2462`) | console-only (+ CLI direct) | |
| +| `internal/release/release.go:1038` (`FastTrack`) | same channel-pointer key | console only (`server.go`) — no CLI caller found | console-only | |
| +| `internal/release/restartgen.go:100` (`SetRestartGen`) | `apps/<acct>/<app>/restart-gen.json` | console (`carlos restart -app` door) | console-only | |
| +| `internal/release/retention.go:103` (`SetRetention`) | `apps/<acct>/<app>/retention.json` | console (`apiretention.go`) | console-only | |
| +| `internal/release/retention.go:124` (`ClearRetention`) | same | console (`apiretention.go`) | console-only | |
| +| `internal/release/production.go:122` (`SetProduction`) | `apps/<acct>/<app>/production.json` (root-signed) | console (`server.go` bake controls) | console-only | |
| +| `internal/release/target.go:120` (`setTargetUnchecked`/`SetTarget`) | `apps/<acct>/<app>/target.json` | console (`apiversion.go`) **+ CLI direct** (`cli.go:2299`) | console-only (+ CLI direct) | |
| +| `internal/release/trash.go:50` (`TrashApp`) | `apps/<acct>/<app>/deleted.json` | console (`appsettings.go`, `apicli.go`) **+ CLI direct** (`apps.go:271`) | console-only (+ CLI direct, create-only) | |
| +| `internal/release/trash.go:99` (`RestoreApp`) | same | console (`ownership.go`, `apicli.go`) **+ CLI direct** (`apps.go:348`) | console-only (+ CLI direct) | |
| +| `internal/release/store.go:104,108` (`prefixStore.Put/Delete`) | pass-through — rebases the key onto a fleet's own prefix, then delegates to the wrapped `Store` | n/a — wrapper, not a distinct write | N/A (wrapper; the underlying write is whichever real store the fleet box holds — customer-fleets namespacing, out of platform-box scope) | |
| +| `internal/provision/provision.go:548` (`PutManifest`) | `apps/<acct>/<app>/instances.json` | console — **no HTTP caller found**; only caller is CLI direct (`instances_cli.go:202`, `carlos instances enable`, store-mode) | console-only in spirit (CLI direct-bucket, CAS upsert-with-retry) — see CLI direct-bucket note | |
| +| `internal/provision/provision.go:573` (`PutManifestIfMatch`) | same | console (`apiinstances.go`) | console-only | |
| +| `internal/provision/provision.go:585` (`CreateRecord`) | `apps/<acct>/<app>/instances/<host>.json` | console (`domainattach.go`, `apiinstances.go`) | console-only | |
| +| `internal/provision/provision.go:626` (`SetRecordRegion`) | same | console (`fleets.go`) | console-only | |
| +| `internal/provision/provision.go:642` (`DeleteRecord`) | same | console (`domainattach.go`, `apiinstances.go`, `apidomains.go`) | console-only | |
| +| `internal/provision/provision.go:679` (`UpdateRecord`) | same | console (`apiinstances.go`) | console-only | |
| +| `internal/provision/reconcile.go:667` (`reg.Delete`) | **local SQLite registry**, not a bucket key | every box, own registry | N/A — not a bucket write | |
| +| `internal/domains/reconcile.go:248,397` (`reg.Delete`) | **local SQLite registry**, not a bucket key | every box, own registry | N/A — not a bucket write | |
| +| `internal/domains/mint.go:199` (`MintAccount`) | `accounts/<sqid>/account.json` (create-only) | console (`server.go`) **+ CLI direct** (`carlos accounts create`, `accounts.go:146`) | console-only (+ CLI direct, create-only CAS — id-allocation loser re-lists, never overwrites) | |
| +| `internal/domains/mint.go:254` (`TombstoneAccount`) | same | console (`ownership.go`) | console-only | |
| +| `internal/console/account.go:60` (`WriteAccountManifest`) | same key, alternate write path (materialize/rename flow) | console only | console-only | |
| +| `internal/domains/claims.go:114,140` (`AttachClaim`) | `ClaimKey(host)` — one global key per hostname, cross-account uniqueness gate | console (`apidomains.go`, `domainattach.go`) | console-only | |
| +| `internal/domains/claims.go:166` (`DetachClaim`) | same | console (`apidomains.go`, `domainattach.go`) | console-only | |
| +| `internal/domains/claims.go:289` (`SetClaimCapabilities`) | same | console (`apidomains.go`) | console-only | |
| +| `internal/console/server.go:3189` (canary retire) | `apps/<acct>/<app>/channels/<channel>.json` (Delete) | console only | console-only | |
| +| `internal/objstore/objstore.go:115` (`Put`) | `apps/<acct>/<app>/store.json` | console (`apistore.go`) | console-only | |
| +| `internal/objstore/walk.go:537` (`deleteRecordIfUnchanged`) | same | console (grant/teardown walk, serialised per-app by the console door) | console-only | |
| +| `internal/console/economicspass.go:962` (`upsertBill`) | `economics.BillKey(month)` | console only (CE pull pass + manual save door, both in-process on the console) | console-only | |
| +| `internal/console/economicspass.go:1035` (`putScenario`) | `economics.ScenarioKey(name)` | console only | console-only | |
| +| `internal/console/economicspass.go:1056` (`putActiveScenario`) | `economics.ActiveKey` | console only | console-only | |
| +| `internal/usage/statement.go:55` (`UpsertStatement`) | `usage/accounts/<acct>/<month>.json` | console (`economicspass.go`) | console-only | |
| +| `internal/usage/statement.go:71` (`CreateDayObject`) | `usage/census/...` / `usage/storage/...` day objects | console (`economicspass.go`) | console-only — matches brief's known-expected row | |
| +| `internal/edge/wildcard.go:483` (`w.cache.Put`, wildcard cert PEM) | `autocert.Cache` key `wildcardPEMKey(parent)` | every box that runs the wildcard manager | **N/A today** — see Findings §3 (cache backend is local disk; bucket ("s3") backend is reserved/unimplemented) | |
| +| `internal/edge/wildcard.go:591` (`w.cache.Put`, ACME account key) | `wildcard-account.key` | every box | N/A today — same as above | |
| +| `internal/edge/edge.go:977` (`e.cache.Put`) | in-process HTTP response cache (`*Cache`), keyed by request | every box, in-memory only | N/A — not a bucket write | |
| +| `internal/edge/compress.go:277` (`gzipPool.Put`) | `sync.Pool` recycling a `*gzip.Writer` | every box, in-memory only | N/A — not a bucket write; unrelated `Put` overload entirely | |
| + |
| +## CLI direct-bucket note |
| + |
| +Several rows above are reachable from BOTH the console process and a |
| +`carlos` CLI verb that opens the bucket directly (`openStore()` in `cli.go`, |
| +`accounts.go`, `apps.go`, `appconfig_cli.go`, `instances_cli.go`) — |
| +`ship`/`promote`/`rollback`/`target`/`accounts create`/`env`/`instances |
| +enable`/`apps delete`/`apps restore`. This matches CLAUDE.md's own note that |
| +"direct-bucket promotes (pinfra scripts) don't bump [`control/serial`]" — |
| +`scripts/ship-app.sh`/`promote-app.sh` are exactly this path. |
| + |
| +None of these change the writer map's `console-only` ruling for N platform |
| +boxes: the CLI path is an ad hoc, external invocation (an operator's laptop, |
| +a pinfra script, a first-boot bootstrap) — never a process running |
| +persistently on one of the N boxes, so it does not scale with box count the |
| +way a box's own converge loop would. Every one of these functions already |
| +uses the codebase's standard CAS discipline (create-only, or read-etag-then- |
| +Put with a bounded retry), so two concurrent invocations — whether both CLI, |
| +both console, or one of each — resolve the same way a single console |
| +already resolves two concurrent browser tabs. Called out per-row rather than |
| +silently folded into "console" because the spec's Part 1 table's literal |
| +wording ("console stays the single control-plane writer") undersells the |
| +actual call graph, and a future reader tracing one of these keys should not |
| +have to rediscover the CLI path themselves. |
| + |
| +## Findings (surprises, worth the controller's attention) |
| + |
| +### 1. `control/restore-verify.json` — NEEDS RULING |
| + |
| +`internal/dbverify/dbverify.go` publishes ONE fixed key with no per-box |
| +component, and the doc comment places it "beside `control/serial` and |
| +`control/registry-snapshot.json`" — i.e. explicitly aware of the same |
| +key-collision family Task 1 fixed for snapshots, yet it wasn't migrated. |
| + |
| +The write path (`internal/ops/ops.go`'s `runHibernateVerify`) does |
| +`Load` (Get) → `Merge` (fold this run's verdicts into the prior record, |
| +in-process) → `Publish`. `Publish`'s retry loop (`internal/dbverify/dbverify.go:120-144`) |
| +re-fetches the ETAG on each of 3 attempts but does **not** re-fetch or |
| +re-merge the BODY — the marshaled `rec` is fixed once, before the loop. If |
| +two boxes' `carlos-hibernate-verify.timer` runs race (confirmed installed |
| +on every box: `internal/host/units/carlos-hibernate-verify.timer`), the |
| +loser's retry re-reads a fresh etag and blindly overwrites the winner's |
| +just-published record with its own OLDER merge — silently dropping |
| +whatever the winner had just added, with no error surfaced (the retry |
| +"succeeds"). |
| + |
| +This is the same fixed-key shape #158 fixed for snapshots, but with a worse |
| +failure mode: #158's collision surfaced as an outright overwrite each box |
| +could at least notice was contended; this one merges first, so the loser's |
| +write looks locally correct and the data loss is invisible until someone |
| +diffs `Hosts` against what a box's own log claimed to publish. I'm not |
| +confident whether the right fix is (a) box-scoped keys |
| +(`control/restore-verify/<box>.json`, mirroring snapshots, with a reader |
| +that folds them for display) or (b) keeping one merged key but making |
| +`Publish` re-Load-and-re-Merge inside each retry attempt rather than |
| +retrying a stale body — that's a design call, not something to invent here. |
| +Flagging for the controller before Task 3/4 lock in the housekeeper-lease |
| +boundary, since this key doesn't fit "deployment-global duty" (nothing |
| +elects one issuer) OR "per-box data" (results are meant to be read as one |
| +fleet-wide table) cleanly. |
| + |
| +### 2. `release.PurgeApp` has two callers, one of them every-box — ruled safe, flagged for confirmation |
| + |
| +`internal/domains/reconcile.go`'s auto-purge step (run from |
| +`internal/host/agent.go`'s `domains.Reconcile` pass, **every box, every |
| +converge tick**) calls `release.PurgeApp` for any app trashed past |
| +`TrashCooloff` — not console-only, and not in the writer map's table at |
| +all. Unlike the retention-prune row (which the spec explicitly moves behind |
| +a housekeeper lease because "five concurrent pruners racing a promote is a |
| +bug class we refuse to have"), this call site's own comment argues it is |
| +already safe for concurrent boxes: `PurgeApp` re-reads the trash marker and |
| +re-checks the production flag immediately before destroying, both guards |
| +fail toward keeping data, deletes are idempotent, and the marker is deleted |
| +last — "concurrent boxes converge because every step tolerates already-gone |
| +and the marker goes last" (`internal/domains/reconcile.go:260-267`). |
| + |
| +I'm ruling this one safe as-is (idempotent deletes racing harmlessly, no |
| +CAS content to clobber the way a merge-then-retry does), distinct from |
| +finding #1 — but it's a second every-box destructive-delete path on |
| +`releases/**` that the writer map's table doesn't mention, so Task 3's |
| +housekeeper-lease scope should explicitly decide whether this stays outside |
| +the lease (my read: yes, it's structurally different from keep-newest-N |
| +prune) or gets folded in for uniformity. |
| + |
| +### 3. Wildcard cert issuance (`internal/edge/wildcard.go`) is not a bucket writer today — but is the exact seam Part 2 will need |
| + |
| +The grep caught `w.cache.Put(...)` twice (wildcard cert PEM, ACME account |
| +key). `w.cache` is a `golang.org/x/crypto/acme/autocert.Cache`, and |
| +`internal/edge/certcache.go`'s `newCertCache` resolves it from |
| +`CARLOS_CERT_CACHE`: unset/`"dir"` → `autocert.DirCache` (local disk, |
| +per-box, never touches the deployment bucket); `"s3"` → **refuses to start, |
| +"reserved, not implemented yet."** So today, every box that has |
| +`PLATFORM_APPS_DOMAIN`/`CARLOS_WILDCARD_DOMAINS` set mints its own wildcard |
| +certs independently against its own local disk — not a deployment-bucket |
| +write, so out of this audit's scope by the letter of the brief, but it is |
| +functionally the same shared-ACME-account collision problem Part 2's |
| +housekeeper is designed to solve, on a code path Part 2's design doesn't |
| +mention. The `certcache.go` comment already anticipated this ("Edge boxes |
| +in a pool must eventually SHARE issued certs... an S3 backend is a drop-in |
| +behind this constructor") — worth Task 4/5 checking whether the wildcard |
| +path is meant to keep using this same `autocert.Cache` seam (in which case |
| +implementing `CARLOS_CERT_CACHE=s3` might be a natural way to land both |
| +per-host HTTP-01 AND wildcard DNS-01 through the housekeeper in one |
| +mechanism) or whether wildcard stays on per-box `DirCache` forever |
| +(acceptable only if wildcard and the new `control/certs/**` machinery never |
| +need to agree on a cert for the same hostname). |
| + |
| +### 4. `WriteFleetBinding` — CAS-arbitrated, not lease/per-box, ruled safe |
| + |
| +`internal/host/fleetbind.go` writes `fleetBindingKey(fleet, host)` — a |
| +single key with no platform-box component, from every box's activator |
| +(`OnWake`, `takeover=true`) and agent-startup pass (`takeover=false`). This |
| +doesn't fit the writer map's four ruling categories: it isn't console-only, |
| +isn't lease-elected, and the key doesn't carry a box suffix so it isn't |
| +"per-box" in the snapshot/metrics/usage sense either. It's a fourth shape — |
| +a CAS-guarded, semantically-arbitrated shared key (`bindingBlocked` + |
| +`takeover` decide who wins, not "last writer" or "one elected box") — and |
| +the code's own doc comment reasons carefully about exactly the concurrent- |
| +platform-box case ("a box republishes to the SAME key on every wake and |
| +once more at startup"). Ruled safe as designed; flagged because it's a |
| +pattern the writer map's four-row vocabulary doesn't have a name for, and a |
| +future writer-map revision might want one. |
| + |
| +## Self-review — could a writer exist that none of the greps caught? |
| + |
| +1. **Listed every `release.Store` implementor and read every method that |
| + mutates.** Three implementations: `DirStore` and `S3Store` |
| + (`internal/release/store.go`, `Put`/`Delete` — both caught, since every |
| + caller goes through the interface methods the grep already matches), |
| + `prefixStore` (pure delegation, not an independent writer — see its row |
| + above), and the fleet-api HTTP client (`internal/fleetapi/fleetapi.go`'s |
| + `store.Put`/`store.Delete`, method *definitions*, which the grep's |
| + call-site pattern `\.Put(\|\.Delete(` correctly does not match — but |
| + every byte it sends lands on the store-door server side, which the grep |
| + DID catch at `internal/edge/storedoor.go:190,211`, so nothing behind |
| + this client escapes the audit). |
| +2. **Swept for direct S3 client usage bypassing the `Store` interface |
| + entirely**, in case some package talks to `*s3.Client` itself instead of |
| + going through `release.Store`. Found two: `internal/release/store.go` |
| + (that's `S3Store`'s own implementation — already covered) and |
| + `internal/objstore/aws.go`/`provisioner.go` (`CreateBucket`, |
| + `DeleteBucket`, `DeleteObjects`, `PutUserPolicy`, etc.) — confirmed this |
| + is the TENANT object-storage feature's own bucket (a member's |
| + provisioned S3 bucket, issue #55), never the deployment control bucket, |
| + so it's out of scope for a deployment-bucket writer map and isn't given |
| + a row above. |
| +3. **Swept `.Write(` on store-shaped receivers** per the brief's |
| + instruction, in case some writer uses `io.Writer`-shaped code instead of |
| + `Store.Put`. Every hit was a `http.ResponseWriter`/hash/gzip/file write — |
| + none touch the deployment bucket (`internal/host/restart.go` and |
| + `internal/release/bake.go`'s `f.Write(raw)` are local box-side files, not |
| + bucket keys). |
| +4. **Checked every `release.Store`-typed function for callers outside what |
| + the grep's line already showed**, rather than trusting the first caller |
| + found — this is how findings §1, §2, and the CLI-direct-bucket note were |
| + caught: `dbverify.Publish` looked console-adjacent from its own package |
| + doc comment until tracing to `internal/ops/ops.go` showed a systemd timer |
| + installed fleet-wide; `release.PurgeApp` looked console-only until |
| + `internal/domains/reconcile.go` showed a second, every-box caller; |
| + several `release.*`/`appconfig.*`/`domains.*` functions I initially |
| + assumed were console-exclusive (based on `deploy.go` going through the |
| + console HTTP API) turned out to have a second CLI direct-bucket caller |
| + via `openStore()` once I grepped for it explicitly across the CLI's root |
| + package. |
| +5. **Did not find** any additional deployment-bucket writer beyond the |
| + above. The two areas most likely to hide one — a second `Store` |
| + implementation, and non-`Store`-interface S3 calls — were both |
| + enumerated and both came back accounted for (fleet-api client |
| + delegates to the store door; objstore's S3 calls are a different |
| + bucket entirely). |
| + |
| +## Cross-check against the spec's Part 1 table |
| + |
| +| Spec row | Audit outcome | |
| +|---|---| |
| +| `apps/**`, `domains/*`, `control/serial`, restart-gen — console | Confirmed, with the CLI-direct-bucket caveat above (does not change the ruling) | |
| +| `control/registry-snapshot*` — per-box keys | Confirmed done (Task 1) | |
| +| `status/**` — lease-elected publisher | Confirmed (`status/fleet.json` elected; `status/boxes/<label>.json` already per-box) | |
| +| `releases/**` retention prune — housekeeper (Task 3) | Confirmed current state is every-box (`host/retention.go`); ruled `housekeeper (Task 3 pending)` as instructed. **Plus** a second every-box destructive path on `releases/**` the table doesn't mention — Finding §2 | |
| +| usage metering day objects — audit box-scoping | Confirmed already box-scoped (`usage/edge/<box>/days/`) | |
| +| `control/certs/**`, `control/acme-challenges/**` — housekeeper (Part 2) | Not yet built (correctly absent from this tree) — but Finding §3 flags a live, unbuilt-for-N-boxes adjacent cert-issuance path (wildcard) worth Part 2/Task 4 awareness | |
| +| `control/edges/<box>.json` — each box, own key | Not yet built (Part 3, correctly absent) | |
| +| `control/edge-mesh-key` — housekeeper create-only | Not yet built (Part 3, correctly absent) | |
| + |
| +No writer discovered outside the spec's table lacks a ruling here — two |
| +(§1, `control/restore-verify.json`) genuinely need the controller's |
| +decision; the rest (§2, §3, §4) are ruled with reasoning attached. |