Skip to content

Cached-scope header lock: ppr = document-level cache() — ONLY middleware writes headers in cached scenarios; handlers/loaders disallowed loudly #713

Closed

Description

@ivogt

Found in the field (2026-07-06, storefront basket investigation)

While exonerating rango for an app-side session bug, the investigation proved: response headers set by HANDLERS (e.g. Cache-Control, custom x-*) are present on MISS responses and absent on HIT responses — handlers are frozen on the fast path and nothing replays their headers from the entry. Middleware-set headers/cookies ride only because middleware runs live. NO test in the shell suites asserts MISS→HIT header fidelity, so a regression here (including one that dropped middleware Set-Cookie — session-breaking) would ship silently.

Contract to define + implement

  1. Handler-set headers: capture them into the ShellCacheEntry at capture time and REPLAY them on every HIT (browser-can't-tell doctrine — headers are observable). Decide the exclusion list (per-request/security-sensitive headers must NOT bake: set-cookie from handlers should either be forbidden-at-capture or explicitly documented as live-lane-only).
  2. Middleware-set headers + Set-Cookie: already live per request — PIN with tests so it can never regress (this is session integrity).
  3. Producer B parity: build-time entries need the same header capture/replay.

Coverage required (both apps, dev + production)

  • e2e: route whose handler sets Cache-Control + a custom header → assert identical on MISS and HIT; middleware that sets a header + a cookie → assert both on every HIT; an action POST → GET(HIT) flow asserting session cookie continuity end-to-end (the storefront basket shape).
  • Unit: entry serialization of captured headers; exclusion list.
  • Exec-matrix style guarantee: header sources (middleware=live, handler=replayed) asserted layer-by-layer.

Refs: #695/#697/#701/#706 lineage; field evidence in the 2026-07-06 session (storefront worker.ts session bug investigation — rango exonerated, gap discovered).

Activity

  1. changed the title [-]Shell HIT response-header fidelity: handler-set headers are dropped on HITs — define the contract, replay from the entry, pin with tests[/-] [+]Cached-scope header lock: ppr = document-level cache() — ONLY middleware writes headers in cached scenarios; handlers/loaders disallowed loudly[/+] on Jul 6, 2026
  2. ivogt commented on Jul 6, 2026

    @ivogt
    CollaboratorAuthor

    Design pivot (2026-07-07, supersedes the original issue body)

    The original proposal (capture handler headers into the entry, replay on HITs, exclusion list) was implemented, evaluated, and rejected in design review in favor of a strictly simpler doctrine:

    ppr is a top-level document-scoped cache() — and in any cached scenario, ONLY MIDDLEWARE writes headers. That is the entire rule.

    • Handlers under ppr/route-scoped cache() inherit the existing cached-scope execution contract on every render: ctx.headers throws (as it already does inside cache() today), same as cache: false context vars (live session etc.). Deterministic — fails the first dev render, exactly like cache(); no MISS/HIT divergence can exist.
    • Loaders are also disallowed: they are live but POST-COMMIT — shell-first streaming locks response headers at TTFB before any loader settles, so loader header writes are dead letters on HITs by physics.
    • Middleware headers + cookies live on every request including HITs — pinned by contract tests (including an action POST → GET(HIT) session-cookie continuity test, the storefront basket shape).
    • Plain uncached routes keep ctx.headers unrestricted — the guard is a property of declaring the handler cached material.
    • No replay machinery, no entry fields, no exclusion taxonomy. ETag story lives in INVESTIGATION: Waku-style etag segment deltas for navigation payloads (client advertises held segments, server omits before serializing) #709 (segment deltas), not here.

    Rationale for rejecting replay: the original header drop was an intentional guard steering authors to the live lane (same family as cache:false context vars throwing in cached scopes); replaying handler headers would legitimize writing per-request-adjacent material in baked scopes and required a fragile per-header taxonomy. Field evidence (a storefront session bug caused by exactly a MISS/HIT cookie-shaped divergence) favors deterministic loud failure over any silent or best-effort semantics.

    Implementation bar

    1. Unify the two existing guards (ppr capture ctx + cache() scope) so ppr-handler behavior is test-pinned equal to cache()-handler behavior.
    2. Loader header writes: disallowed loudly (dev), stripped (prod).
    3. Middleware-live contract tests in BOTH apps, dev + production, incl. cookie continuity through action → HIT.
    4. One-sentence doctrine in ppr-shell-resume.md.
    5. Consumer migration note: handler ctx.headers.set on cached routes moves to route middleware (e.g. the storefront's PDP Cache-Control / x-page-type lines).

    The prior replay implementation is discarded (never merged); this issue now tracks the guard.

  3. ivogt commented on Jul 7, 2026

    @ivogt
    CollaboratorAuthor

    Final spec (2026-07-07, post six-framework survey — supersedes prior comments)

    Survey verdict (full memo in session record): the ecosystem splits by SURFACE. Response-object endpoints (Next route handlers, Nitro, vinext handlers) replay headers — with set-cookie as an open wound (Nitro session-fixation issue #3468; Next silently caches Set-Cookie). Document/page surfaces — our surface — are doctrine-B UNANIMOUSLY: no framework replays page-code headers from a document cache entry (Next app pages: no mutable API, cookies().set() throws; SvelteKit/Astro: silent drop, self-acknowledged warts; RR7: build error when no live lane). Derived Cache-Control (Next revalidate→CC) does NOT transfer to us: ppr ttl/swr governs the WORKER-INTERNAL shell entry, never HTTP cacheability — ppr responses are dynamic by design (live holes, live middleware, per-request payload rows; worker-handles-every-request hard rule). The only "special" header in the ecosystem is Vercel's postponed-state transport for their CDN-split serving model — rango has no split, so no analog exists.

    Build spec — the pure guard:

    1. Handler AND loader ctx.headers writes THROW in cached scopes (ppr routes + route-scoped cache()) on EVERY render — unify with the existing cache()-scope guard so ppr-handler behavior is test-pinned equal to cache()-handler behavior. Loud in dev; same throw in prod (deterministic, first-render).
    2. Middleware is THE header lane: live on every request including HITs — pinned by contract tests in both apps, both modes, including header + Set-Cookie presence on HITs and an action POST → GET(HIT) session-cookie continuity test.
    3. NO replay machinery, NO derived headers, NO exclusion taxonomy. Serve-owned headers (x-rango-shell, content-type) unchanged.
    4. Docs: the one-sentence doctrine ("ppr = document-scoped cache(); in cached scenarios only middleware writes headers") + survey summary in ppr-shell-resume.md.
    5. Consumer migration note: handler header writes on cached routes are a build/runtime error after this — move to route middleware (or delete; e.g. an s-maxage on a holed document was already edge-freezing per-request material on MISSes).

    Someday-option (not this PR): RR7-style declarative per-route headers() use-item on the live lane, if a request-dependent need materializes.

  4. added a commit that references this issue on Jul 7, 2026
    e2448eb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions