docs(19.3): re-verification after gap closure (human checks pending)

This commit is contained in:
Jakub Zych
2026-10-15 23:10:59 +02:00
parent c5d9a7b6b1
commit 56fe2ae372

View File

@@ -1,8 +1,8 @@
---
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
verified: 2026-10-15T21:08:52Z
status: human_needed
score: 4/4 roadmap success criteria verified; plan must-haves 44/44 verified (36 from plans 01-05, 8 from gap-closure plan 06); 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
@@ -14,6 +14,8 @@ covered_files:
- .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
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-SUMMARY.md
- go.mod
- go.sum
- internal/build/build.go
@@ -39,46 +41,56 @@ covered_files:
- modules/surf/router.go
- modules/surf/serve.go
- scripts/check-phase19.3.sh
covered_digest: "v2:sha256:f9d58003dac25f701e22b19bc467b0e06c88a94264b5dce19b90c2a29bacc6b5"
covered_digest: "v2:sha256:469d289a206fd43b90c3401d9971e57193333d0c2efefa1c88a2356520b1a092"
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"
re_verification:
previous_status: gaps_found
previous_verified: 2026-10-15T20:45:00Z
previous_score: 3/4 roadmap success criteria (SC-1 partial); plan must-haves 35/36 (D-05 failed)
gaps_closed:
- "SC-1 / D-05 (CR-01): cors:<profile> headers now reach every response of a profiled route. Router.wrap skips the cors entry in its middleware loop and wraps the recovered handler in r.corsProfiles[profile].profileMiddleware just inside recordPattern (modules/surf/router.go:483-485, fix commit 0723536). Verifier probe on HEAD c5d9a7b: [deny, cors:spa] 401, group Use(deny) + route cors:spa 401, nested group 401, handler panic 500 (JSON), middleware panic 500, raw-group panic 500, oversize body 413, throttle 429 all carry ACAO=https://www.example.com, ACAC=true, Vary: Origin; disallowed origin gets Vary: Origin only. The same probe against the pre-fix tree (0723536^) fails 7 of 11 cases."
gaps_remaining: []
regressions: []
advisory:
- finding: "WR-04 residual: credentialed allowed_origins https://*.co.uk loads (probe origins catch https://*.com and https://*, not multi-label public suffixes such as co.uk or com.pl)"
category: security
reason: "Raised in this re-verification by a scratch probe of surf.LoadCORSConfig (https://*.co.uk -> no error; https://*.com, https://*, https://*example.com -> error). Plan 06's must-have names probe origins and the https://*.com example, and that contract holds; refusing public-suffix wildcards would need a public-suffix list (a new dependency) or an extra probe set. Operator must write such an entry deliberately. Resolve by adding probes such as https://cors-probe.co.uk / .com.pl, or accept and note in docs/services/routing.md."
evidence_status: "scratch-probe output only; new scope relative to the carried-forward gap, not blocking"
human_verification:
- test: "Cookie refresh across real api/www subdomains in a browser. 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, wait past the access TTL."
expected: "The SPA reads the 401, the refresh call sends the cookie, the rotated cookie replaces the old one, the replayed old token answers 401."
why_human: "Needs real DNS, TLS and a browser's cookie and CORS enforcement; httptest cannot reproduce SameSite/Domain handling."
- test: "S3-compatible upload. Build with build.tags [summercms_s3], set storage bucket_url s3://...?endpoint=..., upload a public file, fetch it via storage.uploads.public_url and via /storage/uploads; try a protected (is_public false) upload with public_url set."
expected: "Both URLs serve the public file; the protected upload is refused with attach.ErrProtectedNeedsPrivateBucket (admin answers the opaque 500 and logs the reason)."
why_human: "Needs bucket credentials. Note deferred-items.md: the blob's stored Content-Type is the sniffed type."
- test: "Live Nuxt purge. Save a menu entry and a FAQ entry in admin against a running Nuxt site using the 0.3.0/0.5.0 layers; for a proxied deployment set NUXT_SITE_CACHE_ALLOW_PROXIED=true."
expected: "The page updates without waiting for the 300 s SWR TTL; the app log shows 'cooler: site cache purged' after the save returned."
why_human: "Needs a running Nuxt site and the app; purges are now asynchronous."
- test: "Swagger UI under its new nonce-only CSP. Enable http.api_docs, open /api-docs in a browser."
expected: "The UI renders the spec with no CSP violations in the console (script-src is now only 'nonce-...')."
why_human: "Browser rendering; plan 06 (WR-06) tightened script-src, and whether the pinned swagger-ui-bundle 5.33.1 needs anything beyond nonce-tagged scripts is only observable in a browser. SRI values were checked automatically in the initial verification."
---
# 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
**Mode:** mvp (ROADMAP marks the phase `mode: mvp`, but the goal is not written as a user story; as in the initial report, this report uses standard goal-backward verification with a derived User Flow Coverage table. Run `/msd mvp-phase 19.3` to restate the goal, or drop `mode: mvp`, to make the mode consistent.)
**Verified:** 2026-10-15T21:08:52Z (HEAD `c5d9a7b`, local master, not yet tagged or pushed: expected, the release follows verification)
**Status:** human_needed
**Re-verification:** Yes, after gap-closure plan 19.3-06
## Re-verification Summary
| Item | Previous (2026-10-15T20:45Z) | Now | Evidence |
|------|------------------------------|-----|----------|
| SC-1 / D-05, CR-01: `cors:<profile>` headers on refusals, 413, 500 | ✗ FAILED (partial) | ✓ VERIFIED | Verifier probe (below) on HEAD: 11/11 PASS. Same probe on the pre-fix tree `0723536^`: 7/11 FAIL. `TestCORSProfileHeadersOnRefusals` and `TestCORSProfileVaryKeptWithHandlerVary` are required by name in `SURF_TESTS` and passed in the gate |
| WR-01..WR-09 (warnings) | open | fixed, verified in code and by named tests | see Plan 06 must-haves |
| Previously verified must-haves (35) | ✓ | ✓ no regressions | full gate with Docker green; cross-instance smokes and `go test -short ./...` green |
### Resolved gap (history)
The initial verification recorded one gap. `Router.wrap` composed `cors:<profile>` at its list position, inside the body limit and panic recovery. A guard listed before the profile, a guard on an enclosing group and a recovered 500 therefore answered without `Access-Control-Allow-Origin`, and the browser hid the 401 that starts the SPA's cookie refresh. Plan 06 (commit `0723536`) resolves the profile through `routeCORSProfile`, skips the `cors:` entry in the middleware loop after the unknown-profile check, and wraps the recovered handler in `r.corsProfiles[profile].profileMiddleware` just inside `recordPattern` (`modules/surf/router.go:417-485`). `bufferedResponse.commit` now merges `Vary` through `addVary` instead of replacing it (`router.go:837-852`), so a handler's own `Vary` does not drop `Vary: Origin`. The docs say the profile applies whatever its position: `docs/services/routing.md:101,271`, `modules/surf/README.md:31` and `docs/architecture/request-lifecycle.md:40`.
## User Flow Coverage (derived; the goal is not a user story)
@@ -86,13 +98,13 @@ Flow: a SPA on `www.example.com` talks to an API on `api.example.com` run as sev
| 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 |
| Log in, get a cookie | httpOnly `token` cookie, Domain/SameSite/Secure from `auth.cookie.*`, Max-Age = refresh window; production refuses an insecure cookie | `bouncer.LoadCookieConfig`, `CookieConfig.TokenCookie`; `TestTokenCookieMaxAgeIsRefreshWindow`, `TestLoadCookieConfigProductionInferredSecure`, `TestZeroCookieConfigDefaults` passed | VERIFIED |
| Access token expires, API answers 401 | The SPA can read the 401 cross-origin | Global `http.cors`: verified in the initial run. `cors:<profile>`: 401 carries ACAO+ACAC+Vary whatever the order or group nesting (probe, this run) | VERIFIED (real browser: human item 1) |
| Refresh through the cookie | New cookie, old jti blacklisted, replay refused | `TestCookieRefreshHandlerRotatesAndBlacklists` (real `bouncer.Refresh`), `TestCookieRefreshAcrossSubdomainsSmoke` passed in this run | VERIFIED |
| Requests land on different instances | Throttle counts one window across instances | `TestRateLimitAcrossInstancesSmoke` (two apps, one real Redis container) passed in this run; the 429 also carries the profile's CORS headers (probe) | VERIFIED |
| Upload a file to S3 | `bucket_url: s3://` in a tagged binary, no app import | `s3_on.go`/`s3_off.go`; `TestOpenBucketS3` in both tag modes; protected uploads refused when `public_url` is set (`TestStoreProtectedNeedsPrivateBucket`) | VERIFIED (real bucket: human item 2) |
| Read the API docs | `/api-docs` and `/api-docs.json` | `mountAPIDocs`; nonce on all three script tags (`apidocs.go:65-67`), `script-src 'nonce-…'` only (`apidocs.go:208`); `TestAPIDocsCSPNoHostSource` passed | VERIFIED (rendering under CSP: human item 4) |
| Register behind Turnstile | `turnstile:open|closed` middleware; refusals readable cross-origin | `bouncer.Turnstile`, surf factory; Turnstile 422/503 carry the profile's headers (`TestCORSProfileHeadersOnRefusals/turnstile_*`); boot warning without a secret (`TestTurnstileEmptySecretWarnsAtBoot`) | VERIFIED |
## Goal Achievement
@@ -100,160 +112,157 @@ Flow: a SPA on `www.example.com` talks to an API on `api.example.com` run as sev
| # | 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 |
| 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 | ✓ VERIFIED | Cookie half unchanged from the initial run and still green. CORS half: global `http.cors` (initial run) and now `cors:<profile>` decorate every response of the route; verifier probe 11/11 on HEAD, 7/11 FAIL on the pre-fix tree; credentialed wildcard/unanchored origins refused at boot (`TestCORSCredentialedOriginRules`, `TestCORSAnchoredPatternNoSuffixMatch`) |
| SC-2 | Cache module with memory and Redis drivers; Redis-backed `Store` so rate limits hold across instances; Redis client decision note before code | ✓ VERIFIED | Unchanged since the initial run; cooler suites and `TestRateLimitParityDocker`, `TestRedisCacheDocker` passed in the gate; `TestRateLimitAcrossInstancesSmoke` passed separately |
| SC-3 | S3 blob driver selectable through framework config without the app importing it; `/api-docs` + `/api-docs.json` servable; Turnstile middleware available | ✓ VERIFIED | Unchanged artifacts plus plan 06 hardening (WR-06, WR-07, WR-08), each pinned by a required test that passed in the gate (attach tests ran untagged and with `-tags summercms_s3`) |
| SC-4 | Touched modules' READMEs and `docs/` updated; new code has unit tests in the last plan | ✓ VERIFIED | Plan 06 updated the surf, bouncer, lagoon and cooler READMEs and `docs/services/{routing,authentication,storage,caching}.md`, `docs/architecture/request-lifecycle.md`; `TestDocsTree` and `docs:build --check` passed in the gate's docs stage. Plan 06 commits its tests in `8d80580` (Task 6) after the fix commits, so the "unit tests last" rule holds for the gap-closure plan |
**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.
**Score:** 4/4 roadmap truths verified. Plan must-haves 44/44 verified (0 present-but-behavior-unverified).
### Plan must-haves (36 truths across 5 plans)
### Plan must-haves
| 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 |
| 01 (cooler, Redis store) | 8 | 8 ✓ | Regression check: cooler required tests and Docker parity passed in this gate run |
| 02 (cookies, CORS, Turnstile, api-docs) | 7 | 7 ✓ | D-05 previously failed, now VERIFIED (CR-01 closed) |
| 03 (S3 tag, DiskExtension, public_url) | 6 | 6 ✓ | Regression check: build and attach tests (both tag modes) and `TestStoreDiskExtension` (Postgres) passed |
| 04 (site-cache purge, plugins, layers) | 7 | 7 ✓ | Regression check: plugin Docker admin suites passed with the queued purge; three layers' shared files still byte-identical (gate `cmp`) |
| 05 (tests, gate) | 8 | 8 ✓ | Gate still fail-closed on required names (lists include the plan 06 tests) |
| 06 (gap closure) | 8 | 8 ✓ | See below |
### Required Artifacts
**Plan 06 truths:**
| # | Truth (abridged) | Status | Evidence |
|---|------------------|--------|----------|
| 1 | CR-01: every response of a `cors:<profile>` route carries ACAO (exact origin), ACAC, `Vary: Origin` for 401/403 (route-before or group), 422/503 Turnstile, 429, 413, panic 500 (JSON and raw); disallowed origin gets `Vary: Origin` only; handler `Vary` merged | ✓ VERIFIED | Verifier probe 11/11 (real HTTP server via `httptest.NewServer`); `TestCORSProfileHeadersOnRefusals` (11 subtests incl. Turnstile 422/503, 403) and `TestCORSProfileVaryKeptWithHandlerVary` passed in the gate |
| 2 | CR-01 gate and docs | ✓ VERIFIED | `scripts/check-phase19.3.sh:80` lists both tests in `SURF_TESTS`; docs lines cited above |
| 3 | D-04 / WR-04: credentialed wildcard-host, unanchored and probe-matching origins fail boot, globally and per profile; non-credentialed configs unchanged | ✓ VERIFIED | `cors.go` `wildcardHostLabel`, `anchoredOriginPattern` (parsed with `regexp/syntax`), `corsProbeOrigins`; `TestCORSCredentialedOriginRules`, `TestCORSAnchoredPatternNoSuffixMatch`, `TestCORSOptionsMatchesLaravel`, `TestCORSStarUnchanged` passed. Verifier probe: `https://*.com`, `https://*`, `https://*example.com` refused, `https://*.example.com` loads. Residual: see Advisory |
| 4 | D-03 / WR-05, WR-09: production refuses Secure resolving to false (set or inferred); zero `CookieConfig` writes Lax + Secure | ✓ VERIFIED | `cookie.go:111` error names `auth.cookie.secure` and `app.url`; `TestLoadCookieConfigProductionInferredSecure`, `TestZeroCookieConfigDefaults`, `TestLoadCookieConfigErrors` passed |
| 5 | D-17, D-12, D-18 / WR-06, WR-07, WR-08 | ✓ VERIFIED | `apidocs.go:65-67,208`; `attach/store.go:42,136` returns `ErrProtectedNeedsPrivateBucket` after the argument checks, before the body is read; boot warning in `bucket.go`; Turnstile secret warning in `BuildRouter`; `TestAPIDocsCSPNoHostSource`, `TestStoreProtectedNeedsPrivateBucket` (both tag modes), `TestTurnstileEmptySecretWarnsAtBoot` passed |
| 6 | D-10, D-11 / WR-02, WR-01: no redirects; queued, coalesced after-commit purges; `Wait`; `Handle.Close` drains; exported names unchanged | ✓ VERIFIED | `sitecache.go:109,115` `CheckRedirect: noRedirect` → `http.ErrUseLastResponse`; `enqueue`/`work`/`Wait` (`sitecache.go:212-275`) keep one request in flight and one pending follow-up; `cooler.go:119` waits in `Close`. `TestSiteCachePurgeNoRedirect` (redirect target receives 0 requests, 307 logged, token not logged), `TestSiteCachePurgeAsyncCoalesced`, `TestSiteCachePurgeWaitAndClose`, Docker `TestSiteCacheBulkCommitCoalesced`, `TestSiteCacheAfterCommitOnly` passed. Plugin commits change only test files (`git show --stat`) |
| 7 | D-11 / WR-03: layer purge route honours `siteCache.allowProxied`; shared files byte-identical with updated pins; caching docs pair remote https `purge_url` with the flag | ✓ VERIFIED | `purge.post.ts` passes `allowProxied` (`=== true` or `'true'`) to `authorizeSitePurge`; sha256 of the shared files is identical in vue-faq-layer, vue-menu-layer, vue-journal-layer; layer TAP 24/37/170 pass, including "allowProxied lets a proxied request reach the token check" |
| 8 | Release precondition: gate `--all` green with Docker and names every new test; disposition records CR-01, WR-01..WR-09 fixed; nothing tagged or pushed before the human reply | ✓ VERIFIED | Gate exit 0 (below). 19.3-REVIEW-DISPOSITION.md: 10 fixed, 11 info open. `git tag --contains 0723536` empty; summercms.go 22 commits ahead of `origin/master`, no `v0.1.28` on origin; plugins and layers each 1 commit ahead of origin with no tag on HEAD |
### Required Artifacts (plan 06)
| 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 |
| `modules/surf/router.go` | profile outermost; Turnstile boot warning | ✓ VERIFIED | `profileMiddleware` applied at `router.go:483-485` |
| `modules/surf/cors.go` | credentialed origin rules with probe origins | ✓ VERIFIED | `cors-probe.invalid` at `cors.go:92-93` |
| `modules/surf/apidocs.go` | nonce on every script tag | ✓ VERIFIED | 3 tags |
| `modules/bouncer/cookie.go` | production refusal, zero-config defaults | ✓ VERIFIED | "resolves to false" at `cookie.go:111` |
| `modules/lagoon/attach/store.go` | protected-upload refusal | ✓ VERIFIED | |
| `modules/cooler/sitecache.go` | no-redirect client, queue, Wait | ✓ VERIFIED | |
| `../vue-faq-layer/server/lib/siteCachePurge.ts` (and two siblings) | allowProxied opt-in | ✓ VERIFIED | byte-identical |
| `scripts/check-phase19.3.sh` | requires gap-closure tests | ✓ VERIFIED | |
### Key Link Verification
Artifacts from plans 01-05: unchanged status (✓ VERIFIED; `cors.go` upgraded from ⚠️ PARTIAL).
### Key Link Verification (plan 06)
| 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 |
| `surf/router.go` wrap | `surf/cors.go` | `routeCORSProfile(rt)` then `r.corsProfiles[profile].profileMiddleware(h)` after recovery, inside `recordPattern` | WIRED (probe confirms runtime effect) |
| `scripts/check-phase19.3.sh` | `surf/cors_profile_test.go` | `SURF_TESTS` names `TestCORSProfileHeadersOnRefusals` | WIRED (ran in the go stage: 29 surf tests passed) |
| `cooler/sitecache.go` | `lagoon.AfterCommit` | `PurgeSiteCacheAfterCommit` callback only calls `p.enqueue` | WIRED |
| `cooler/cooler.go` | `cooler/sitecache.go` | `Handle.Close` → `h.purger.Wait(ctx)` | WIRED |
| layer `purge.post.ts` | layer `siteCachePurge.ts` | `authorizeSitePurge({..., allowProxied})` | WIRED |
Key links from plans 01-05: unchanged, 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.
Not applicable: no rendered dynamic data. The runtime flows (cookie → Refresh → blacklist; request → throttle → shared Redis counter; save → after-commit → queued purge POST; refusal → profile CORS headers) are each exercised by a passing test or the verifier probe.
### 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 |
| Phase gate, all stages, Docker required | `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` | exit 0; PASS self-test, go, plugins, layers, docs, docker ("Phase 19.3 gate passed") | ✓ PASS |
| CR-01 on HEAD (`cors:<profile>`) | scratch module outside the repo (`replace` to the repo), `surf.Assemble` + `httptest.NewServer`, profile `spa` credentialed for `https://www.example.com` | `[deny,cors:spa]` 401, `[cors:spa,deny]` 401, group `Use(deny)` + route `cors:spa` 401, group `Use(cors:spa)` > inner group `Use(deny)` 401, handler panic 500, `[boom,cors:spa]` middleware panic 500, raw-group panic 500, `body.limit:16` 64-byte POST 413, throttle 200 then 429: all ACAO=`https://www.example.com`, ACAC=`true`, Vary=`Origin`. Disallowed origin 401: no ACAO/ACAC, Vary=`Origin`. ALL PASS | ✓ PASS |
| Same probe on the pre-fix tree (`git archive 0723536^`) | identical probe, `replace` to the extracted tree | 7/11 FAIL (route-before 401, group 401, all three 500s, 429, disallowed-origin Vary) | discriminating, as expected |
| Credentialed wildcard validation | scratch module calling `surf.LoadCORSConfig` | `https://*.example.com` ok; `https://*.com`, `https://*`, `https://*example.com` refused; `https://*.co.uk` ok (Advisory) | ✓ PASS (contract) |
| Cross-instance rate limit and cookie refresh smokes | `go test -count=1 -run '^(TestRateLimitAcrossInstancesSmoke\|TestCookieRefreshAcrossSubdomainsSmoke\|TestCookieRefreshHandlerRotatesAndBlacklists)$' ./modules/surf ./modules/bouncer` | all PASS (real Redis container) | ✓ PASS |
| Regression, whole module (once) | `go test -short -count=1 ./...` | all packages ok | ✓ PASS |
| examples/hello workspace module | `cd examples/hello && go test -count=1 ./...` | ok | ✓ 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).
No `scripts/*/tests/probe-*.sh` exist and no plan declares one. The phase gate was run by the verifier in its own process (above).
### Requirements Coverage
REQUIREMENTS.md maps no IDs to phase 19.3 (no orphans). Phase-local IDs from PLAN frontmatter:
REQUIREMENTS.md maps no IDs to phase 19.3 (no orphans). Phase-local IDs from PLAN frontmatter (plans 01-06):
| ID | Source Plans | Status | Evidence |
|----|--------------|--------|----------|
| SC-1 | 02, 05 | ✗ PARTIAL | see gap (D-05 profile middleware) |
| SC-1 | 02, 05, 06 | ✓ SATISFIED | cookie tests; CR-01 closed |
| 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 |
| SC-3 | 02, 03, 05, 06 | ✓ SATISFIED | S3 tag, api-docs (nonce-only CSP), Turnstile |
| SC-4 | 01-06 | ✓ SATISFIED | READMEs, docs, tests last in each plan |
| D-01, D-02 | 02, 05 | ✓ SATISFIED | cookie tests |
| D-03 | 02, 05, 06 | ✓ SATISFIED | plus WR-05/WR-09 hardening |
| D-04 | 02, 05, 06 | ✓ SATISFIED | WR-04 fixed; public-suffix residual is Advisory |
| D-05 | 02, 05, 06 | ✓ SATISFIED | CR-01 closed |
| 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-10, D-11 | 04, 05, 06 | ✓ SATISFIED | WR-01/02/03 fixed; live purge is human item 3 |
| D-12 | 03, 05, 06 | ✓ SATISFIED | WR-07 fixed |
| D-13, D-14, D-15 | 03, 05 | ✓ SATISFIED | real bucket is human item 2 |
| D-16, D-17 | 02, 05, 06 | ✓ SATISFIED | WR-06 fixed; rendering under CSP is human item 4 |
| D-18 | 02, 05, 06 | ✓ SATISFIED | WR-08 fixed |
| D-19 | 01-04 | ✓ SATISFIED | placement matches (cooler / surf / bouncer / lagoon/attach) |
### Prohibitions (all judgment-tier; non-authoritative verifier verdicts)
Plans 01-05: verdicts from the initial run still hold (cooler imports, bouncer imports, Flush scope, byte-identical `*` CORS, Turnstile secret never logged, no new `s3.*` keys, Journal/menu/FAQ compatibility, test network scope).
| 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 |
| 06 | No breaking change to Journal, menu, FAQ: exported cooler purge names/signatures, Journal aliases, plugin controllers unchanged; only plugin test files change | holds | plugin commits `e52b286`, `4b42978`, `4f633f0` touch only `*_test.go`; `PurgeSiteCacheAfterCommit` and `SiteCachePurger.Purge` keep their signatures (`Wait` is additive); Journal alias tests passed. Note: the plugin tests now call `SiteCachePurger.Wait`, so the plugins need the v0.1.28 require bump before they build on origin without go.work (recorded in the SUMMARY handoff) |
| 06 | Non-credentialed `*` CORS byte-identical | holds | `TestCORSOptionsMatchesLaravel`, `TestCORSStarUnchanged` passed |
| 06 | Framework code and docs never name a consuming application | holds | gate docs stage: "no consuming application named in framework lines since 2d352ff^" |
| 06 | Nothing pushed or tagged before the human's reply | holds | no tag contains `0723536`; no `v0.1.28` on origin; plugins and layers 1 commit ahead with untagged HEADs |
| 06 | IN-01..IN-11 not addressed | holds | disposition shows all 11 `open` |
### 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:
No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK` markers in files changed by plan 06 (the only match is the gate's TAP `SKIP|TODO` directive parser at `scripts/check-phase19.3.sh:261`). No stubs: every plan 06 change is exercised by a named test that ran.
| 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 |
| `modules/surf/cors.go` | Credentialed `https://*.co.uk`-style wildcards (multi-label public suffix) still load | 📋 Advisory | Needs a deliberate misconfiguration; outside plan 06's stated contract. See frontmatter `advisory` |
| 19.3-REVIEW.md IN-01..IN-11 | Info findings, open by design | ℹ️ Info | recorded in 19.3-REVIEW-DISPOSITION.md |
| deferred-items.md | S3 object Content-Type from sniff; Redis `Remember` outage latency; pre-existing gofmt/naming in older files | ℹ️ Info | follow-ups, unchanged |
| 19.3-VALIDATION.md | still `status: draft`, `nyquist_compliant: false` | ℹ️ Info | `/msd-validate-phase 19.3` can settle it |
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`.
### Advisory (New Scope, Unevidenced)
### Human Verification Required (after the gap closes)
| # | Finding | Category | Why Advisory |
|---|---------|----------|--------------|
| 1 | Credentialed wildcard on a multi-label public suffix (`https://*.co.uk`) passes boot validation | security | new scope relative to the carried-forward gap; only scratch-probe evidence; plan 06's contract (probe origins, `https://*.com`) holds |
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.
### Human Verification Required
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.
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 SPA reads the 401, the refresh call sends the cookie, 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`; try a protected upload with `public_url` set.
- Expected: both URLs serve the public file; the protected upload is refused.
- Why human: needs bucket credentials.
3. **Live Nuxt purge.** Save a menu entry and a FAQ entry in admin against a site on the new layer versions (set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` behind a proxy).
- Expected: the page updates without waiting for the 300 s SWR TTL; the purge is logged after the save returned.
- Why human: needs a running Nuxt site and the app.
4. **Swagger UI under the nonce-only 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; WR-06 tightened `script-src` since the initial verification.
The initial report's item 5 (triage WR-01..WR-09) is settled: all ten CR/WR findings are `fixed` in 19.3-REVIEW-DISPOSITION.md.
### 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}"
```
No gaps. The one gap from the initial verification (SC-1 / D-05, CR-01) is closed in code, docs and gate. The verifier reproduced the earlier failure scenarios in a scratch module outside the repo: every case now carries the profile's headers on HEAD, and the same probe still fails on the pre-fix tree, so it actually tests the fix. The full gate passes with Docker required, and the earlier must-haves show no regression. The status is `human_needed` because four checks need real infrastructure or a browser. The release steps in the 19.3-06 SUMMARY handoff remain for the phase session: whole-phase docs, the v0.1.28 tag, the plugin require bumps and the layer tags. Until the plugins move to v0.1.28, their tests do not build on origin without go.work.
---
_Verified: 2026-10-15T20:45:00Z_
_Verified: 2026-10-15T21:08:52Z_
_Verifier: Claude (msd-verifier)_