carlos / platform Public

spec+plan: what a design review found in the half not yet written

Tasks 14-17 were reviewed BEFORE implementation, which is the point:
five blockers, and two of them are places the spec contradicted itself.

THE GUARANTEE WAS BACKWARDS. §6.4 said callbacks are at-least-once.
They are at-MOST-once: DrainApp deletes the queue items before delivery
is attempted, so a console that dies between the delete and the POST
loses that callback and nothing re-sends it — the sweep re-tags the
object but never delivers. Duplicates are still possible, so apps must
be idempotent AND must not treat the callback as the delivery mechanism.
The docs now have to say: if no callback arrives within your timeout,
poll. The case that makes it matter is `infected` + `recorded:false` +
a lost callback — enforcement is not in effect, the object is servable,
and nothing tells the app unless it asks.

THE BODY CONTRADICTED §11.7. §6.3 put the opaque version token in the
callback body; §11.7 says "no door or app sees the token" and calls it a
tested property. Both sentences were in the same document, facing each
other. §11.7 wins, because it is the property the version binding exists
to protect: an app holding the token is an app that can compare it
wrongly, which is the laundering bug arriving as a correctness bug. The
ETag stays — the app has it from its own PUT.

DELIVERY MUST NOT RUN IN THE DRAIN'S GOROUTINE. The plan named
scandrain.go and said nothing else, and the obvious implementation puts
a tenant's hanging endpoint directly in the path of every other tenant's
verdict: fifty items times a retry schedule, inside a tick that sweeps
apps sequentially. That is the starvation §7.2 and §7.3 exist to prevent,
and a tenant can trigger it by pointing a callback at a black hole. Now
specified as a bounded worker pool with a per-app in-flight cap.

BEHAVIOUR WITH NO MASTER WAS UNDEFINED, and the enqueue door already
accepts callback_path — so the implementer's three choices included
"send it unsigned", which is the hole §6.3 opens by describing. Decided:
the door refuses with 503 callbacks_not_configured, and delivery will not
build a request without a signer.

"PACED RE-SEAL" HAD NO MECHANISM. It is `scan sweep`'s now: one budget
per call, an answer saying how many apps remain, the CLI loops. Plus the
multi-console ordering nobody had written down — every console must hold
both masters before the rotation runs, or a console still on the old one
signs callbacks that apps re-sealed by another console cannot verify.

Two factual corrections. §6.2 claimed an app with only its own domain
gets no callbacks; the domains loop mints the platform host from ANY
ready route (`firstReadyUpstream`), custom domains included, so the true
condition is "no ready route at all". And §11.3's properties are
properties of the APP's verifier, which is tenant code — so task 14 now
ships `VerifyCallback` as the reference implementation the docs
reproduce, or the tests test nothing anybody runs.

Also added, all from the review: a per-app `webhook_gen` so a tenant that
leaks its key does not need a deployment-wide rotation and a reclaimed
app name cannot inherit a deleted app's key; a domain label in the
derivation; a key id under a different label from the tag scheme's; a
distinct app-side variable name, because the same name for master and
derived child is how a master ends up in a tenant environment; `revoke`
unsetting the secret; both secrets folded into one Apply so a first grant
restarts the app once rather than twice; and a note on why a delimiter is
right here when scantag.go uses length prefixes, so the next person to
notice the inconsistency finds the reason instead of "fixing" it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExnZtUYmt2h8QFcymjPaSL
Paul Campbell pushed by paul@keymail.dev f089085e8e86aeeb58f613a5e50df99db1e5abd0 parent 23fa8ea
2 files changed, +228 −39
  • docs/superpowers/plans/2026-09-02-scan-on-demand.md +107 −25
  • docs/superpowers/specs/2026-09-02-scan-on-demand-design.md +121 −14

CI passed — run details

diff --git a/docs/superpowers/plans/2026-09-02-scan-on-demand.md b/docs/superpowers/plans/2026-09-02-scan-on-demand.md
index 85ff870d0..2a9200008 100644
--- a/docs/superpowers/plans/2026-09-02-scan-on-demand.md
+++ b/docs/superpowers/plans/2026-09-02-scan-on-demand.md
@@ -506,18 +506,38 @@ Eleven mutations, all caught. Two gaps are recorded rather than papered over:
## Task 14: callback signing (pure)
-**Files:** `internal/console/scanhook.go` + test.
+**Revised after review 7**, which found five blockers in tasks 14-17 before any
+of it was written. The additions below are all its findings.
+
+**Files:** `internal/console/scansign.go` (new — signing is its own file, not
+more of the path validator) + test.
- [ ] `TestSignatureRejectsTamperedBody`, `TestSignatureIsPerApp` (app A's does
not verify for app B), `TestSignatureRejectsStaleTimestamp`,
- `TestTwoMasterRotationVerifies`, `TestTargetURLBuiltStructurally`.
-
-Per-app key `HMAC(master, sqid + "/" + app)` derived at sign time — nothing
-extra stored, and without it one deployment secret lets app A forge callbacks
-to app B. Delimiter is injection-safe: sqid `^[a-z]{3,16}$` (`qualified.go:56`),
-app `^[a-z][a-z0-9-]{0,31}$` (`release.go:242`).
-
-**Commit:** `console: per-app callback signing`
+ `TestTwoMasterRotationVerifies`.
+- [ ] **`VerifyCallback(key, header, body, now)` — the reference verifier**, and
+ it is the deliverable §11.3 was missing. "A stale timestamp is refused" and
+ "both masters verify" are properties of the APP's verifier, which is tenant
+ code; without a reference implementation the tests test nothing anybody runs.
+ The docs reproduce it line for line.
+- [ ] `TestSignatureIsOverRawBodyBytes` — an app that re-serialises the JSON
+ before verifying gets a different byte string and fails. Said in the docs.
+- [ ] `TestVerifierToleratesTwoSignaturesAndUnknownKeyIds` — the rotation
+ window depends on it.
+- [ ] `TestPreviousEqualToCurrentIsRefused` — a rotation that rotated to
+ nothing, refused exactly as `NewTagSigner` refuses it.
+
+Per-app key `HMAC(master, "carlos-scan-webhook-v1" + 0x00 + sqid + "/" + app +
+"/" + gen)` derived at sign time — nothing extra stored, and without it one
+deployment secret lets app A forge callbacks to app B. Delimiter is
+injection-safe: sqid `^[a-z]{3,16}$` (`qualified.go:56`), app
+`^[a-z][a-z0-9-]{0,31}$` (`release.go:242`), verified in the repo. The domain
+label and the per-app `gen` are review 7's; so is the key id's distinct label.
+
+`TestTargetURLBuiltStructurally` is dropped — it landed with the validator as
+`TestCallbackTargetIsBuiltStructurally`.
+
+**Commit:** `console: per-app callback signing, and the verifier apps will run`
---
@@ -536,13 +556,39 @@ hook), `apisystem.go`, root `system.go`, `authcli.go`.
plaintext".** `appconfig.Seal` uses a fresh ephemeral key and nonce on every
call (`internal/appconfig/crypto.go:85-87`), so the ciphertext always differs
and Sync would restart the unit every time; the console holds only the public
- key and cannot compare by opening the old value. Rotation therefore records a
- **key id** in the record — `ScanBlock` gains `webhook_key_id`, additive and
- safe under the PARSER LIST rule — and skips any app already carrying the
- current one. The test asserts the skip.
+ key and cannot compare by opening the old value. Rotation records a **key
+ id** in the record — `ScanBlock` gains `webhook_key_id`, additive and safe
+ under the PARSER LIST rule — and skips any app already carrying the current
+ one. The test asserts the skip.
- [ ] `TestRotateWebhookKeyDoesNotDropPrevious` — dropping `_PREVIOUS` is a
separate operator step, because "done" means sealed, not converged
(`server.go:566-569` makes this point for store rotation).
+- [ ] **`TestGrantFoldsBothSecretsIntoOneApply`** — a first grant writes the
+ store credential and the callback secret. Two `Apply` calls are two
+ generation bumps and therefore two unit restarts for one operator action.
+- [ ] **`TestRevokeUnsetsTheCallbackSecret`** — a key outliving the consent it
+ was issued under is the same mistake as a result object outliving it.
+- [ ] **`TestRotateIsBudgetedAndResumable`** — "paced" needs a mechanism, and
+ it is `scan sweep`'s: one budget per call, an answer saying how many apps
+ remain, the CLI loops. No background job, no pacing state; idempotence by key
+ id is what makes a re-run safe.
+- [ ] **`TestNoMasterRefusesACallbackPath`** — with no `CARLOS_SCAN_WEBHOOK_KEY`
+ the enqueue door refuses `callback_path` with 503 `callbacks_not_configured`.
+ A 202 would promise a delivery that cannot happen. (The door already accepts
+ the field, so this is a fix, not only an addition.)
+- [ ] **`TestPerAppKeyGenerationRotatesOneApp`** — `webhook_gen` in `ScanBlock`;
+ a tenant that leaks its key must not need a deployment-wide rotation, and a
+ reclaimed app name must not inherit the deleted app's key.
+
+**The app-side variable is NOT `CARLOS_SCAN_WEBHOOK_KEY`.** Same name for the
+master and the derived child is a copy-paste footgun that ends with a master in
+a tenant environment. Follow `EnvNames(prefix)` (`walk.go:598`):
+`<PREFIX>_SCAN_CALLBACK_SECRET`.
+
+**Multi-console ordering, which the spec now states:** every console must be
+restarted with `KEY=new, PREVIOUS=old` BEFORE `rotate-webhook-key` runs, or a
+console still on the old master signs only under old while apps another console
+re-sealed hold only new.
**Commit:** `console: webhook key delivery and rotation`
@@ -550,19 +596,39 @@ hook), `apisystem.go`, root `system.go`, `authcli.go`.
## Task 16: delivery
-**Files:** `internal/console/scandrain.go`, `scanhook.go`.
-
-- [ ] `TestCallbackGoesToPlatformHostOnly` — never a custom domain; the
- outbound fetcher picks the first ready non-static route
- (`instances.go:88-97`), which a tenant can point where they like.
+**Files:** `internal/console/scandeliver.go` (new — the worker pool),
+`scandrain.go`, `server.go`.
+
+- [ ] **`TestDeliveryDoesNotBlockTheTick`** — review 7's blocker. Delivery in
+ the drain's goroutine puts a tenant's hanging endpoint in the path of every
+ other tenant's verdict: 50 items times a bounded retry schedule, inside a
+ tick that sweeps apps sequentially. That is the starvation §7.2 and §7.3
+ exist to prevent and a trivial tenant-triggered DoS. A bounded worker pool
+ fed from `done` after `scanNotify`, a bounded queue (overflow drops and logs
+ — best effort by construction), a per-app in-flight cap, retries scheduled by
+ the pool.
+- [ ] `TestCallbackGoesToPlatformHostOnly` — never a custom domain.
- [ ] `TestCallbackRefusesRedirects` — Go follows them by default and a tenant
302 turns a signed POST into a GET at an internal address
(`sesevents.go:143-158` is the precedent).
-- [ ] `TestCallbackRetriesAreBounded` — §6.4.
+- [ ] **`TestTransportDoesNotSkipTLSVerification`** — a tested ABSENCE, because
+ TLS name verification is the second lock that makes DNS rebinding irrelevant
+ (§6.2). Needs `Config.CallbackTransport http.RoundTripper` (nil = production)
+ as the test seam; name it, or the test cannot intercept an https POST.
+- [ ] **`TestNeverPostsUnsigned`** — with no signer there is no request.
+- [ ] `TestCallbackRetriesAreBounded` — 2xx success; 3xx and 4xx not retried;
+ 5xx/429/timeout retried, three attempts at 1s/4s/16s; 10s client timeout;
+ response body drained through a 4KiB `LimitReader` and discarded.
- [ ] `TestDroppedCallbackStillReadableFromStatusDoor` — the §13 promise that
makes lazy retries acceptable.
- [ ] `TestCallbackBodyCarriesRecorded` — or a callback saying `clean` and a
door saying `recorded:false` disagree.
+- [ ] **`TestCallbackBodyOmitsTheVersionToken`** — §11.7. The body carries
+ account, app, key, the bare ETag, verdict, recorded, and provenance; never
+ the opaque version.
+- [ ] **`TestNoPlatformHostIsNotAnError`** — an app with no ready route has
+ nowhere to be called back yet; it polls. Refuse at the door rather than
+ building `https:///hooks`.
**Commit:** `console: callback delivery, bounded and backstopped by the door`
@@ -578,17 +644,33 @@ hook), `apisystem.go`, root `system.go`, `authcli.go`.
- [ ] `TestScanUsageCountsOnDemandOnly` — sweep scans are not attributed to a
tenant who did not ask for them.
-- [ ] `TestScanUsageFoldsPerTick` — not per object.
+- [ ] `TestScanUsageFoldsPerTick` — not per object. `Drained.Scanned` and
+ `Size` already exist for this; the fold accumulates per account across
+ `drainScanApp` calls and writes once at the end of `scanDrainTick`.
- [ ] `TestStoreScanStatusReportsDeploymentState` — a per-app record saying
`enabled: clamav` while the deployment switch is off is a new way for the
status output to lie.
-- [ ] `TestPrintScanStatusShowsQueueDepth` (per app) and
- **`TestPrintSystemAntivirusStatusShowsQueueDepth`** (deployment-wide, §4.4).
- Task 6 lands before the drain exists and cannot surface either.
+- [ ] `TestPrintScanStatusShowsQueueDepth` (per app, `objstore.QueueDepth`) and
+ **`TestPrintSystemAntivirusStatusShowsQueueDepth`** (deployment-wide,
+ `Server.ScanQueueDepth`, §4.4). Both landed in task 12; this surfaces them.
+ Note the deployment number counts APPS WITH WORK, and an app's entry survives
+ one extra tick after its last item drains — say "apps with work", not "apps
+ with items", or it reads as off by one.
+
+**Docs must carry, and this is review 7's list:**
+- *If no callback arrives within your own timeout, poll the status door.*
+ Delivery is at-most-once (§6.4); the callback is an accelerator, not the
+ delivery mechanism.
+- The idempotency tuple: `(account, app, key, etag, verdict)`.
+- The signature is over the RAW BODY BYTES.
+- `store scan grant` and `rotate-webhook-key` RESTART apps as a side effect.
+- The corrected platform-host sentence (§6.2): an app with no ready route has
+ no platform host yet — not "an app with only its own domain".
`usage/scan/<date>.json`, CAS-merged as `UpsertEdgeDay` (`edge.go:85-116`);
-meter names frozen once merged (`usage.go:4-5`). Rollout plan step 4 becomes
-`CARLOS_CLAMD_ADDR` + `enable`; rollback becomes `disable`, no restart.
+meter names frozen once merged (`usage.go:4-5`), so they are named in this task
+and not later. Rollout plan step 4 becomes `CARLOS_CLAMD_ADDR` + `enable`;
+rollback becomes `disable`, no restart.
**Commit:** `console+cli: scan metering, surfaces and docs`
diff --git a/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md b/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
index 32f903f16..ba1b8aef8 100644
--- a/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
+++ b/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
@@ -579,9 +579,23 @@ into a GET at an internal address. The codebase already knows this
So, all of:
- POST only to the **platform-assigned host**, `<app>.<sqid>.<AppsDomain>`
- (`apiinstances.go:566,1250`) — never a custom domain. **An app that has only
- its own domain gets no callbacks**; it uses long-poll. Said plainly in the
- docs.
+ (`apiinstances.go:566,1250`) — never a custom domain.
+ **Correction (review 7): an app with only its own domain DOES get
+ callbacks.** The domains loop mints the platform host for any app with a
+ ready route, and `firstReadyUpstream` (`domains/reconcile.go:268`) accepts a
+ custom-domain route as the source it copies an upstream from. The true
+ condition is narrower and duller: an app with **no ready route at all** has
+ no platform host yet, so it has nowhere to be called back and uses long-poll
+ until it does. The docs say that, not the old sentence.
+- **Why the host cannot be moved, with the evidence.** `app` is tenant-chosen
+ but `^[a-z][a-z0-9-]{0,31}$` (`release.go:242`) — no dots, so no escaping the
+ label; `sqid` and `AppsDomain` are the platform's. A tenant cannot attach a
+ custom domain under `AppsDomain` (`apidomains.go:96`). DNS for the name is
+ written by the fleet's domains loop to edge IPs, not by a tenant. **DNS
+ rebinding is closed twice**, and the second reason is the one that matters:
+ TLS name verification against a public CA means a poisoned resolver still
+ cannot present a valid certificate for that name. `InsecureSkipVerify` on the
+ callback transport is therefore a **tested absence**, not a code-review item.
- **https only**
- `CheckRedirect` **refuses every redirect**
- bounded timeout, `LimitReader` on the response
@@ -648,13 +662,67 @@ unit** on a bundle change (`sync.go:33,42,331`). So:
unchanged master re-seals the same plaintext and therefore does **not**
restart anything: Sync restarts only when the rendered bundle changes
(`sync.go:340+`).
+- **"Paced" needs a mechanism, and it is `scan sweep`'s.** One budget per
+ call: the door re-seals as many apps as it can inside `scanSweepTimeout`,
+ answers with how many remain, and the CLI loops until none do. No background
+ job and no pacing state — idempotence by key id is what makes a re-run safe,
+ so a console that dies mid-rotation simply resumes.
+- **Every console must be restarted with the new pair BEFORE the rotate runs.**
+ With more than one console, a console still holding only the old master will
+ sign only under the old key, while apps another console has already re-sealed
+ hold only the new derived key — and those callbacks fail verification.
+ Order: restart all consoles with `KEY=new, PREVIOUS=old`, then run
+ `rotate-webhook-key`, then drop `_PREVIOUS` once converged.
+- `store scan revoke` **unsets** the app's callback secret. A key outliving the
+ consent it was issued under is the same mistake as a result object outliving
+ it.
- constant-time compare; skew window 5 minutes
- the body carries `recorded`, so a callback saying `clean` and a door saying
`recorded:false` cannot disagree — the disagreement §5.2.1 exists to prevent
-- the body carries account, app, key, the **bare ETag** *and* the opaque
- version. Draft 2 carried only the version, contradicting §5.2's rule that
- apps must not interpret it — the app has the ETag from its own PUT response
- and can match on that.
+- the body carries account, app, key and the **bare ETag** — and **not** the
+ opaque version. Draft 4 carried both, which contradicted §11.7 ("no door or
+ app sees the token") in the same document; a review found the two sentences
+ facing each other. §11.7 wins, because it is the property the version binding
+ exists to protect: an app that holds the token is an app that can compare it
+ wrongly, and that is the laundering bug arriving as a correctness bug. The
+ app has the ETag from its own PUT response and matches on that.
+- **Replay, stated plainly because there is no nonce.** A callback replayed
+ inside the 5-minute skew window verifies and is accepted. That is harmless
+ and it is the design: the body is idempotent on `(account, app, key, etag,
+ verdict)`, which is the tuple an app uses as its idempotency key, and it is
+ named here so two implementers do not choose differently. A nonce is
+ deliberately NOT added — apps have no durable nonce store to check one
+ against, so it would be assurance in name only.
+- **Clock.** `t` is the console's clock. Apps run on platform boxes and do have
+ a clock, so the skew check is expected of them; skipping it only widens the
+ (harmless) replay window above. The verifier must tolerate two `v1=` entries
+ and unknown key ids.
+- **Key id**: `HMAC("carlos-scan-webhook-key-id", master)[:16]` — a *different*
+ label from `scantag.go`'s tag-key id, so the two schemes cannot produce a
+ colliding id from the same bytes. `PREVIOUS == current` is refused, exactly
+ as `NewTagSigner` refuses it: a rotation that rotated to nothing.
+- **Derivation carries a domain label**: `HMAC(master, "carlos-scan-webhook-v1"
+ + 0x00 + sqid + "/" + app)`. The label costs nothing and means no future
+ `HMAC(master, x)` use can collide with a derived per-app key.
+- **Why a delimiter here when `scantag.go` uses length prefixes.** `t + "." +
+ body` is injective because `t` is decimal digits — it cannot contain a `.` —
+ so the first `.` splits it unambiguously. The tag scheme needs length
+ prefixes because an object key can contain any delimiter you pick. Stated
+ because the two schemes now look inconsistent, and the next person to notice
+ should find the reason rather than "fix" it: length-prefixing here would make
+ every non-Go verifier harder for no gain.
+- **Per-app rotation.** `ScanBlock` carries a non-secret `webhook_gen`, and the
+ derivation is over `sqid + "/" + app + "/" + gen`. Without it a tenant that
+ leaks its callback key has no remedy short of an operator rotating the
+ deployment master and restarting every scanning app — and a reclaimed app
+ name in the same account would inherit the deleted app's key. The tag key
+ needs no such thing because it never leaves the console; this one is handed
+ to tenants, so it needs an owner-side rotation.
+- **With no master configured**, the enqueue door refuses a `callback_path`
+ with 503 `callbacks_not_configured` rather than accepting it. A 202 would
+ promise a delivery that cannot happen. Delivery refuses to build a request
+ without a signer, which is a test (`TestNeverPostsUnsigned`) rather than a
+ convention — §6.3 exists because an unsigned callback is the hole.
Considered and rejected: Gotenberg's replay-my-headers trick. Neat, avoids a
signing scheme, but gives no body integrity, no replay protection, and puts a
@@ -662,12 +730,45 @@ tenant secret in request logs.
### 6.4 Delivery is best effort
-Retries with exponential backoff, a small bounded count, then stop. **No
-dead-letter queue**: the verdict is durably on the object and the status door
-always answers, so a dropped callback costs one poll rather than stranding a
-file. That is the dividend of building pull first.
-
-Callbacks are at-least-once; apps must be idempotent.
+**No dead-letter queue**: the verdict is durably on the object and the status
+door always answers, so a dropped callback costs one poll rather than stranding
+a file. That is the dividend of building pull first.
+
+**The guarantee is AT-MOST-ONCE, not at-least-once.** Draft 4 said the
+opposite and a review caught it: `DrainApp` deletes the queue items before
+delivery is attempted, so a console that dies between the delete and the POST
+loses that callback and nothing re-sends it — the sweep re-tags the object but
+never delivers. Duplicates are still possible (a retry after an ambiguous
+failure, or two consoles draining the same prefix), so apps must be idempotent
+**and** must not treat the callback as the only way an answer arrives. The
+docs sentence is: *if no callback arrives within your own timeout, poll the
+status door.*
+
+The combination that actually hurts is `infected` + `recorded:false` + a lost
+callback: enforcement is not in effect (§5.2.1), the object is servable, and
+the app is never told unless it polls. That is the case the docs sentence is
+written for.
+
+**Delivery does not run in the drain's goroutine.** Inline delivery would put a
+tenant's hanging endpoint directly in the path of every other tenant's verdict
+— 50 items times a bounded retry schedule, inside a tick that sweeps apps
+sequentially, which is the exact starvation §7.2 and §7.3 exist to prevent and
+a trivial tenant-triggered denial of service. Delivery is a bounded worker pool
+fed from `done` after the waiters are notified, with a bounded in-memory queue
+(overflow drops and logs — it is best effort by construction), a per-app
+in-flight cap, and retries scheduled by the pool rather than by the tick.
+
+**The policy, written down so it is not invented twice:** success is 2xx only;
+3xx is a refusal and is not retried, since redirects are refused by design;
+4xx is not retried; 5xx, 429, timeouts and network errors are retried, three
+attempts at 1s/4s/16s; `http.Client.Timeout` around 10s so it covers connect,
+TLS, headers *and* body; the response body is drained through
+`io.LimitReader(…, 4KiB)` and discarded, because the console needs nothing from
+it; headers are `Content-Type: application/json`, a User-Agent and
+`Carlos-Signature`, and nothing else — no cookies, no credentials. Ordering is
+not guaranteed: retries reorder, and two verdicts for one key can arrive
+newest-first, which is why the body carries the ETag. One delivery per distinct
+callback path per key, which is already what `Drained.CallbackPaths` means.
---
@@ -904,7 +1005,13 @@ Each is a test.
`//host`, `/\host`, `@`-authority, traversal, CRLF, and encoded variants.
2. No redirect is followed.
3. A tampered body fails; app A's signature does not verify for app B; a stale
- timestamp is refused; both masters verify during rotation.
+ timestamp is refused; both masters verify during rotation. **These are
+ properties of the APP's verifier**, which is tenant code — so the console
+ ships `VerifyCallback(key, header, body, now)` as the reference
+ implementation, the docs reproduce it, and these tests run against it.
+ Without that, the test tests nothing anybody runs. The signature is over the
+ RAW BODY BYTES: an app that re-serialises the JSON before verifying will
+ fail, and the docs say so.
4. The app policy still has no `s3:PutObjectTagging` (`aws.go:152-165`).
5. A role-`Service` credential cannot be minted account-scoped (§3.4) and
cannot act on another app. (Admin reaches these doors by construction,