docs(19.3): add phase verification report (gaps found)

This commit is contained in:
Jakub Zych
2026-10-15 20:28:18 +02:00
parent 6cd29eae35
commit ebdd896d82

View File

@@ -0,0 +1,259 @@
---
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
verified: 2026-10-15T20:45:00Z
status: gaps_found
score: 3/4 roadmap success criteria verified (SC-1 partial); plan must-haves 35/36 verified (D-05 failed); 0 present-behavior-unverified
covered_files:
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-03-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-03-SUMMARY.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-SUMMARY.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-SUMMARY.md
- go.mod
- go.sum
- internal/build/build.go
- internal/build/manifest.go
- modules/bouncer/cookie.go
- modules/bouncer/turnstile.go
- modules/cabana/reorder.go
- modules/cooler/cooler.go
- modules/cooler/memory.go
- modules/cooler/ratelimit.go
- modules/cooler/redis.go
- modules/cooler/rediscache.go
- modules/cooler/sitecache.go
- modules/lagoon/attach/bucket.go
- modules/lagoon/attach/diskext.go
- modules/lagoon/attach/s3_off.go
- modules/lagoon/attach/s3_on.go
- modules/lagoon/attach/store.go
- modules/lagoon/attach/thumb.go
- modules/pact/capabilities.go
- modules/surf/apidocs.go
- modules/surf/cors.go
- modules/surf/router.go
- modules/surf/serve.go
- scripts/check-phase19.3.sh
covered_digest: "v2:sha256:f9d58003dac25f701e22b19bc467b0e06c88a94264b5dce19b90c2a29bacc6b5"
behavior_unverified: 0
overrides_applied: 0
gaps:
- truth: "SC-1 / D-05: credentialed CORS for a configured SPA origin is available as middleware; a named middleware cors:<profile> applies the profile's policy to the routes that carry it"
status: partial
reason: >-
Review CR-01, reproduced in this verification. Router.wrap (modules/surf/router.go:416-466) composes
route middleware in declaration order and puts bodyLimit, locale and recoverJSON/recoverBare outside
all of them. profileMiddleware (modules/surf/cors.go:255-262) only sets the CORS headers when the
request reaches it. A cross-origin GET from the allowed origin got these answers on HEAD:
["deny","cors:spa"] -> 401 with no Access-Control-Allow-Origin; group Use("deny") with route
cors:spa -> 401 with no ACAO; a panicking cors:spa route -> 500 with no ACAO. Only ["cors:spa","deny"]
and a group-level Use("cors:spa") carried ACAO and ACAC. A body-limit 413 and a recovered 500 never
carry them, whatever the order. docs/services/routing.md tells readers to list cors:spa "next to its
guard", which does not prevent the failing order. The browser then reports an opaque network error, so the SPA cannot read the
401 that should trigger its cookie refresh (the phase's headline flow), the 429 from a throttle, or the 422
that should re-show the Turnstile widget. The global path-scoped http.cors wrapper is NOT affected (it
wraps the mux: 401 and 500 both carried ACAO and ACAC in the same probe), so credentialed CORS works
for apps that configure it through http.cors.paths. The per-route-group profile that D-05 delivered is the part that fails.
artifacts:
- path: "modules/surf/router.go"
issue: "wrap applies the cors:<profile> middleware at its list position, inside bodyLimit and recovery; refusals from outer layers and from guards/throttle/turnstile listed before it carry no CORS headers"
- path: "modules/surf/cors.go"
issue: "profileMiddleware relies on being outermost; nothing enforces it"
- path: "docs/services/routing.md"
issue: "line ~265 suggests placing cors:spa 'next to its guard' without stating that order matters"
missing:
- "In Router.wrap, skip the cors:<profile> entry in the middleware loop and apply r.corsProfiles[profile].profileMiddleware after recoverJSON/recoverBare (just inside recordPattern), so every response on a profiled route, including 401/403/413/422/429/500, carries the profile's headers"
- "Test: a cross-origin request to a cors:spa route behind a refusing guard listed BEFORE cors:spa answers 401 with Access-Control-Allow-Origin, Access-Control-Allow-Credentials and Vary: Origin; same for a body-limit 413 and a recovered panic 500; add it to SURF_TESTS in scripts/check-phase19.3.sh"
- "docs/services/routing.md and modules/surf/README.md: state that the profile applies to every response of the route regardless of its position in the list"
---
# Phase 19.3: Framework transport and infrastructure for WavePath Verification Report
**Phase Goal:** The framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
**Mode:** mvp (ROADMAP marks the phase `mode: mvp`, but `user-story.validate` returns `valid: false` for the goal because it is not written as "As a ..., I want to ..., so that ...". As in the 18 and 18.1 reports, this report uses standard goal-backward verification against the goal text and its four success criteria, with a User Flow Coverage table derived from the goal. To make the mode consistent, run `/msd mvp-phase 19.3` to restate the goal, or drop `mode: mvp`.)
**Verified:** 2026-10-15T20:45:00Z
**Status:** gaps_found
**Re-verification:** No, initial verification
## User Flow Coverage (derived; the goal is not a user story)
Flow: a SPA on `www.example.com` talks to an API on `api.example.com` run as several instances.
| Step | Expected | Evidence | Status |
|------|----------|----------|--------|
| Log in, get a cookie | httpOnly `token` cookie, Domain/SameSite/Secure from `auth.cookie.*`, Max-Age = refresh window | `bouncer.LoadCookieConfig`, `CookieConfig.TokenCookie` (`modules/bouncer/cookie.go`); `TestTokenCookieMaxAgeIsRefreshWindow`, `TestLoadCookieConfigDefaults/Errors` passed | VERIFIED |
| Access token expires, API answers 401 | The SPA can read the 401 cross-origin and calls refresh | Global `http.cors` path: 401 and 500 carry ACAO+ACAC (my probe). `cors:<profile>` path: 401 carries no ACAO when the guard is listed before the profile or comes from a group; a 500 or 413 never carries them (my probe, CR-01) | PARTIAL (gap) |
| Refresh through the cookie | New cookie with the same attributes, old jti blacklisted, replay refused | `CookieRefreshHandler`; `TestCookieRefreshHandlerRotatesAndBlacklists` uses the real `bouncer.Refresh` and asserts the old jti is blacklisted and the replay answers 401; `TestCookieRefreshAcrossSubdomainsSmoke` passed in my run | VERIFIED |
| Requests land on different instances | Throttle counts one window across instances | `TestRateLimitAcrossInstancesSmoke` (two `Assemble`d apps, one real Redis container, `throttle:2,1`: 200, 200, then 429 from the other instance) passed in my run | VERIFIED |
| Upload a file to S3 | `bucket_url: s3://` works in a tagged binary, the app imports nothing | `s3_on.go` is the only `s3blob` importer (`//go:build summercms_s3`); `summer build` passes `-tags` from `summer.yaml` `build.tags`; `TestOpenBucketS3` passed in both tag modes | VERIFIED (real bucket: human item 2) |
| Read the API docs | `/api-docs` and `/api-docs.json` | `mountAPIDocs`; SRI hashes in `apidocs.go` match the live jsDelivr files (checked with curl and openssl) | VERIFIED (rendering under CSP: human item 4) |
| Register behind Turnstile | `turnstile:open|closed` middleware | `bouncer.Turnstile`, surf factory; 7 Turnstile tests + `TestTurnstileFactoryParams` passed | VERIFIED |
## Goal Achievement
### Observable Truths (roadmap success criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| SC-1 | `bouncer` refresh issues a new httpOnly `token` cookie with configurable `Domain`/`SameSite`/`Secure`, blacklists the old JTI; 60-min TTL and 14-day refresh window are config keys; credentialed CORS for a configured SPA origin is available as middleware | ✗ FAILED (partial) | Cookie half: VERIFIED. `cookie.go` builders, loader and handlers; old jti blacklist proven by `TestCookieRefreshHandlerRotatesAndBlacklists`. TTL keys `golem15.user.jwt.ttl` (60) / `refresh_ttl` (20160) exist in the user plugin (`api_controller.go:825-836`) and `admin.jwt.*` in `cabana/auth.go:454-463`. CORS half: global `http.cors` with `supports_credentials` works on every response, including 401 and 500. The named `cors:<profile>` middleware (D-05) drops its headers on refusals depending on order, and always on 413 and 500. See gap |
| SC-2 | Cache module with memory and Redis drivers; `surf` gets a Redis-backed `Store` so rate limits hold across instances; Redis client decision note before code | ✓ VERIFIED | `modules/cooler` (otter v2 memory driver, go-redis v9 Redis driver, Get/Set/Has/Forget/Remember/Increment/Flush). `RateLimitStore.Attempt` is one atomic Lua script (PTTL/INCR). `serve.go` `installCooler` publishes `surf.Store` before `BuildRouter`, and `BuildRouter` looks it up (`router.go:497`). Note commit `77dd11c` is an ancestor of `fa4d89d`, the first commit that adds go-redis to go.mod (`git merge-base --is-ancestor`). Cross-instance 429 proven against real Redis |
| SC-3 | S3 blob driver selectable through framework config without the app importing it; `/api-docs` + `/api-docs.json` servable; Turnstile middleware available | ✓ VERIFIED | `s3_on.go`/`s3_off.go` build tag, `checkS3Driver` error names `summercms_s3`, `goBuildArgs` adds `-tags=`. `mountAPIDocs` gated by `http.api_docs.enabled`, exactly-one-provider check, pinned 5.33.1 with SRI that matches the CDN. `turnstile:open|closed` factory registered in `BuildRouter` (`router.go:528-538`) |
| SC-4 | Touched modules' READMEs and `docs/` updated; new code has unit tests delivered in the last plan | ✓ VERIFIED | READMEs of bouncer, cabana, cooler (new, standard structure, root table row), lagoon, pact, surf changed; 13 docs pages changed; each new config key appears in docs (grep). `TestDocsTree` and `docs:build --check` passed in the gate. Plan 05 commits touch only `_test.go`, the gate script, go.mod/go.sum (miniredis) and the cooler README |
**Score:** 3/4 roadmap truths verified (SC-1 partial). Plan must-haves: 35/36 verified; the D-05 truth failed for the same reason as SC-1. 0 present-but-behavior-unverified.
### Plan must-haves (36 truths across 5 plans)
| Plan | Truths | Status | Notes |
|------|--------|--------|-------|
| 01 (cooler, Redis store) | 8 | 8 ✓ | Decision note ordering, module + README + root row, cross-instance window (`TestRateLimitAcrossInstancesSmoke`), MemoryStore parity (`TestRateLimitParity` miniredis + `TestRateLimitParityDocker`), fallback + once-per-minute warning (`TestRateLimitFallbackOnError`), `BuildRouter` never dials (`TestBuildRouterNeverDials`), Cache API + prefix-only Flush (`TestRedisFlushOnlyPrefix`), `FromApp` lazy (`TestFromAppLazy`) |
| 02 (cookies, CORS, Turnstile, api-docs) | 7 | 6 ✓, 1 ✗ | D-01, D-02, D-03, D-04, D-16/17 and D-18 verified by named tests. **D-05 failed (partial):** the profile config, validation and OPTIONS routes work (`TestCORSProfilePreflightRoute`, `...Unknown`, `...WithPathsRefused`, `...OverlapGlobal`), but the middleware does not reach error responses (CR-01) |
| 03 (S3 tag, DiskExtension, public_url) | 6 | 6 ✓ | `TestManifestBuildTags`, `TestSaveManifestKeepsBuildTags`, `TestGoBuildArgs`, `TestOpenBucketS3` (both tag modes), `TestDiskExtensionTable`, `TestStoreDiskExtension` (Postgres), `TestPublicURLBase/Validation`; deploy nosniff note at `docs/services/storage.md:122`; `store.go:154` calls `DiskExtension` |
| 04 (site-cache purge, plugins, layers) | 7 | 7 ✓ | `cooler/sitecache.go`; `pact.ReorderAfterSave` called inside the reorder transaction (`cabana/reorder.go:354`); menu/FAQ/Journal adoption with Docker admin tests; Journal alias keys and deprecated names kept (`sm-journal-plugin/classes/site_cache.go`); three byte-identical layer routes (gate `cmp`); plugin and layer repos clean and in sync with origin |
| 05 (tests, gate) | 8 | 8 ✓ | The gate ran green with `PHASE193_REQUIRE_DOCKER=1` (self-test, go, plugins, layers, docs, docker); detectors fail on a skipped or missing test name |
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `.planning/notes/2026-10-15-cooler-go-redis-otter.md` | decision note | ✓ VERIFIED | committed in `77dd11c`, before go-redis lands |
| `modules/cooler/{cooler,memory,redis,rediscache,ratelimit,sitecache}.go` | cache, store, purge | ✓ VERIFIED | 1,142 lines, imports only backpack/compass/lagoon |
| `modules/surf/serve.go` | `installCooler` | ✓ VERIFIED | called before `BuildRouter` |
| `modules/bouncer/cookie.go` | cookie config, builders, handlers | ✓ VERIFIED | bouncer imports no SummerCMS module (`go list`) |
| `modules/bouncer/turnstile.go` | verifier + middleware | ✓ VERIFIED | siteverify URL, 5 s timeout, 2048 limit |
| `modules/surf/cors.go` | validated CORS, profiles, preflights | ⚠️ PARTIAL | global wrapper correct; profile middleware placement defect (gap) |
| `modules/surf/apidocs.go` | provider, pinned page, spec route | ✓ VERIFIED | SRI matches CDN |
| `modules/lagoon/attach/{s3_on,s3_off,diskext}.go` | S3 tag, DiskExtension | ✓ VERIFIED | |
| `internal/build/manifest.go` | `Manifest.Tags` | ✓ VERIFIED | |
| `modules/pact/capabilities.go` | `ReorderAfterSave` | ✓ VERIFIED | |
| `../vue-*-layer/server/api/__site__/cache/purge.post.ts` | shared purge route | ✓ VERIFIED | identical in three layers |
| `scripts/check-phase19.3.sh` | phase gate | ✓ VERIFIED | ran green |
### Key Link Verification
| From | To | Via | Status |
|------|----|-----|--------|
| `surf/serve.go` | `cooler.Install` | `installCooler` → `app.Publish[Store]` when `http.rate_limit.store: redis` | WIRED |
| `surf/router.go` BuildRouter | published `Store` | `app.Lookup[Store]()` else `NewMemoryStore` | WIRED |
| `cooler/ratelimit.go` | `surf.Store` | structural `Attempt(key, max, decay)` | WIRED |
| `surf/router.go` | `cors.go` | `"cors"` factory + `addCORSPreflights` | WIRED (placement defect, see gap) |
| `surf/router.go` | `bouncer/turnstile.go` | `"turnstile"` factory, `buildTurnstile`, `ClientIP` with trusted proxies | WIRED |
| `bouncer/cookie.go` | `bouncer/refresh.go` | app-supplied Refresh wraps `Refresh`/`RefreshAudienceFor` | WIRED (test uses real `Refresh`) |
| `surf/router.go` | `apidocs.go` | `mountAPIDocs` | WIRED |
| `internal/build/build.go` | `manifest.go` | `goBuildArgs(binRel, m.Tags)` | WIRED |
| `attach/bucket.go` | `s3_off.go` | `checkS3Driver` / `s3Enabled` | WIRED |
| `attach/store.go` | `diskext.go` | `newDiskName(DiskExtension(sniffed, ext))` | WIRED |
| `attach/thumb.go` | `bucket.go` | `PublicURLBase()` | WIRED |
| `cabana/reorder.go` | `pact.ReorderAfterSave` | type assertion inside the Transaction closure with `txCtx` | WIRED |
| FAQ/menu/Journal controllers | `cooler.PurgeSiteCacheAfterCommit` | `cabana.TxFromContext` + `SiteCacheFromApp` | WIRED |
### Data-Flow Trace (Level 4)
Not applicable: no rendered dynamic data. The equivalent runtime flows (cookie → Refresh → blacklist; request → throttle → shared Redis counter; save → after-commit → purge POST) are each exercised by a passing test listed above.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Phase gate, all stages incl. Docker | `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` | exit 0; PASS self-test, go, plugins, layers, docs, docker | ✓ PASS |
| Cross-instance rate limit + cookie refresh smoke | `go test -count=1 -run '^(TestRateLimitAcrossInstancesSmoke\|TestCookieRefreshAcrossSubdomainsSmoke)$' ./modules/surf` | both PASS (real Redis container) | ✓ PASS |
| CR-01 reproduction (`cors:<profile>`) | scratch module (outside the repo) with `surf.Assemble` and a refusing `deny` middleware | `[deny,cors:spa]` 401 ACAO="" ; `[cors:spa,deny]` 401 ACAO set ; group `Use(cors:spa)` 401 ACAO set ; group `Use(deny)` + route `cors:spa` 401 ACAO="" ; panic 500 ACAO="" | ✗ FAIL |
| Global `http.cors` on error responses | same scratch module, credentialed `http.cors.paths` | 401 and 500 both carry ACAO + ACAC | ✓ PASS |
| Swagger UI SRI values | `curl` jsDelivr 5.33.1 assets, `openssl dgst -sha384` | all three equal the constants in `apidocs.go` | ✓ PASS |
### Probe Execution
No `scripts/*/tests/probe-*.sh` exist and no plan declares one. The phase gate (`scripts/check-phase19.3.sh`) was run by the verifier in its own process (see above).
### Requirements Coverage
REQUIREMENTS.md maps no IDs to phase 19.3 (no orphans). Phase-local IDs from PLAN frontmatter:
| ID | Source Plans | Status | Evidence |
|----|--------------|--------|----------|
| SC-1 | 02, 05 | ✗ PARTIAL | see gap (D-05 profile middleware) |
| SC-2 | 01, 05 | ✓ SATISFIED | cooler, Redis store, note ordering |
| SC-3 | 02, 03, 05 | ✓ SATISFIED | S3 tag, api-docs, Turnstile |
| SC-4 | 01-05 | ✓ SATISFIED | READMEs, docs, tests in plan 05 |
| D-01, D-02, D-03 | 02, 05 | ✓ SATISFIED | cookie tests |
| D-04 | 02, 05 | ✓ SATISFIED | literal contract met; WR-04 hardening gap is a warning |
| D-05 | 02, 05 | ✗ BLOCKED (partial) | CR-01 |
| D-06, D-07, D-08, D-09 | 01, 05 | ✓ SATISFIED | |
| D-10, D-11 | 04, 05 | ✓ SATISFIED | live purge is human item 3 |
| D-12, D-13, D-14, D-15 | 03, 05 | ✓ SATISFIED | real bucket is human item 2 |
| D-16, D-17 | 02, 05 | ✓ SATISFIED | rendering under CSP is human item 4 |
| D-18 | 02, 05 | ✓ SATISFIED | |
| D-19 | 01-04 | ✓ SATISFIED | placement matches (cooler / surf / bouncer / lagoon/attach) |
### Prohibitions (all judgment-tier; non-authoritative verifier verdicts)
| Plan | Prohibition | Verdict | Evidence |
|------|-------------|---------|----------|
| 01 | cooler imports neither surf nor cabana | holds | `go list` imports: backpack, compass, lagoon |
| 01/04 | Framework code and docs never name a consuming application | holds | gate docs stage: "no consuming application named in framework lines since 2d352ff^" (older test files are logged in deferred-items.md) |
| 01 | Cache Flush never empties the whole Redis database | holds | SCAN MATCH `<prefix>cache:*` + UNLINK; `TestRedisFlushOnlyPrefix` |
| 02 | bouncer imports no SummerCMS module | holds | `go list` shows none |
| 02 | Non-credentialed `*` CORS answers byte-identical | holds | `TestCORSOptionsMatchesLaravel`, `TestCORSStarUnchanged` |
| 02 | Turnstile secret/token never logged | holds | `TestTurnstileNeverLogsSecret` |
| 03 | No new `s3.*` keys | holds | no `"s3.` config reads in modules/internal |
| 03 | Uploads passing today's allow-list still pass | holds | allow-list check unchanged; only the disk name derives from the sniff |
| 03 | App call sites outside the framework untouched | holds | no fonoteka.go commits today |
| 04 | No breaking change to Journal/menu/FAQ | holds | alias keys and deprecated names kept; `TestSiteCacheAliasKeysDeprecated`, admin Docker suites green |
| 04/05 | Nothing pushed/tagged without the human's reply | not code-verifiable | SUMMARY records the checkpoint replies; v0.1.27 points at `9921768`, and no code commits follow it |
| 05 | Tests reach no network beyond localhost/httptest/miniredis/testcontainers | holds (sampled) | Turnstile tests set `Endpoint` to httptest servers |
| 05 | No production code changes in plan 05 except recorded deviations | holds | plan 05 commits touch only tests, the gate, go.mod/go.sum and the cooler README |
### Anti-Patterns Found
No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK` markers in any file the phase changed (the gate script's `TODO` match is its TAP directive parser). The open code-review findings are the real debt:
| File | Finding | Severity | Impact |
|------|---------|----------|--------|
| `modules/surf/router.go`, `cors.go` | CR-01: profile CORS headers missing from refusals/413/500 | 🛑 Blocker | SPA cannot read the 401 that triggers cookie refresh (gap above) |
| `modules/cooler/sitecache.go` | WR-01: purge is synchronous after commit; bulk delete sends N purges | ⚠️ Warning | slow admin saves when the site is slow; accepted "no debounce" (user answer in plan 04), but the 2 s × N latency was not weighed |
| `modules/cooler/sitecache.go` | WR-02: purge client follows redirects with the bearer token | ⚠️ Warning | token leak on an https→http redirect |
| `vue-*-layer/.../purge.post.ts` | WR-03: route refuses proxied requests; Go side accepts https remote targets | ⚠️ Warning | remote mode cannot work; D-10 only promised loopback |
| `modules/surf/cors.go` | WR-04: `https://*` entries and unanchored patterns still trust arbitrary origins with credentials | ⚠️ Warning | same exposure D-04 meant to close, by another spelling |
| `modules/bouncer/cookie.go` | WR-05: inferred Secure=false in production when `app.url` is http | ⚠️ Warning | matches D-03's literal rule but produces an insecure cookie silently |
| `modules/surf/apidocs.go` | WR-06: CSP allowlists all of jsDelivr for scripts | ⚠️ Warning | weakens the nonce |
| `modules/lagoon/attach/bucket.go` | WR-07: protected files land in a public bucket when `public_url` is set | ⚠️ Warning | Info-level log only |
| `modules/surf/router.go` | WR-08: `turnstile:*` with no secret logs nothing at boot | ⚠️ Warning | silent bot-protection off |
| `modules/bouncer/cookie.go` | WR-09: zero `CookieConfig` has no SameSite and no Secure | ⚠️ Warning | contradicts its own field doc |
Info items IN-01..IN-11 and deferred-items.md (S3 object content type, Redis outage latency in `Remember`) remain as recorded in 19.3-REVIEW.md. 19.3-VALIDATION.md is still `status: draft`, `nyquist_compliant: false`.
### Human Verification Required (after the gap closes)
These remain open from 19.3-VALIDATION.md and are not satisfiable by tests. Status is `gaps_found`, so they are listed here and not in the frontmatter.
1. **Cookie refresh across real subdomains.** Deploy to staging with `auth.cookie.domain` set to the parent domain and `cors:<profile>` or `http.cors` credentialed for the www origin. Log in on www and wait past the access TTL. Expected: the 401 is readable in the SPA, the refresh call sends the cookie and the rotated cookie replaces it. Why human: needs real DNS and TLS, and a browser's cookie and CORS enforcement.
2. **Real S3/R2/MinIO upload.** Build with `build.tags: [summercms_s3]`, set `bucket_url: s3://...?endpoint=...`, upload, fetch via `public_url` and via `/storage/uploads`. Expected: both serve the file. Note deferred-items' stored Content-Type caveat. Why human: needs bucket credentials.
3. **Live Nuxt purge.** Save a menu entry and a FAQ in admin. Expected: the page updates without waiting for the 300 s SWR TTL. Why human: needs a running Nuxt site and the app.
4. **Swagger UI under its CSP.** Enable `http.api_docs`, open `/api-docs`. Expected: the UI renders the spec with no CSP violations in the console. Why human: browser rendering; the SRI values were checked automatically.
5. **Triage the open review warnings WR-01..WR-09.** Set each disposition in 19.3-REVIEW-DISPOSITION.md (fix in the gap-closure plan, defer with a reason, or skip). WR-04 and WR-02 are security-relevant and cheap to fix next to CR-01.
### Gaps Summary
The phase delivers almost all of its goal, and the evidence is solid: the gate runs green including Docker, the cross-instance rate limit is proven against a real Redis, the cookie refresh path uses the real `Refresh` and its blacklist, the S3 tag guard works in both tag modes, and the api-docs SRI values match the CDN.
One gap remains, and it hits the phase's headline flow. `cors:<profile>`, the named credentialed-CORS middleware that D-05 added for SPA route groups, is applied at its list position inside body-limit and panic recovery. A guard, throttle or Turnstile refusal listed before it, a guard from an enclosing group, a 413 and a recovered 500 all leave without `Access-Control-Allow-Origin`, so the browser hides the 401 the SPA needs in order to refresh its cookie. I reproduced this on HEAD. Apps that use the global `http.cors` paths are not affected, which is why SC-1 is partial rather than absent. The fix is local: apply the profile outermost in `Router.wrap`, add a refusing-guard test to the gate, and adjust the routing docs. That needs a small gap-closure plan and a patch release (v0.1.28). No later phase (19.4 OIDC, 19.5 social login, 19.6 2FA) covers it, so it is not deferred.
**If you decide to accept the current behaviour instead** (for example, by documenting that `cors:<profile>` must come first and only the global `http.cors` covers 413/500), record it as an override:
```yaml
overrides:
- must_have: "D-05: a named middleware cors:<profile> applies the profile's CORS policy to routes that carry it"
reason: "Documented that cors:<profile> must be listed first (or set on the group); 413/500 are not covered; apps that need every response covered use http.cors paths"
accepted_by: "{name}"
accepted_at: "{ISO timestamp}"
```
---
_Verified: 2026-10-15T20:45:00Z_
_Verifier: Claude (msd-verifier)_