From ebdd896d82170a24d0073c1ba9ec7cc44f4b83e7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 20:28:18 +0200 Subject: [PATCH] docs(19.3): add phase verification report (gaps found) --- .../19.3-VERIFICATION.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md diff --git a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md new file mode 100644 index 0000000..acb8465 --- /dev/null +++ b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md @@ -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: 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: 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: 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:` 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:` 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:`) | 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 `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:` 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:`, 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:` 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: applies the profile's CORS policy to routes that carry it" + reason: "Documented that cors: 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)_