Skip to content

feat(cache): classify private and dynamic render downgrades#1247

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/726-cache-08-private-dynamic-downgrade
May 16, 2026
Merged

feat(cache): classify private and dynamic render downgrades#1247
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/726-cache-08-private-dynamic-downgrade

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 16, 2026

Summary

Implements #726-CACHE-08 from issue #726.

This adds explicit downgrade classification metadata to cache-proof render observations and cache variant dimension sources:

  • public request observations such as params and searchParams classify as publicVariant
  • private request observations such as cookies and headers classify as private
  • draft mode and uncacheable observations classify as privateUncacheable
  • connection(), dynamic fetches, incomplete observations, and unknown observations classify as freshRender
  • auth, session, draft, cookie, and header dimension sources classify without allowing them into public cache variants

Bonk: please read issue #726 before reviewing this PR. The architectural big picture matters here: this is a narrow #726-CACHE-08 classification slice, not cache-hit authority or static layout artifact reuse.

Why

The disabled cache-proof model already records request API usage, dynamic fetches, cacheability, and boundary outcomes, but future proof consumers would otherwise need to infer privacy and freshness rules from raw observations. This PR makes the downgrade decision explicit, typed, and attached to the observation that produced it.

The model schema is bumped because serialized render observations now carry a downgrade field.

What Changed

  • Added CacheProofDowngradeTarget, downgrade reasons, and CacheProofDowngradeClassification.
  • Added pure classifiers for render observations and cache variant dimension sources.
  • Attached normalized downgrade metadata in buildRenderObservation() after dynamic fetch URLs are redacted.
  • Bumped CACHE_PROOF_MODEL_SCHEMA_VERSION from 0 to 1 and derived cache key prefixes from the schema version constant.
  • Added focused tests for public, private, draft, auth/session, dynamic, incomplete, and schema-versioned cache proof behavior.

Non-Goals

  • Does not enable runtime cache-hit reuse.
  • Does not make isPublicCacheCandidate a cache authority.
  • Does not implement static layout artifact reuse.
  • Does not add a compatibility parser for old cache-proof observation payloads.

Validation

  • vp test run tests/cache-proof.test.ts
  • vp check packages/vinext/src/server/cache-proof.ts tests/cache-proof.test.ts
  • vp test run tests/cache-proof.test.ts tests/app-elements.test.ts tests/app-page-render.test.ts tests/app-page-cache.test.ts tests/isr-cache.test.ts
  • vp check
  • git diff --check
  • Commit hook also ran vp check and knip

Refs #726.

Render observations recorded dynamic request usage but did not carry an owned cacheability downgrade decision. That left future cache proof consumers to infer privacy from raw request API observations and cacheability strings.

Add pure downgrade classification for public variants, private request state, auth/session dimensions, uncacheable draft state, dynamic fetches, and incomplete observations. Bump the cache proof schema for the serialized observation shape and keep the disabled proof model as the only reuse decision.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1247

commit: 3e358d3

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 02:31
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped CACHE-08 classification slice. The downgrade hierarchy is sound, the type discriminants are exhaustive via assertNever, the fallback escalation logic is correct, and the existing test suite passes. Two notes below — one is a genuine double-normalization concern, the other is a minor test coverage observation.

Overall this is a good incremental step that makes the downgrade decision explicit without overreaching into cache-hit authority.

target = maxCacheProofDowngradeTarget(target, reason.target);
}

const requestApis = normalizeRequestApiObservations(input.requestApis);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double normalization of requestApis.

buildRenderObservation (line 1030) normalizes input.requestApis via normalizeRequestApiObservations and then passes the already-normalized result into classifyRenderObservationDowngrade (line 1047). But classifyRenderObservationDowngrade calls normalizeRequestApiObservations again here on line 993.

This is harmless today — normalization is idempotent (dedup-by-max-rank, then sort) — and the test on cache-proof.test.ts:399 proves the round-trip produces identical results. But it's still redundant work on the hot path. More importantly, it means the public API contract of classifyRenderObservationDowngrade accepts denormalized input, which could surprise a future caller who expects pre-normalized data to pass through unchanged without the extra allocation.

Two options:

  1. Remove the normalizeRequestApiObservations call here and document that ClassifyRenderObservationDowngradeInput.requestApis must be pre-normalized.
  2. Keep it (defense in depth) and add a one-line comment explaining the idempotency expectation.

Either is fine. Not blocking.

switch (target) {
case "public":
case "publicVariant":
case "private":
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design note: private target returns null fallback (no breaker), which means a render that only observes cookies/headers won't produce a CacheProofBreakerFallback. This is correct for the current model — private is still cacheable in a per-user cache, so no breaker is needed. Just calling it out since the jump from private → null to privateUncacheable → breaker is the critical semantic boundary and it's easy to miss in the fall-through.

Comment thread tests/cache-proof.test.ts
"CP_DOWNGRADE_PUBLIC_REQUEST_API",
"CP_DOWNGRADE_PUBLIC_REQUEST_API",
]);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice coverage for publicVariant, private, privateUncacheable, and freshRender targets. Two small gaps that could be filled in a follow-up:

  1. Base case "public" target — a render observation with cacheability: "public", completeness: "complete", no dynamic fetches, and no observed request APIs should produce target: "public", reasons: [], fallback: null, isPublicCacheCandidate: true. This documents the zero-downgrade baseline.

  2. status: "unknown" request API — exercises the CP_DOWNGRADE_UNKNOWN_REQUEST_API branch in classifyRenderObservationDowngrade. Currently uncovered.

Neither blocks this PR.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary of the review:

Approved with three inline comments:

  1. Double normalization of requestApis (cache-proof.ts:993) — classifyRenderObservationDowngrade calls normalizeRequestApiObservations internally, but buildRenderObservation already normalizes before passing data in. The normalization is idempotent so it's harmless, but it's redundant work and a potential API contract surprise. Suggested either removing the internal normalization or adding a comment about the idempotency expectation.

  2. Design note on privatenull fallback (cache-proof.ts:816) — Called out that the private target returning no breaker fallback (while privateUncacheable does) is the critical semantic boundary in the escalation ladder. Correct behavior, just worth highlighting.

  3. Two minor test coverage gaps (cache-proof.test.ts:409) — No test for the base case "public" target (zero downgrades) and no test for status: "unknown" request API observations (CP_DOWNGRADE_UNKNOWN_REQUEST_API branch). Neither blocks the PR.

github run

export const CACHE_PROOF_MODEL_SCHEMA_VERSION = 0;
export type CacheProofModelSchemaVersion = 0;
export const CACHE_PROOF_MODEL_SCHEMA_VERSION = 1;
export type CacheProofModelSchemaVersion = 1;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couldnt this type's value be inferred from the constant?

Comment on lines +212 to +217
export type CacheProofDowngradeTarget =
| "freshRender"
| "private"
| "privateUncacheable"
| "public"
| "publicVariant";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this type could just be a union of the all the target values in the below type

Comment on lines +877 to +878
default:
return assertNever(kind);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im a bit surprised we need these to be honest

@james-elicx james-elicx merged commit 4e55862 into cloudflare:main May 16, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants