Skip to content

Split origin shareability from template eligibility, and add the readthrough evidence gate - #1169

Draft
prk-Jr wants to merge 41 commits into
mainfrom
852-template-and-origin-caching
Draft

prk-Jr wants to merge 41 commits into
mainfrom
852-template-and-origin-caching

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Draft — incomplete. This is the first two thirds of issue Origin template caching, then transformed-HTML caching #852. The origin readthrough gate itself (the part that actually removes with_cache_bypass) is not in this branch yet, so merging this changes no request-path behavior on its own.
  • Splits origin shareability out of template-cache eligibility, so the two predicates can be reasoned about (and later gated on) independently, and records the shareability predicate on auction telemetry.
  • Adds ts origin probe-shareability, the operator-facing evidence gate: five comparison axes and four response-header verdicts, all blocking. Nothing may enable readthrough without it passing first.
  • Adds the purge plumbing readthrough will need: template cache entries are now keyed on the reader-facing URL as well as the origin-rewritten one, and the cache trait gained a surrogate-key purge.

Why the issue is still open post-ESI

#1009 built the sophisticated caching layer (the template cache). #852 is the crude one, and that is where the measured latency actually sits: with_cache_bypass() is ~485ms of a 773ms TTFB. The template cache only exists under assembly_mode = "esi", which is off by default — so most deployments today pay the full bypass cost on every ad page and get nothing back from #1009. The two are multiplicative, not redundant.

Changes

File Change
crates/trusted-server-core/src/publisher.rs Extracts SharedRequestInputs, origin_response_is_shareable, request_can_use_shared_template as pure predicates; captures request_path before origin rewriting; test harness with both a template cache and a telemetry sink
crates/trusted-server-core/src/auction/telemetry.rs origin_cache_shareable on the observation context and event row
crates/trusted-server-core/src/platform/template_cache.rs request_path on the cache key (schema v4 → v5), reader_url_surrogate_key with URL canonicalization, purge_url_surrogate_key on the trait
crates/trusted-server-adapter-fastly/src/{app,template_cache,tinybird}.rs Purge implementations, new telemetry column
crates/trusted-server-cli/src/commands/origin/ ts origin probe-shareability — probe, result model, two renderings
crates/trusted-server-cli/tests/ Portable loop-accept fixture origin; 15 probe tests
crates/trusted-server-cli/Cargo.toml reqwest with rustls-tls-webpki-roots-no-provider, so the CLI uses the workspace's existing aws-lc-rs provider instead of compiling a second one
tinybird/ origin_cache_shareable column, fixture rows, README covering deploy ordering
docs/superpowers/ Design doc and three implementation plans

Refs

Refs #852

Deliberately not Closes — see "Still to do" below.

Still to do on this branch

  • Admin purge endpoint POST /_ts/admin/cache/purge, with its guard list: registered for all methods with an in-handler 405, because non-primary methods fall through to the publisher and enforce_basic_auth leaves the Authorization header in place — a GET would otherwise ship the admin credential upstream.
  • ts cache purge CLI command and the harness purge leg.
  • The readthrough gate itselfPlatformCacheIntent, the two call sites, the ts-origin surrogate key, and the runbook.
  • Staging verification of the ts-origin key, which needs a real Fastly service; Viceroy cannot model it.

Test plan

  • cargo test-fastly (2,675 core tests) and cargo test-axum
  • cargo clippy-fastly and cargo clippy-axum
  • cargo fmt --all -- --check
  • Host-target CLI tests: ./scripts/test-cli.sh
  • Manual testing via fastly compute serve — not meaningful until the gate lands
  • Staging check of the ts-origin surrogate key — blocked on a real Fastly service

Notes for review

Two things worth a reviewer's attention beyond the diff:

  • with_cache_ttl was considered and rejected. Calling CacheOverride::set_ttl or set_surrogate_key reverses a prior set_pass(true) and overrides an origin's private / no-store. Readthrough must be enabled by omitting set_pass, never by layering a TTL over it. This is why every probe verdict is blocking: no post-response hook is reachable on Fastly (Viceroy 0.17 stubs the HTTP Cache ABI), so the probe is the only control on that path.
  • Nothing in CI checks the Tinybird schema / struct / fixture three-way agreement. Adding a column means editing three files by hand with no gate. Flagged here; deserves its own issue rather than a fix smuggled into this branch.

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code
  • New code has tests
  • No secrets or credentials committed

Records what remains of #852 after #1009 shipped the transformed-template
cache, and specifies the five remaining pieces: making origin readthrough
deliberate, an origin shareability probe, operator and CMS purge surfaces,
cache observability, and the documentation to match.

Two findings from review are recorded as corrections rather than folded
away. The readthrough cache is already shared for every non-ad-stack
request, so the change makes an existing sharing decision deliberate
rather than opening a new one. And stripping TS-owned cookies would not
raise the hit rate, because the gate keys on Cookie header presence
rather than cookie identity.

The readthrough change is left with an explicit ship/no-ship decision.
Its safety rests on probe-verified operator preconditions rather than
enforced checks, because the decision is made before the origin responds
and no post-response hook is reachable on this adapter.
First of five PRs for issue #852. Ships no behavior change: it extracts the
two eligibility predicates as pure functions, splits origin shareability out
of template eligibility, and reports both caches' outcomes on the existing
auction telemetry row.

Plan review found that the bypass reason the spec treats as the primary
triage signal cannot be produced where the spec said. template_cache_ttl runs
only for requests that already earned a cache key, so its InlineMode,
AuthorizedRequest and CookieForwarded variants are unreachable there, and the
request-side bypass carries no structured reason at all. The spec is updated
to record this and the plan budgets the missing derivation as its own task.
Two independent reviews checked the plan against the repository and against
the spec. Five findings would have left the implementer writing code that
does not compile or chasing pre-existing problems.

StubHttpClient has no Default and run() takes &Arc<Settings>, so every test
body in the plan was wrong. RecordingTelemetrySink has no accessor and three
copies, one already reachable from the target module, so the proposed move
was unnecessary. The fixture is missing user_agent today, so the plan's own
verifier script failed before any change. Test filters named a module path
that does not match, which reports zero tests as success rather than failure.

An earlier correction of mine was itself wrong: the take sites at :4957 and
:4996 run after the state write, not before, so naming them as known-None
paths would have made the comment false.

The spec is corrected where the plan disproved it: the carrier needs no stash
variable, the Hit state hook is reachable only at :4617, and the fields are
wired in base() rather than the summary row alone.

Adds the approval gate the spec requires before this PR, its trim fallback,
and a task for the two dashboard caveats.
Tasks 2 and 3 both use it and neither module has one; they must build the
context identically or the two tasks' assertions diverge.
The work was specced as five sequential pull requests. It ships as one
change set instead, so the sequencing section becomes commit order on one
branch and the plan becomes three parts of one plan.

Records what the single pull request costs: the readthrough gate is the only
change with new blast radius, and it now reverts together with the telemetry
that would say whether to revert it. Mitigated by the gate being inert until
an operator opts in, and by assembly_mode remaining a runtime kill switch, so
the practical rollback is a configuration change rather than a revert.

Also records the decision that the readthrough gate ships, which an earlier
revision left open, and asks that it be reviewed as its own commit against
the precondition list rather than buried in the wider diff.
Completes the plan for issue #852 as a single pull request. Part 2 builds the
shareability probe and the purge surface; part 3 changes the bypass condition.

Part 3 records three things as settled so they are not re-litigated during
implementation: no TTL override, because set_ttl reverses set_pass and
overrides the origin's own private and no-store; after_send is unreachable
because Viceroy stubs the HTTP Cache ABI; and set_pass and set_surrogate_key
are mutually exclusive and order-dependent, so the platform layer models
cache intent as one enum rather than two flags.

The probe's verdicts are blocking rather than advisory. The gate is decided
before the origin responds and no response-side hook is reachable, so none of
the template cache's refusals apply to that path and the probe is the only
control.
Adds three absent-by-default fields to the observation context and to the
event row, wired in AuctionEventRow::base so provider and bid rows carry them
too rather than the summary alone.

Absent is deliberately distinct from false. A row from a source that does not
make the readthrough decision reports None, and a dashboard that reads that
as a cache miss will be wrong for every /auction row.

Nothing writes the fields yet; the publisher path wiring follows.
The row serializer has no skip_serializing_if, so the three new fields are
always on the wire including as null. Undeclared columns are quarantined
rather than rejected loudly, so this must reach Tinybird before the emitting
code deploys.

Also adds user_agent to the fixture rows. It was declared in the datasource
and missing from every row beforehand, so the fixture did not match the
schema it is meant to exercise.
…ry sink

Neither existing builder wires both, so cache-outcome telemetry had no way to
be asserted end to end. Includes a self-test: a summary row is only emitted
when an auction runs, and without one every assertion built on this harness
would pass vacuously.
The single predicate mixed two questions: whether the origin response may be
shared at all, and whether this pipeline can assemble a shared template.
Gating anything but the template cache on the combined form would couple
origin readthrough caching to the assembly mode for no safety reason, and
would make assembly_mode = "inline" silently change caching behaviour.

Extracted as pure functions so the invariant is testable against real code
rather than a re-typed copy of the expression. One test asserts template
eligibility still implies shareability across all 128 input combinations;
another asserts each shared condition is individually necessary, which is
what catches a dropped term.

Behaviour is unchanged: nothing consumes the new binding except telemetry.
The gate that will consume it is a later commit.
The bypass reason has two sources and only one existed. template_cache_ttl
runs inside template_cache_reservation.and_then, and a reservation exists
only when a key was built, so its InlineMode, AuthorizedRequest and
CookieForwarded variants are structurally unreachable there. The request-side
bypass set a response state and free-text logs and nothing else.

That put the single most useful triage value on the unreachable side:
cookie-disqualified is the expected default in production, because Trusted
Server sets its own identity cookie. Adds request_side_bypass_reason to
derive it, reusing the existing variants and matching the response-side
ordering so one request cannot be described two ways.

Adds one variant, NotShareableRequest, covering the four remaining conditions
that each already have their own log line and none of which is a
cross-serving vector on its own.
The store outcome cannot reach the summary row. On a cold fill the ordering
is fixed: stream_publisher_body_async collects the auction, takes the
observation and emits the telemetry batch, and only afterwards does
store_template_if_authorized run and the state get stamped. The store cannot
move earlier because it needs the transform, and the emit cannot move later
without giving up collecting during body streaming.

So miss-stored and miss-store-error are unreachable while hit is reachable,
and a column that records hits but not misses makes hit rate compute as
roughly 100 percent. A silently wrong metric is worse than an absent one.

template_cache_bypass_reason and origin_cache_shareable carry the triage, and
the x-ts-template-cache response header still reports all nine states per
response for debugging a single request.
The list omitted the template-cache shell harness, the CLI and
openrtb-codegen clippy invocations, the parity crate's fmt and clippy, the
bench smoke, the release WASM builds, the JS and docs lint steps, and the
entire integration-tests workflow.

Points at .github/workflows as authoritative rather than restating it, so the
next omission is a stale subset rather than a wrong instruction.
…ndings

Adds a Tinybird README covering the deploy ordering and the two ways a query
over the cache columns goes wrong: the denominator is ad-serving pageviews
rather than all requests, and NULL means not measured rather than false,
because the /auction source populates neither column.

Records in the spec and plan that template_cache_state was attempted and is
unreachable, so nobody tries again without reading why.

Adds an RSC axis to the probe. RSC fetches are not navigations, so they never
set the bypass and already flow through the readthrough cache while HTML
navigations are PASS. Removing the bypass puts both representations under one
cache key for the first time, and an origin that varies on rsc or
next-router-* without declaring it can serve a flight payload to an HTML
navigation. The probe as specced would not have caught it.
Three things had drifted in that the issue does not ask for.

The template cache bypass reason diagnoses the template cache's refusals,
which is #1009's feature. Origin readthrough has no refusal reasons Trusted
Server controls, so the column said nothing about the change this issue
makes. Moved to successor issue B, which promotes that cache out of spike
status and should instrument it there. Removes the column, its derivation and
the enum variant added for it.

The CI gate list correction is a genuine docs fix but unrelated to this
change; it should land as its own small pull request.

The Tinybird README keeps its deploy-ordering section, which is a live hazard
this branch's schema migration creates, and drops the guidance for the column
that is no longer here.

Kept: origin_cache_shareable, which measures exactly what this issue changes.
The predicate split stays either way; it is a prerequisite for the gate.
Two independent reviews. The Rust review approved the diff and confirmed the
predicate extraction is term-for-term equivalent to what it replaced. The
verification review confirmed behaviour-neutrality, the Tinybird three-way
schema agreement, and the ordering argument for why a template-cache
hit/miss column is impossible. Both found documentation problems.

The column description overstated what ships. It said the field reports
whether the readthrough gate admitted a request, but no gate exists yet and
every ad-serving request still forces an origin fetch. A dashboard author
reading it would have concluded readthrough was live. It now says the field
records a predicate rather than an outcome, in both the Rust doc comment and
the Tinybird README.

Reverts a gratuitous hunk in the buffered finalizer. It was shape left over
from the telemetry field that was later removed, and behaviour-identical, so
it no longer appears in the diff at all.

Corrects spec and plan text that still described three telemetry fields, a
request-side bypass-reason derivation, a 36-column schema and an AGENTS.md
edit, none of which are in this branch any more.
The existing hyper/rustls stack is scoped to macOS, because ts dev proxy
needs a native TLS stack that the repo-default wasm32-wasip1 target cannot
build. The shareability probe has to run on Linux CI too, so it needs a
client in the non-wasm block.

reqwest is already a workspace dependency with rustls-tls and is already
built natively by the Axum adapter and the integration-tests crate, so this
links no new TLS backend.
The existing tests/support module is tokio + tokio-rustls + the dev proxy,
all macOS-scoped, and the probe has to be testable on Linux CI too. This one
is plain std::net and std::thread.

It loop-accepts deliberately. A single-accept fixture caused a CI flake here
before, fixed in PR #823: clients open more sockets than they send requests
on, and the probe opens one connection per arm and per --repeat, so a
one-shot server would hang the second fetch rather than fail it.

Self-tests cover the three things later tasks depend on: repeated requests
are answered, the fixture can vary its answer per request so the
self-identity axis has something to detect, and it sees request headers and
cookies so the cookie and user-agent axes can be driven.
Compares an origin's responses across five axes — self-identity, cookie,
Accept-Encoding, User-Agent, and RSC router headers — and checks four
response-header verdicts: positive shared freshness, no Set-Cookie, no CSP
nonce, and Vary coverage of any axis that varied.

Every axis and verdict is blocking, and a failure exits non-zero so the
command can gate a deploy. That is not caution for its own sake: the origin
readthrough gate is decided before the origin responds and no post-response
hook is reachable on the Fastly adapter, so none of the template cache's
response-side refusals apply to that path. This probe is the only control.

Self-identity runs first and is reported separately, because an origin that
is unstable against itself would otherwise surface as a failure on whichever
axis happened to run next and send the operator after the wrong thing.

The RSC axis is the one specific to removing the bypass: RSC fetches are not
navigations, so they never set it and already flow through the readthrough
cache while HTML navigations are passed. Removing the bypass puts both
representations under one cache key for the first time.

Output states on every run what the probe cannot see: it runs from one client
address, so IP-keyed personalization is undetectable, and a verdict covers
the URLs sampled rather than the origin.

reqwest is declared directly rather than inherited so its rustls crypto
provider can be pinned. Its plain rustls-tls forces ring, while this crate
already links aws-lc-rs through reqwest 0.13; compiling both made rustls's
process default ambiguous and panicked the dev-proxy tests.
A purge caller knows the page address; the cache key holds the
origin-rewritten target URI. Reconstructing one from the other means
reimplementing the publisher path's rewrite in every caller, and when that
drifts it does not fail — it produces a well-formed key that matches nothing,
so the purge returns success and invalidates nothing. That is the worst
failure mode on an incident path.

Adds request_path to the key, populated before rewrite_origin_request
replaces the URI, and a reader-facing surrogate key derived from it. Callers
hash the string the operator typed; no origin logic, no reimplementation.

The derivation is a free function because neither purge caller can build a
whole TemplateCacheKey: they have a URL, not an origin identity, a template
fingerprint, or the origin's Vary values.

Canonicalizes scheme and host case, default ports, a trailing slash and an
empty query, because the digest is over exact bytes and a spelling mismatch
is a silent no-op. The query itself is preserved: a different query is a
different page. An unparseable URL hashes as given, so an operator typo
purges nothing rather than failing the command.

Distinct ts-template-readerurl- prefix so the two derivations cannot alias
when a staging edge host happens to equal the configured origin host.

Schema version 5: the key gained a field, so v4 entries hash differently and
must not be read.
purge_url takes a whole TemplateCacheKey, which a purge caller cannot build:
an operator or a CMS webhook has a URL, not an origin identity, a template
fingerprint, or the origin's Vary values. The new method takes an
already-derived key, to be paired with reader_url_surrogate_key.

Implemented across all five implementors. The null object used by every
adapter without a template cache reports Unsupported rather than succeeding:
a purge surface that silently does nothing is worse than one that refuses,
because an operator mid-incident would read the success and stop looking.
The cache key is built from the path exactly as the reader sent it, so
`?a=1&b=2` and `?b=2&a=1` can be cached as two entries. Their purge
handles were derived the same way, so purging the ordering an operator
happened to type left the other entry serving stale content while the
command reported success.

Sort the raw query pairs before hashing the purge handle. The cache key
is deliberately untouched: collapsing the orderings there would risk
serving one reader's entry to another, and separate entries sharing one
purge handle is the outcome we want.

Sorting operates on raw pairs rather than decoded ones so percent-encoded
values stay byte-exact, and drops empty pairs, which over-purges by one
spelling — the safe direction.
`RequestBuilder::header` appends rather than replaces, so layering an
arm's override on top of the default sent `User-Agent` and
`Accept-Encoding` twice. An origin that reads the first instance never
saw the override, so the user-agent and accept-encoding arms fetched the
same document as the baseline and passed an origin nobody had varied.
Resolve the headers into one map before building the request.

The freshness verdict read only the first `Cache-Control` and
`Surrogate-Control` instance. A proxy that appends `private` after the
origin's `public, max-age=300` would pass. Judge every instance, as the
set-cookie and csp-nonce verdicts already did, and drop the
first-instance accessor so nothing reaches for it again.

The fixture origin collected headers with `insert`, keeping the last
value, which is why the existing user-agent axis test passed against the
append bug. It now records every instance and resolves reads to the
first, modelling the origin class the bug defeats — that test fails
without this fix, as it always should have.
Every axis compares two responses. A cache between the probe and the
origin can serve both from one stored object, so all five axes read
identical and the report goes green on an origin that personalizes
freely on a miss. It is the one failure that invalidates a whole run at
once, and nothing detected or disclosed it.

Add a blocking `fronting-cache` verdict, judged before the others, on a
positive `Age` or a vendor hit header. `Age: 0` passes, since that is
what a conforming cache sends on a miss and failing it would make the
probe unusable against any origin that reports age.

Detected rather than defeated: busting the cache needs either a query
parameter, which changes the cache key and the page identity, or a
no-cache request header, which can change the origin's own caching and
with it the freshness verdict. Perturbing the measurement to rescue it
would make a green result mean less.

Also state three limits a green report cannot reveal on its own: the
cookies are synthetic, only the signals with axes are varied, and a few
back-to-back requests cannot see variation on a slower cycle.
Review found the docs and the code had drifted apart in five ways.

All 113 checkboxes were unchecked, including for finished work, so
nothing distinguished "done" from "not started". Part 1 and part 2's
Sections A and B are now checked; part 2's C and D and all of part 3
stay open, which is accurate.

Part 1 was written for three telemetry fields and one shipped. Its task
bodies still build all three, and its Task 0 trim instruction names the
opposite field from the one that was actually cut. Rather than rewrite
the code blocks of a completed plan — churn that risks new inaccuracies
for work nobody will re-execute — the divergence is stated once at the
top, with the reason each field was dropped. The task bodies stay as the
record of what was planned; the code is the record of what was built.

Part 3 told the operator to watch `template_cache_bypass_reason`, which
does not exist. That line is destined for the runbook, so it is fixed
rather than annotated.

The spec's probe section was one axis behind the code and is now two,
since review added the `fronting-cache` verdict. Both the RSC axis and
that verdict are now in the spec's tables, and every "four axes" and
"four verdicts" reads five.

The spec's own Open risks section still framed observability as an
unresolved approval question with the outcome tacked on parenthetically,
which is what made the shipped column read as surviving scope creep. It
now states that the trim was taken, and argues why the one remaining
field belongs to #852.

Fixture row 0 was an `auction_api` row carrying a cache decision, which
the README says is structurally NULL for that source, while rows 1-3
shared its auction_id and disagreed — no real emission can look like
that, since base() stamps one observation onto every row. The /auction
rows are now NULL and the navigation rows carry both a 1 and a 0.
The five shareability inputs each had a necessity test against hardcoded
expectations. The two conditions that make template caching stricter
than plain shareability had none of their own — they were covered only
by the 128-combination test, which compares the predicate against a
restatement of its own body. That comparison does catch either term
being dropped, since the reference formula is written out independently,
but it says nothing about a bug inside origin_response_is_shareable, and
it reports a combination rather than a condition when it fails.

Assert both directly, plus that template eligibility cannot outlive
origin shareability.
POST /_ts/admin/cache/purge with {"scope":"all"} or
{"scope":"url","url":"..."}. The URL scope hashes the reader-facing
surrogate key, so callers pass the URL a reader would see and never
replay this service's origin rewriting.

The route claims every method rather than POST alone. A method a named
route does not claim falls through to the publisher, and
enforce_basic_auth leaves the Authorization header attached, so a GET
here would authenticate and then ship the shared admin credential to the
origin. The handler answers non-POST with 405 itself. The test asserts
this against publisher_fallback_methods() rather than a copy of the
list, so a method added there cannot quietly open the hole again.

Content-Type must be exactly application/json. Browsers attach
basic-auth credentials automatically and a cross-origin form post with
enctype="text/plain" is not preflighted, so requiring POST alone does
not stop CSRF; requiring a type no form can produce does.

The body is parsed as a flat struct rather than an internally-tagged
enum. deny_unknown_fields does not reach the unit variant of such an
enum, so {"scope":"all","url":"..."} parsed as a full flush — an
operator who mistyped the scope while meaning to purge one page would
have emptied the cache and been told it worked. That combination is now
an error naming the confusion.

A full flush is an unbounded origin-stampede lever behind one shared
static credential and there is no rate-limit primitive on this path, so
the authenticated username is logged every time. The username only; the
password is a shared secret and must never reach a log line.

Purge is idempotent and purge_all is a single surrogate-key call, so
there is no partial state: the response says so, because an operator
mid-incident needs to know whether retrying is safe.

Adding the path to ADMIN_ENDPOINTS is a breaking config-validation
change: an operator whose handler regexes enumerate admin paths will
fail validation until the new path is covered. The narrow-regex test
that had to be updated here is that migration in miniature.
An unregistered path falls through to the publisher origin and 404s,
which reads to a CMS purge webhook as "this endpoint does not exist"
rather than "not supported on this platform". Register it on Axum,
Cloudflare and Spin with an explicit 501 and a message naming the
adapter that does support it.

Registered for every publisher-fallback method on all three, matching
the Fastly route and the legacy admin aliases: a method a route does not
claim falls through to the publisher with the caller's Authorization
header still attached.

Axum's named_routes() array goes 16 to 17 and Spin's
named_fallback_paths() likewise, so the count is compile-enforced rather
than left to a reader to notice.
Three tests rather than one, because the obvious single test passes for
the wrong reason. The suite sets basic auth on ^/_ts/admin, so an
unauthenticated probe gets 401 and never reaches a handler; a bare
"assert not 200" would be satisfied by that 401 whether or not the route
exists.

So: the authenticated probe asserts 501, the unauthenticated one asserts
401 to prove the first reached a handler through auth, and the third
walks every non-POST method to pin the credential-forwarding guard
cross-adapter — a method a route does not claim falls through to the
publisher with the Authorization header attached.

No new helpers and no new dependency were needed: the credential-
carrying axum/cf/spin_authorized_json helpers already existed, so the
integration crate's separate lockfile is untouched.
The plan had this call the Fastly purge API directly, and noted that if
the token scope could not be granted the commit should be dropped. The
recorded token is config- and secret-store write only, with no purge
permission, so as planned it was blocked.

Calling the admin endpoint instead removes the dependency: the purge
runs inside the service, where the platform SDK needs no API token at
all, so this works with credentials an operator already has. It also
leaves exactly one implementation — the surrogate key is derived
server-side, so the CLI cannot drift out of agreement with the cache it
is purging. The plan's own D1 test existed to catch that drift; this
shape makes it unrepresentable.

The password is read from the environment and is deliberately not a
flag: an argument is visible to every process on the host through `ps`
and lands in shell history.

Neither --all nor --page is an error rather than a default, so a bare
`ts cache purge` cannot flush production. 401, 404 and 501 each get
their own hint, because a purge is usually run mid-incident and the
three send the operator somewhere different.

Fixes a real defect found while testing: the purge path built a reqwest
client without installing the rustls crypto provider, so any HTTPS purge
would have panicked with "No provider set" on the operator's machine.
The provider install moves out of the probe into `tls.rs` so both
clients share it, with the rationale for the `-no-provider` feature kept
in one place.

The fixture origin gains method and body capture so the e2e test pins
the wire format against the endpoint's guards: POST, exactly
application/json, and credentials attached.
Viceroy implements purge_surrogate_key against the same in-process cache
it serves reads from, so store -> hit -> purge -> miss is testable end to
end with no Fastly service involved. That makes this the strongest check
available for the purge path, and the only one that exercises the real
cache rather than a double.

The new `purge` mode reuses the esi configuration exactly — a CONFIG_MODE
indirection so the generator and every esi assertion keep running
unchanged — then purges and asserts the entry is gone.

It also drives the endpoint's guards against the real router rather than
a unit double: unauthenticated is refused, a GET is answered locally with
405 instead of reaching the publisher with the admin credential, a
form-postable Content-Type is refused, and scope "all" carrying a url is
refused rather than silently flushing.

The assertion is `miss-stored`, not `miss`: the purged entry is gone and
the same request immediately refills it, which proves the purge removed
an entry without disabling the cache.

The admin password is read back out of the generated manifest rather than
repeated, because the config's `password = "handler_password"` is a
secret-store reference and the basic-auth value is the seeded secret — two
literals that would otherwise drift apart silently.

CI gains a matching step; the script's mode list and usage banner accept
`purge` alongside `inline` and `esi`.
On Fastly, setting a surrogate key or a TTL reverses a prior set_pass:
both carry "overrides any previous Request::set_pass call"
(fastly-0.12.1/src/http/request.rs:2462, :2381). A pair of booleans
could therefore express "bypass the cache, and tag it for purge", which
silently means "do not bypass" — the opposite of how it reads. The enum
makes that combination unspellable, and the builders replace rather than
accumulate so call order cannot decide the outcome either.

No behavior change: Bypass is what with_cache_bypass already did, and
Default is the absence of it. The Shared branch is added but nothing
constructs it yet; the gate that will is the next commit.

Shared attaches the surrogate key and deliberately does not set pass,
because readthrough is enabled by omitting set_pass, never by adding a
TTL — set_ttl would additionally override the origin's own private and
no-store, turning the hazard this work exists to close into an API.

A surrogate key that cannot be encoded as a header value falls back to
bypass rather than caching without it. Storing an object no purge can
reach would be worse than not storing it, since "purge the key" is the
whole rollback story for readthrough.

Cloudflare maps Shared to its default mode, having no surrogate-key
concept, rather than pretending to honor a key it cannot use.
Stop forcing an origin MISS on every ad-serving pageview. The bypass now
keys on origin_response_is_shareable, which asks whether the origin's
response may be held in a shared cache. should_run_ad_stack asked
something unrelated: whether this request runs an auction says nothing
about whether the response behind it can be shared.

This change cuts both ways and is not "strictly more conservative":

- Loosening, for cookieless ad-serving navigations, which stop forcing a
  MISS. This is the ~485ms of a 773ms TTFB the issue exists to recover.
- Tightening, for cookie-bearing, Authorization-bearing, conditional,
  range and non-GET requests, including bot traffic that previously did
  not bypass and now does. Four existing tests changed in this direction
  and their messages now say why.

Nothing about this is enabled by the code alone: an origin that marks
HTML private still stores nothing, so the win only materializes where an
operator has verified shareability with ts origin probe-shareability.

Both origin-fetch paths call one decision function rather than repeating
the condition. They are alternatives for the same fetch — one inside the
EC-preload fan-out, one in the branch taken when that did not fire — and
reverting only the first passed all 2,697 tests, because a behavioral
test can reach that path only with a valid signed EC id. A duplicated
condition would have made readthrough eligibility depend on whether EC
preload fired, which is not a property of the origin response at all.
One function makes that divergence unrepresentable rather than merely
tested for.
Two independent reviews of the previous commit reached the same critical
finding, and they were right: that commit's loosening had no operator
control at all.

origin_is_cookie_independent reads like the switch but is not one here.
cookie_disqualifies is `request_had_cookie && !origin_is_cookie_independent`,
so for a request carrying no Cookie header the flag never participates:
every cookieless GET navigation was judged shareable and stopped forcing
an origin MISS the moment the binary shipped, on every deployment, with
no operator action. Rollback would have been a code revert and redeploy.

That is also the worst population to enable silently. The design doc
names it: `origin_response_is_shareable` is true precisely for readers
carrying no cookie — first-time visitors, which is exactly when an origin
issues a session cookie. There is no response-side guard on this path;
the decision is made before the origin replies and no post-response hook
is reachable on the Fastly adapter. Safety rests on the origin's own
Cache-Control plus an operator's verification, so enabling it has to be
a deliberate act rather than a consequence of deploying.

Add creative_opportunities.origin_readthrough_enabled, default false,
ANDed into the gate as a separate term. The shareability predicate is
still evaluated and still recorded on telemetry, so an operator can see
how much traffic the gate would admit before turning it on. Rollback is
now a config flip plus a purge.

Also closes two review findings:

The EC-preload fetch path now has behavioral cover. Reverting only that
call site previously passed all 2,697 tests. Reaching it needs three
things the harness did not supply — a KV store, an EC id, and a client
reporting pending-streaming support — not just the EC id I had claimed;
with all three, reverting that site alone now fails.

A doc comment was misattached: the new function's block ran on from
request_can_use_shared_template's with no blank line between, so rustdoc
gave the template function's summary to the cache-intent function and
left that pub(crate) item undocumented.
Review finding: request_requires_origin folded two different questions
together, and the predicate was read before the headers it judges are
stripped.

A repeat visitor sends If-None-Match. When the ad stack runs, those
headers are stripped before the origin is asked anything, so the origin
answers an unconditional question with a full document — as shareable as
any other. The predicate was computed pre-strip, so that request was
marked unshareable and forced an origin MISS, losing readthrough for
exactly the repeat-visit population this work exists to speed up, in
exchange for nothing.

Split the two questions:

- reader_requires_origin, read pre-strip, gates the *template* cache. A
  reader who asked for a range or a revalidation must reach the origin;
  stripping changes what the origin is asked, not what the reader
  wanted. This is now its own term on request_can_use_shared_template
  rather than riding inside the shared inputs.
- request_requires_origin, read post-strip, feeds origin shareability.
  The strip removes four headers while this predicate tests six plus
  Cache-Control request directives, so If-Match, If-Unmodified-Since and
  a no-store reader still disqualify either way.

A first attempt moved the single value and broke
revalidation_partial_and_conditional_requests_bypass_a_warm_template: a
range request would have been answered from a warm shared template. That
test is the reason the two questions are now separate rather than
reordered.

The 128-combination sweep becomes 256 to cover the new term, and both
directions are pinned: reverting the split fails the readthrough test,
reverting the reader term fails the range and revalidation tests.
Both reviews flagged that the operator-facing docs said nothing about
this cache: no mention of the gate, the probe, or the new flag. An
operator had no config surface to reason about a change with cross-reader
blast radius.

The section says plainly that readthrough is a different and weaker cache
than the template cache, and gives the refusal-by-refusal comparison. The
three rows the template cache enforces on the response — Set-Cookie, CSP
nonce, missing positive freshness — are not covered here and cannot be,
because the decision is made before the origin replies and no
post-response hook is reachable on this adapter. Those rows are named as
accepted operator risk rather than left for a reader to derive, along
with the sharpest case: readthrough admits requests carrying no cookie,
which is exactly the first-time visitor an origin issues one to.

Rollback is documented as it actually is, not as we would like it. The
config flip is real and takes effect on the next request. Purge is not:
`ts cache purge` covers `ts-template` only, and whether readthrough
objects can be tagged at all is unverified against a real Fastly service,
so nothing claims to reach them. Already-stored objects age out on the
origin's TTL. Shipping a rollback step that does not affect the cache
being rolled back would be worse than admitting the gap.

That unverified staging check is also why the `ts-origin` surrogate key
task is not implemented: its own plan gates it on that verdict, and an
unverifiable purge is the failure mode where an operator believes stale
content is gone when it is not.

Also points `origin_is_cookie_independent` at the probe that now answers
its question, in both the doc comment and the example config, instead of
leaving "unsafe unless independently verified" with no way to verify.
The last of the five items this issue was rescoped to. The cache has
shipped behind an opt-in assembly mode, is covered by the local harness
and the rendered-document byte-identity tests, and now has an operator
purge surface — but every doc comment still described it as a #1009
validation spike to be removed, which is no longer what it is.

The substantive one was the `VarySpec` note, which called the configured
`Vary` list "a spike-grade choice, not a production one". Re-examined
rather than reworded: the drift it risks fails closed, because the
origin's actual `Vary` is compared before storage and an uncovered name
refuses the template, carrying the offending names so a stale config is
identifiable from one log line. A configuration that falls behind the
origin costs hit rate, not correctness. That is a defensible production
posture, and the note now says so along with what would justify
revisiting it — the extra lookup of a two-phase design, not a limitation
of the guard.

The module's three-cache table also still used the retired C1 label for
the origin readthrough cache, which this issue has since given its own
gate; it now names both and says they are independent.

The remaining markers were status rather than substance: the optional
config fields keep `Option` + `skip_serializing_if`, because the reason
for them is rollback compatibility with binaries that use
`deny_unknown_fields`, which is a production concern and not a spike one.

Documentation only. No behaviour change, and the rustdoc warning count is
unchanged at 30 against the branch point.
A "~485ms of a 773ms TTFB" figure had worked its way into a code comment
and a test assertion on this branch. It came from an earlier session's
measurement against a real deployment, not from anything verified here,
and it was being repeated as though it were established.

Replaced with what was actually measured, locally, this time:

Under Viceroy 0.17, with origin_readthrough_enabled set, the request
instrumented and confirmed shareable, and the stub origin answering
`Cache-Control: public, max-age=60` with no `Set-Cookie`, two identical
navigations still produced two origin fetches. Viceroy does not
implement the readthrough cache.

Three confounds were eliminated before drawing that conclusion: the
origin's response is cacheable, the gate admitted the request rather than
bypassing, and the flag was on.

The consequence is worth stating where people will look for it, so it is
recorded both at the test that could be mistaken for proof of a saving —
it asserts the recorded intent, not a cache hit — and in the operator
guide, which now says plainly that local timings say nothing about this
setting and the first evidence comes from a deployed service.
Run against a real protected origin, the probe produced a confident
verdict describing content the origin never served. Its baseline arm
sent no cookies, so a DataDome-style wall answered it with a challenge
page, and every axis and verdict then reported on that page: "private,
no-store, must-revalidate", a Set-Cookie, and no Vary. The origin's
actual response is `max-age=60` with a declared Vary and no Set-Cookie.

That is a false FAIL on a plausible candidate, and it reads exactly like
a real one. For a tool whose entire job is answering this question, and
whose failure direction was supposed to be the safe one, silently
describing the wrong document is worse than refusing to answer.

Two changes:

A non-200 baseline now aborts with the status and what to do about it,
rather than proceeding to judge. Nothing downstream of a challenge page
is evidence about the origin.

`--admission-cookie` is carried by every arm, including the baseline and
the self-identity repeats, because without it a protected origin answers
each arm with the same wall. It is separate from `--cookie`, which is
what the cookie axis varies: the admission cookie is what gets the probe
admitted at all, so including it in both arms keeps that axis measuring
personalization rather than admission.

Seeded inside `fetch` rather than at each call site, so an arm that sets
its own `cookie` header replaces it through the existing resolution
logic instead of sending the header twice. A first attempt threaded it
through the call sites and missed `self_identity_axis`, which the new
fixture test caught by comparing a challenge page against real content.
An axis that differs on a signal the origin declares in `Vary` is not a
hazard: that signal is part of the platform's cache key, so each value
gets its own stored object and both readers get the right one. The probe
failed those anyway, so an origin doing exactly the right thing was
marked not shareable.

Measured against a real origin declaring
`Vary: rsc, next-router-*, accept-encoding, arena-exp`: the
accept-encoding and rsc axes both failed, while the vary-coverage
verdict passed on the same run and said so. Two of four failures were
the probe contradicting itself.

Axes now carry `covered_by_vary` and pass when the origin declared the
signal, printing the reason rather than reporting a silent pass.
`vary-coverage` keeps judging the raw observation through a new
`differs()`, because it is the check that decides whether a difference
is declared — reading `passed()` there would have made it vacuous.

Self-identity is excluded: it varies no request signal, so no `Vary` can
key it, and a page unstable against itself cannot be shared however it
is keyed.
Two independent reviews of the whole branch. No critical or high
correctness defects were found in the code; the substantive findings
were places the branch asserted things it knew to be untrue.

A false PASS the probe could produce. An axis differing on a signal the
origin declares in Vary now passes, which is right for accept-encoding
and rsc but wrong for cookie: that axis answers "does the origin ignore
cookies", and `Vary: Cookie` is the origin saying it does not. Passing
it would have printed a green verdict whose own closing line reads "do
not enable origin_is_cookie_independent", and would have contradicted
the template cache, which refuses `Vary: Cookie` at runtime. Cookie is
now excluded from that excuse.

The Tinybird README still said no readthrough gate consumes the
predicate. True when written; the gate landed later on this same branch.

The config field's rustdoc promised rollback by "a purge of the
ts-origin surrogate key". No production code applies that key — the
operator guide says so at length and the rustdoc an engineer reads first
said the opposite.

`origin_readthrough_enabled` was missing from the list of keys to remove
before rolling back to an older binary. Those binaries use
deny_unknown_fields, and setting the flag to false to roll back
serializes it into the blob, so the rollback step itself would have made
every request fail.

Adding /_ts/admin/cache/purge to ADMIN_ENDPOINTS is a breaking
config-validation change for operators whose handlers enumerate admin
paths. Now stated as an upgrade note.

Also: the template-cache rollback pointed at "normal purge tooling"
rather than the command this branch added; the spike framing survived in
the two operator-facing places after being removed from the code; part
3's plan named the superseded flag; and the retired C1/C3 cache labels
survived in two files the earlier sweep missed.

Full CI gate list run, which the code review was explicit about not
having done: fmt, all eight clippy invocations, 2,917 + 41 + 44 + 86
adapter tests, 268 CLI, 16 parity, all three harness modes, 901 JS tests,
docs prettier.
@aram356 aram356 linked an issue Sep 17, 2026 that may be closed by this pull request
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.

Origin template caching, then transformed-HTML caching

1 participant