Skip to content

Add ts CLI ad-template config diagnostics and browser audit - #823

Merged
aram356 merged 280 commits into
mainfrom
feature/ts-cli-ad-templates
Sep 21, 2026
Merged

aram356 merged 280 commits into
mainfrom
feature/ts-cli-ad-templates

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Jun 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Adds operator tooling to verify server-side ad-template ([creative_opportunities]) configuration: static path/slot diagnostics via ts config ad-templates …, and browser-backed live verification via ts audit … (local Chrome/Chromium over CDP).
  • Adds ts audit ad-templates generate <url> to bootstrap [creative_opportunities] from a live site. One run crawls the publisher's sections (sitemap via robots.txt, else navigation links), samples a landing page and an article per section, reconciles each slot across the pages it appeared on, and writes the result into an existing trusted-server.toml in place, preserving every other section and comment.
  • Where the evidence proves it, the run infers a {network_id}/{section} ad-unit template plus the section_root/section_segment policy it depends on, instead of pinning each slot to the one literal path it happened to be scraped from. A wrong template makes a publisher bid against inventory that does not exist, so inference refuses rather than guesses — see the table below.
  • Everything the run writes is validated before it replaces the file, on the --dry-run path too. The candidate is deserialized as TrustedServerAppConfig and checked through TrustedServerAppConfig::new, the push-time validation path. This checks slot/template compilation, provider configuration, and secret-store key references without I/O. Resolved secret values are checked separately at runtime. Parse/schema failures report safe location or context without source excerpts or invalid values. Deploy-validation failures report a generic phase-level error because their detailed messages can contain literal values. This applies to both candidate refusal and pre-existing-baseline warnings.
  • Lets publishers catch slot/config drift before deploy — match a path to configured slots, assert expected slots in CI, explain runtime ad-stack gating, and confirm configured slots actually render (DOM/GPT/APS evidence) on live pages.
  • Keeps the CLI host-only: browser deps (chromiumoxide) are excluded from the wasm32-wasip1 build, and the runtime ad-stack gate is shared with publisher.rs so the CLI cannot drift from server behavior.

Stacks on the EdgeZero CLI base (server-side-ad-templates-impl). After merging the base back in, the diff against it is feature-only.

Behavior change: bare ts audit <url> remains a hidden alias for ts audit generate <url>; the base branch's draft-artifact generation moved to the explicit ts audit generate <url>.

Deploy ordering — read before pushing generated config. A config carrying section_root or section_segment is not rollback-safe. CreativeOpportunitiesConfig is deny_unknown_fields, so a binary predating ad-unit templating (cee1e20e) rejects the entire Settings load — not just the ad-template section — and every route serves an error. Deploy the template-aware binary first, then push, and do not roll that binary back while the config is live. A run that did not template writes neither key and leaves the config exactly as rollback-safe as it was. The command prints this warning itself.

closes #701

Registry and slot matching

Generation stops its final GPT wait after two consecutive empty snapshots. A nonempty registry needs the configured dwell window; late bursts reset that window. Initial settling, optional post-scroll settling, and GPT polling share one settle budget, with GPT receiving at least --settle-quiet-ms plus two 250ms poll intervals even when preceding phases exhaust it. This gives an already-stable registry time to establish a repeat and finish its dwell before the deadline. Later changes or slow browser reads can still leave incomplete nonempty evidence, which retains its warning. GPT polling precedes metadata extraction, so DOM, script, and network snapshots reflect the post-GPT-wait page. An exhausted post-scroll budget reports a skipped wait rather than a timeout.

A concrete slot written from one page remains literal evidence when another page refuses its stem, keeping sibling slots separate and preventing unintended floor-price inheritance. Split warnings identify only the longest matching configured ancestor. Consonant-only placement abbreviations remain a documented limitation of the random-suffix heuristic.

Artifact generation also retains disabled JavaScript asset-proxy candidates and their summary counts alongside discovered ad-template slots.

Changes

Area Change
trusted-server-core/src/creative_opportunities.rs [creative_opportunities] config types, match_slots, shared evaluate_ad_stack_gate; compile_page_pattern as the single glob definition; derive_section made public so tooling checks inference against the runtime's own derivation rather than a second implementation
trusted-server-core/src/publisher.rs Route should_run_server_side_ad_stack through the shared gate (behavior-preserving)
trusted-server-cli/src/commands/config/ad_templates.rs ts config ad-templates {lint,match,check,explain} static diagnostics
trusted-server-cli/src/app_config.rs Shared effective app-config loader for the config + audit commands
trusted-server-cli/src/ad_templates/{expected,compare,output}.rs Pure expected-slot projection, DOM/GPT/APS evidence comparison, stable JSON model, terminal-safe text escaping
trusted-server-cli/src/commands/audit/{mod,page,collector,browser,ad_templates}.rs, commands/audit/ad_template_collector.js ts audit page + ts audit ad-templates verify: chromiumoxide collector, read-only GPT/APS/DOM init script, verifier orchestration, cross-origin refusal
trusted-server-cli/src/commands/audit/generate/crawl_plan.rs New. Turns links and sitemap entries into a bounded page set — one landing page and one article per section, ranked by nav/sitemap corroboration, capped by section and page budgets, same-origin enforced on both sources
trusted-server-cli/src/commands/audit/generate/evidence.rs New. Cross-page slot evidence: formats unioned, divergent unit paths retained, network ids required to agree, and detection of one placement fragmented across per-render div ids
trusted-server-cli/src/commands/audit/generate/unit_template.rs New. {network_id}/{section} inference with positional network binding, a single-varying-segment rule, the witness rule, and replay through the runtime's own renderer
trusted-server-cli/src/commands/audit/generate/page_patterns.rs New. Expands observed paths into per-section glob pairs (/news and /news/*) without extrapolating past a witnessed section
trusted-server-cli/src/commands/audit/generate/validate.rs New. Write-side gate: the candidate config must pass TrustedServerAppConfig::new source-config validation before it replaces the file; a pre-existing failure downgrades to a warning so an already-broken config can still be updated
trusted-server-cli/src/commands/audit/generate/{mod,gpt_slots}.rs Crawl orchestration, challenge-rate refusal, per-page diagnostics; slot reconstruction from live GPT plus normalization of ephemeral div-id noise (React _R_/_r_ ids, -container, hex UUIDs)
trusted-server-cli/src/commands/audit/generate/{browser_collector,collector,analyzer}.rs Batch collection on one reused browser, hydrated-DOM link extraction, in-page sitemap fetch, device profiles, request pacing, consent answering, proxy support; GPT slot reads wait for the registry to hold still for the settle-quiet window, within the remaining settle budget or minimum of the quiet window plus 500ms, reporting partial evidence when stabilization cannot finish
trusted-server-cli/src/run.rs, src/lib.rs Command wiring and parser tests for the audit namespace
trusted-server-cli/Cargo.toml Host-only edgezero-core + serde_json deps (cfg-gated off wasm, like the existing browser deps)
docs/guide/cli.md ts audit ad-templates generate documented: crawl behavior, refusal table, consent platforms, proxy auditing, the deploy-ordering contract, and generation’s shared settle budget with GPT stabilization before metadata extraction
docs/superpowers/{specs,plans}/2026-06-26-server-side-ad-template-cli* Design spec and implementation plan

Test plan

Per CLAUDE.md, a bare cargo test/cargo clippy --workspace fails at the workspace root — the repo has multiple wasm runtimes with runtime-specific SDKs, so the target-matched aliases are the real gate.

  • cargo fmt --all -- --check
  • cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm
  • cargo clippy -p trusted-server-cli --target <host-triple> --all-targets --all-features -- -D warnings
  • cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin
  • cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity (13 passed)
  • Host CLI tests: cargo test -p trusted-server-cli --target <host-triple>
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 (plus cargo build -p trusted-server-cli --target wasm32-wasip1 — browser deps stay out of wasm)
  • Chrome-gated browser fixtures (./scripts/test-cli.sh) for evidence collection, scroll-phase attribution, and GPT registry stability — including a fixture whose second registration burst lands on a real timer
  • Manual: full crawl against a live publisher through ts dev proxy — template and section policy inferred, per-render div-id fragments refused, generated config loads through Settings::from_toml

Generation command-path tests cover malformed TOML and creative-section values, with and without --dry-run, asserting safe errors and unchanged files. Browser-backed command tests also exercise deploy-validation refusal and baseline warnings in both write and dry-run modes; the CI helper runs these explicitly. The exhausted-budget Chrome fixture exposes its second batch on the second read; separate fixtures retain timer-driven registration coverage.

Notable fixture-based coverage, all offline: crawl planning (cross-origin rejection on links and sitemap entries, utility/asset filtering, query/fragment collapsing, budget truncation), evidence reconciliation (format union, network-id conflict, fragment detection with a co-occurrence false-positive guard), and one test per template-inference refusal case.

How to use

Static ts config ad-templates … commands only read local effective config (no browser). Browser-backed ts audit … commands launch a local Chrome/Chromium.

Configure slots

In your (gitignored) trusted-server.toml — fictional values shown:

[creative_opportunities]
gam_network_id = "123"
section_root = "homepage"     # required when a template uses {section}

[[creative_opportunities.slot]]
id = "atf"
gam_unit_path = "/{network_id}/news/{section}"   # or a literal path
div_id = "ad-atf-"            # treated as a prefix; matches e.g. ad-atf-0
page_patterns = ["/", "/news", "/news/*"]
formats = [{ width = 300, height = 250 }]

# optional provider hints
[creative_opportunities.slot.providers.aps]
slot_id = "atf"

Generate slots from a live site (needs local Chrome/Chromium)

# Crawl the site's sections and write [creative_opportunities] in place
ts audit ad-templates generate https://www.example.com/

ts audit ad-templates generate https://www.example.com/ --dry-run    # preview, no write
ts audit ad-templates generate https://www.example.com/ --replace    # overwrite instead of merge

# Bound the crawl
ts audit ad-templates generate https://www.example.com/ --max-sections 20 --max-pages 41
ts audit ad-templates generate https://www.example.com/ --max-pages 1   # single page, as before

# Set the patterns yourself; disables pattern inference
ts audit ad-templates generate https://www.example.com/ \
  --page-pattern '/' --page-pattern '/news' --page-pattern '/news/*'

# Check whether the publisher splits ad units by device
ts audit ad-templates generate https://www.example.com/ --profiles desktop,mobile

# Behind bot protection: carry a session, pace the crawl, or use a visible browser
ts audit ad-templates generate https://www.example.com/ \
  --cookie '<NAME>=<VALUE>' --page-delay-ms 1500
ts audit ad-templates generate https://www.example.com/ --headful

Re-running merges: a slot seen again keeps its hand-tuned fields and gains this run's patterns and newly observed formats, and a hand-written gam_unit_path template is preserved. --replace discards existing slots, including any template written by hand.

Consent platforms. Publishers gate slot definition behind their consent platform, and the audit runs in a throwaway profile with no consent cookie — so such a site would define no slots at all and look identical to a site with no ad stack. The crawl therefore answers the two IAB interfaces every compliant platform exposes (TCF v2 and US Privacy) as a consenting, out-of-scope reader, before any page script runs. This changes only what the audit browser sees. --no-assume-consent observes the un-consented page instead.

Auditing a production hostname served locally. ts dev proxy serves a production hostname from a local Trusted Server; auditing through it keeps the page's origin, cookie scope, and any origin checks in the ad stack matching production rather than localhost:

ts dev proxy --map www.example.com=127.0.0.1:7676 --upstream-plaintext --rewrite-host

ts audit ad-templates generate https://www.example.com/ \
  --browser-proxy 127.0.0.1:18080 --danger-accept-invalid-certs

Note that a local Trusted Server injects its own configured slots, so a run through the proxy can rediscover config it already has; slot ids absent from the current config are the publisher's own.

When generation keeps literal paths, and when it refuses

Situation Result
Only one page was crawled Literal path — one observation cannot distinguish a literal from a template
The ad unit never varied by section Literal path
A section's slug is not derivable from its URL Refused slot omitted; the failed round-trip is reported
No root page was seen, so section_root is unknown Refused slot omitted rather than guessing a fallback
Two path segments could both be the section No template; the ambiguity is reported
The unit varies by device, geo, or anything the URL cannot supply Refused slot omitted with the conflicting evidence reported
Several slots are one placement under per-render div ids Fragments reported and skipped, with the stable prefix they share
Crawled pages report different GAM network ids Run fails — the pages are not one property
More than a quarter of crawled pages return no slots Run fails — the signature of bot protection serving challenge pages

Static diagnostics (no browser)

# Summarize config + deploy-time implications
ts config ad-templates lint

# Which configured slots match a path (or full URL)
ts config ad-templates match /news/story --details

# CI assertion: exact expected slot set
ts config ad-templates check /news/story --expected-slot atf
ts config ad-templates check /weather --expect-no-slots
ts config ad-templates check /news/story --expected-slot atf --allow-extra-slots

# Explain the runtime ad-stack gates for a modeled request
ts config ad-templates explain /news/story --bot --consent-denied --prefetch

Browser-backed audit (needs local Chrome/Chromium)

# Generic read-only page summary
ts audit page https://www.example.com/

# Verify configured slots render on live pages (DOM/GPT/APS evidence)
ts audit ad-templates verify https://www.example.com/news/story
ts audit ad-templates verify https://www.example.com/news/story --json
ts audit ad-templates verify https://www.example.com/ --strict --scroll

# Carry a session past an origin challenge (repeatable)
ts audit ad-templates verify https://www.example.com/ --cookie '<NAME>=<VALUE>'

# Allow a known redirect between your own properties (e.g. apex → www)
ts audit ad-templates verify https://example.com/ --allow-cross-origin-redirect

# Point at a specific browser / tune settle timing
ts audit ad-templates verify https://www.example.com/ \
  --chrome /path/to/chrome --settle-quiet-ms 1000 --settle-max-ms 15000

Generation shares one --settle-max-ms budget across the initial settle, optional post-scroll settle, and GPT registry wait. Scrolling consumes the same budget. GPT receives the remaining budget or --settle-quiet-ms plus 500ms, whichever is greater. The minimum allowance includes time to establish a repeated reading and confirm a completed dwell before the deadline. It adds at most the quiet window plus 500ms to the shared settle allowance; later changes and slow browser reads can still produce partial evidence and a warning. DOM and network extraction follows GPT polling. Two consecutive empty polls end the registry wait early. Navigation, scrolling, and in-flight browser operations retain their own bounds, so this is not a total page deadline. Increasing --settle-max-ms can help, but continuously busy pages can consume it in initial settling and still leave GPT only its minimum polling allowance. Verification applies its settle budget separately to each page-settle phase and records slot definitions through its injected collector.

Shared config flags (all of the above)

--app-config <path>     # default: <app.name>.toml beside edgezero.toml
--manifest <path>       # default: edgezero.toml
--no-env                # skip the app-config environment overlay

Exit behavior

  • Default verify is auditor-assist: exits 0 even with missing/partial evidence.
  • --strict exits 1 when a confirmable matched slot is missing or partially confirmed; video, native, and out-of-page slots are unconfirmable and do not fail the gate. A page-level navigation failure, or a redirect that leaves the requested origin, also exits non-zero.
  • Runtime gates (e.g. [auction].enabled = false) mark a page "skipped" so --strict does not fail it.

Local live test (deterministic, no external site)

Many large ad publishers block headless/non-evasive browsers, so verify against them sees a challenge page rather than the article (this tool does not evade bot detection — --cookie forwards a clearance a human already earned, and --headful runs a visible browser). When a page comes back without slots, the run now reports GPT's observable state — whether the library reached apiReady, how many queued commands never drained, how many scripts ran — which distinguishes "the library never loaded" from "this page has no ads".

To exercise the full pipeline reliably without an external site, serve a local fixture:

# 1. Fixture page that stubs just enough GPT (no network), served over HTTP
mkdir -p demo/news
cat > demo/news/story.html <<'HTML'
<!doctype html><html><head><meta charset="utf-8">
<div id="ad-atf-0"></div>
<script>
(function(){var s=[];var gt={cmd:[]};
gt.defineSlot=function(p,z,d){var o={getAdUnitPath:function(){return p},getSlotElementId:function(){return d},getSizes:function(){return z.map(function(x){return {getWidth:function(){return x[0]},getHeight:function(){return x[1]}}})}};s.push(o);return o};
gt.pubads=function(){return {getSlots:function(){return s}}};var op=gt.cmd.push.bind(gt.cmd);gt.cmd.push=function(c){op(c);c()};
window.googletag=gt;googletag.cmd.push(function(){googletag.defineSlot('/123/news/atf',[[300,250]],'ad-atf-0')});})();
</script></head><body></body></html>
HTML
(cd demo && python3 -m http.server 8771 &)

# 2. Verify against it (config from the example above, with page_patterns ["/news/*"])
ts audit ad-templates verify http://127.0.0.1:8771/news/story.html --json
#   => slots[0].status == "confirmed"

For a realistic end-to-end generation run, ts dev proxy in front of a local Trusted Server is the reliable path — see the proxy example above.

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • No println!/eprintln! in library code (CLI output uses writeln!; errors use log)
  • New code has tests
  • No secrets or credentials committed

prk-Jr added 30 commits May 6, 2026 18:50
Adds [slot.providers.pbs.bidders] support so PBS bidder params
live in creative-opportunities.toml alongside APS params, without
needing PBS stored requests configured server-side.

PrebidAuctionProvider now sends imp.ext.prebid.storedrequest.id
as a fallback for slots with no inline PBS params, and skips
non-PBS provider keys (e.g. "aps") that belong to separate auction
providers. PrebidImpExt gains an optional storedrequest field;
empty bidder maps are omitted during serialisation.

Wires mocktioneer and criteo (placeholder IDs) for both
autoblog creative-opportunity slots.
…id entries from destroyed slots no longer persist
…patch_auction

dispatch_auction was building AuctionContext with a placeholder Request
(GET https://placeholder.invalid/) that carried no headers. Prebid's
request_bids copies User-Agent, x-forwarded-for, Referer, Accept-Language,
and cookies from context.request before sending to Prebid Server, so SSPs
received stripped requests and returned empty bids.

Fix: dispatch SSP requests before req.send_async(), using the original
request directly as AuctionContext.request. DispatchedAuction holds no
lifetime reference to Request, so the borrow ends at return and req can be
modified (restrict_accept_encoding, Host header) and sent to origin
immediately after.
In collect_dispatched_auction, the select loop checked
`auction_start.elapsed() >= deadline` after each SSP response and broke
early if the 1500ms budget had elapsed. When origin TTFB + body download
exceeded the auction budget, the check fired after collecting the first
SSP response, abandoning the second SSP's already-buffered response.
This left responses with only one (possibly errored) SSP, causing
remaining_ms == 0 which skipped the mediator, and select_winning_bids
on the partial set returned zero bids.

The deadline break is wrong in this context: SSP HTTP connections are
already bounded by the backend first_byte_timeout set at dispatch time
(1000ms per provider). By the time collect is called at origin EOF, all
SSPs have either responded or been errored by Fastly's host. The
select() calls drain instantly — no WASM-level deadline enforcement is
needed or safe.

Also add info-level log statements at dispatch, collect, and
write_bids_to_state to make the auction pipeline observable without
requiring a dashboard.
In collect_dispatched_auction, the mediator was skipped when
remaining_budget_ms(auction_start, timeout_ms) == 0. In the async-dispatch
path, auction_start is set before pending_origin.wait(), so elapsed time
includes the full origin TTFB and body download. For heavy SSR pages
(autoblog), this exceeds the 1500ms SSP budget, making remaining_ms == 0
at every collection and causing the mediator to be permanently skipped.

The mediator (adserver_mock) is the primary bid source — SSPs alone
return no bids. Skipping it means window.__ts_bids == {} on every full
page load, while handle_page_bids (which uses the sequential run_auction
path) works correctly because it measures remaining time from after SSP
collection.

Fix: give the mediator its own configured timeout (mediator.timeout_ms())
instead of the exhausted SSP budget. This mirrors how run_parallel_mediation
works: the mediator's deadline is independent of SSP round-trip time.
Side effect: mediator backend name is now stable (always t1000 for adserver_mock)
rather than varying per request with remaining_ms.
Resolve conflicts in:
- prebid.rs: keep both PBS stored-request tests (branch) and bid-param
  override rule validation tests (main)
- settings.rs: keep both creative_opportunities (branch) and debug (main)
  fields in Settings struct
- trusted-server.toml: keep [creative_opportunities] section from branch

Update handle_page_bids to use compat::from_fastly_headers_ref pattern
introduced by main's HTTP type migration (PR11), replacing direct use
of fastly::Request with the generic Request<EdgeBody> for cookie parsing,
request info extraction, and EC ID generation.
`classify_response_route` returns `BufferedProcessed` when HTML has
post-processors registered (e.g. the Next.js integration registers one
via `with_html_post_processor`). Unlike the `Stream` path, which drives
`one_behind_loop` to collect the dispatched auction at origin EOF, the
`BufferedProcessed` branch previously discarded `dispatched_auction`
entirely — so `ad_bids_state` stayed `None` and lol_html injected the
fallback `window.__ts_bids = {}` instead of real bids.

Fix: collect the in-flight dispatched auction in the `BufferedProcessed`
branch before calling `process_response_streaming`, using the same
`collect_dispatched_auction` + `write_bids_to_state` pattern that the
stream path uses. The `debug.auction_html_comment` injection is mirrored
here as well so the comment appears in both code paths when enabled.
html_escape_for_script now unicode-escapes <, >, & and U+2028/2029 in
addition to \ and ". These characters allow a crafted bid value to break
out of the <script> block or terminate the JS string in some parsers.

AuctionOrchestrator::collect_dispatched_auction now computes remaining
budget (A_deadline - elapsed) before invoking the mediator. If the budget
is already exhausted the mediator is skipped and SSP bids are returned
directly; otherwise the mediator timeout is capped at the tighter of its
configured value and the remaining budget, preventing it from running past
A_deadline.
…erride

Pass the real incoming request to AuctionContext in handle_page_bids instead
of a placeholder — SSPs now receive browser UA, referer, and cookies on SPA
navigation bids.

Guard Cache-Control in finalize_response so operator response_headers cannot
overwrite the private/no-store directives set for per-user HTML and page-bids
responses.

Disable auction_html_comment debug flag in trusted-server.toml.
The mock mediator endpoint does not echo nurl/burl/ad_id back in its
response. Build a bid index in request_bids keyed by (provider, slot_id,
bidder) — where bidder is recovered from the echoed crid field — and
restore the fields in parse_mediation_response from the original SSP bids.

Fixes the spec requirement: both nurl and burl must travel in __ts_bids
for client-side sendBeacon firing on slotRenderEnded (§4.5).
APS reads user agent from request.device — without it, real APS bids
arrive with wrong or missing device targeting. Pass the incoming UA from
both the page-load and page-bids auction paths.

@aram356 aram356 left a comment

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.

Correction to my previous review

My last review's blocking finding claimed the fixture server's HTML-for-every-path behavior caused a ~25% flake in waits_for_delayed_gpt_registry_to_stabilize_in_definition_order, citing a bisect where the parent passed 6/6 and the guard restored 10/10. That causal claim does not reproduce, and I withdraw it. Re-measured on an idle machine: the test fails 3/3 at this head (20-24s) and 2/2 at the previous head c18e59813 (14-20s), always with "timed out waiting for browser process to exit after audit" from browser.wait() (browser_collector.rs:490) rather than anything in the fixture path. The machine has Chrome running at 152.0.7977.76 against an installed framework at 152.0.7977.66 — a mid-flight browser update that postdates my original numbers. The failure is a load- and environment-sensitive problem in the child-process reap against the 5s BROWSER_CLOSE_TIMEOUT, not fixture routing.

The 404 guard is still a correct change on its own merits, and extending fixture_server_answers_every_speculative_browser_request to request /favicon.ico, /robots.txt, and /sitemap.xml and assert byte-exact 404s addresses the separate objection that the old test only proved connection reuse. It simply does not buy the stability I attributed to it. If the flake is worth chasing, the target is the browser.wait() reap path and its timeout — a separate concern from this commit, and not something I would hold this PR for.

Summary

Three of the five findings carried into this pass are resolved. The shared settle budget is implemented correctly: one Instant at browser_collector.rs:621, all three phases drawing from it, saturating_sub clamping at zero, no double-counting, and the zero-budget single-read path in poll_gpt_registry preserved so a spent budget still takes one snapshot. The two-empty-poll shortcut is now documented in cli.md:583-587, the flag help, and the rustdoc, and all three match the code — previous_empty is set on Empty and reset on both Changed and Repeated, so the exit genuinely requires two consecutive empties. The fixture acceptor now blocks in accept() with a wake-on-shutdown connect in Drop, with no shutdown race in either interleaving.

Requesting changes for one defect the shared clock introduced: an exhausted budget silently skips the post-scroll settle entirely and then reports a timeout that never happened.

Both inline suggestion blocks were verified in a scratch worktree, applied individually and together: cargo fmt --all -- --check, cargo clippy -p trusted-server-cli --all-targets --all-features -- -D warnings, and the full CLI suite (509 passed, 0 failed) all clean, with byte-exact drift checks.

Blocking

🔧 wrench

  • Exhausted budget skips the post-scroll settle and reports a timeout that never occurred — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:642 (suggestion)

Non-blocking

🤔 thinking / ⛏ nitpick / 📝 note

  • 🤔 Fixed-cost evidence reads sit inside the shared budget and starve the GPT phase — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:657
  • ⛏ First saturating_sub is dead arithmetic — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:622 (suggestion)
  • 📝 New zero-budget test is sound but silently coupled to quiet == max — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:2093
  • 🤔 Vowel-gate residue remains, closed by concession — see inline at crates/trusted-server-cli/src/commands/audit/generate/gpt_slots.rs:321

Cross-cutting / body-level findings

  • 📝 Squash-merge reminder. HEAD remains clean of real-world identifiers, but the branch's authored commits still carry customer names in their commit messages. A squash keeps them out of main's history.

CI Status

All 19 checks PASS at 5cefd4368, including the four required (cargo fmt, cargo test, format-typescript, format-docs): Analyze (actions / javascript-typescript ×2 / rust), CodeQL, browser integration tests, cargo check (cloudflare native + wasm32-unknown-unknown), cargo check/build/test (spin native + wasm32-wasip1), cargo test (axum native), cargo test (cross-adapter parity), cargo test (ts CLI, native), integration tests, integration tests (Fastly EC lifecycle), prepare integration artifacts, vitest.

Local verification at 5cefd4368: 504 passed, 0 failed, 14 ignored across the CLI lib and integration targets; clippy -D warnings clean; cargo fmt --all -- --check clean. Chrome-gated tests fail in teardown on this machine for the environmental reason described above, at this head and the previous one alike.

@prk-Jr
prk-Jr requested a review from aram356 September 9, 2026 08:04
ChristianPavilonis added a commit that referenced this pull request Sep 10, 2026

@aram356 aram356 left a comment

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.

Summary

Review at cacfb7111, covering two authored commits: the settle-budget follow-up and the adaptation to main's provider-map and secret-reference config split.

Three of the four findings carried into this pass are resolved. The zero-budget post-scroll settle now emits a distinguishable "settle budget was exhausted" warning instead of a fabricated timeout, pinned by assertions in both scroll polarities; the dead saturating_sub is collapsed; and the test-coupling comment landed. The provider-map adaptation is faithful and, on the write-side gate, stronger than what it replaced: TrustedServerAppConfig::new keeps the full prepare_runtime slot and template compilation chain and adds compile_auction_plan plus per-integration deploy validation, while correctly dropping only validate_admin_handler_passwords, which would misread a secret-store key name as a weak password. Provider iteration is BTreeMap-ordered and escaped, and no secret value can reach output because the leaves are Redacted.

Requesting changes for two issues. The new config validator echoes raw source bytes on a parse error, which can be a literal secret. And the GPT settle phase still has no floor, so the knob that exists to fund it remains non-monotonic on exactly the pages this command targets.

Three inline suggestion blocks, each verified in a scratch worktree individually and as a batch: cargo fmt --all -- --check, cargo clippy -p trusted-server-cli --all-targets --all-features -- -D warnings, and the full CLI suite (511 passed, 0 failed) all clean, with byte-exact drift checks.

Blocking

🔧 wrench

  • validate_source_config echoes raw config bytes, which can be a literal secret — see inline at crates/trusted-server-cli/src/commands/audit/generate/validate.rs:50 (suggestion)
  • GPT settle phase still has no floor, so --settle-max-ms stays non-monotonic — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:663 (suggestion)

Non-blocking

🤔 thinking / ⛏ nitpick

  • 🤔 The reorder silently moved the DOM and network snapshot point past the GPT dwell — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:691
  • ⛏ Three dead .replace() no-ops survive in the diagnostics fixture — see inline at crates/trusted-server-cli/src/commands/config/ad_templates.rs:567 (suggestion)
  • ⛏ Provider-map assertion covers neither ordering determinism nor the empty map — see inline at crates/trusted-server-cli/src/commands/config/ad_templates.rs:731
  • ⛏ explain_keeps_provider_state_separate_from_runtime_verdict reads as self-contradictory — see inline at crates/trusted-server-cli/src/commands/config/ad_templates.rs:667
  • ⛏ Ragged prose wrap in the edited docs paragraph — see inline at docs/guide/cli.md:580

Cross-cutting / body-level findings

  • 📝 Squash-merge reminder. HEAD remains clean of real-world identifiers, but the branch's authored commits still carry customer names in their commit messages. A squash keeps them out of main's history.

CI Status

All 19 checks PASS at cacfb7111, including the four required (cargo fmt, cargo test, format-typescript, format-docs): Analyze (actions / javascript-typescript ×2 / rust), CodeQL, browser integration tests, cargo check (cloudflare native + wasm32-unknown-unknown), cargo check/build/test (spin native + wasm32-wasip1), cargo test (axum native), cargo test (cross-adapter parity), cargo test (ts CLI, native), integration tests, integration tests (Fastly EC lifecycle), prepare integration artifacts, vitest.

Local verification at cacfb7111: 511 passed, 0 failed, 9 ignored across the CLI lib and integration targets; clippy -D warnings clean; cargo fmt --all -- --check clean. The 9 ignored are the Chrome-gated set, which fails in teardown on this machine for the environmental reason described in the previous review (Chrome version skew against the 5s browser-close timeout), at this head and earlier ones alike.

Comment thread docs/guide/cli.md Resolved
@prk-Jr
prk-Jr requested a review from aram356 September 11, 2026 07:15

@aram356 aram356 left a comment

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.

Summary

Review at 24ac93f32, covering both authored commits.

Five of the seven findings carried into this pass are resolved: the dead .replace() no-ops are gone, provider-map ordering is now genuinely pinned (the second provider is injected after the first in source order, so the assertion tests BTreeMap sorting rather than input order) with new empty-map coverage for both the (none) and the empty-providers status branches, the load-bearing toml::Value round-trip is explained, and the DOM/network snapshot-point change is documented in the code comment, in cli.md, and pinned by new positive assertions. The prose wrap is fixed.

The GPT budget floor is load-bearing rather than decorative: removing it drops the Chrome fixture from two collected batches to one. The #poll-driven fixture change is a legitimate conversion from a wall-clock race to a read-count-driven shape, and the warning assertion still holds for the right reason. The new tests/audit_config_diagnostics.rs is a real regression guard, verified by reverting each of creative_config's sanitization branches independently and watching it fail.

Both remaining blockers originate in my own suggestion bytes from the previous round, not in a misreading of them. The first sanitized only half of validate_source_config and carried the leaking half forward verbatim. The second floored the GPT budget one poll interval short of what a dwell actually needs. The author implemented exactly what I gave them in both cases; the corrections are below.

Two inline suggestion blocks, each verified in a scratch worktree individually and together: cargo fmt --all -- --check, cargo clippy -p trusted-server-cli --all-targets --all-features -- -D warnings, and the full CLI suite (516 passed, 0 failed) all clean, with byte-exact drift checks.

Blocking

🔧 wrench

  • Deploy-validation errors still echo literal config values — see inline at crates/trusted-server-cli/src/commands/audit/generate/validate.rs:50 (suggestion)
  • The quiet-window floor can never complete a dwell, so an exhausted budget always warns — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:664 (suggestion)

Cross-cutting / body-level findings

  • 📝 Follow-up worth filing rather than fixing here. The cleanest fix for the value echo is upstream in trusted-server-core/src/config.rs: have the configuration-validation error render field paths without validator's params map, so every caller gets a useful diagnostic that cannot carry a secret. The CLI-side sanitization suggested inline is the contiguous change available within this PR's diff, and it costs the field path. If you would rather keep the paths, make the upstream change instead and leave validate.rs alone.
  • 📝 Squash-merge reminder. HEAD remains clean of real-world identifiers, but the branch's authored commits still carry customer names in their commit messages. A squash keeps them out of main's history.

CI Status

All 19 checks PASS at 24ac93f32, including the four required (cargo fmt, cargo test, format-typescript, format-docs): Analyze (actions / javascript-typescript ×2 / rust), CodeQL, browser integration tests, cargo check (cloudflare native + wasm32-unknown-unknown), cargo check/build/test (spin native + wasm32-wasip1), cargo test (axum native), cargo test (cross-adapter parity), cargo test (ts CLI, native), integration tests, integration tests (Fastly EC lifecycle), prepare integration artifacts, vitest.

Local verification at 24ac93f32: 481 passed, 0 failed, 9 ignored at unmodified HEAD; 516 passed with both suggestions applied; clippy -D warnings clean; cargo fmt --all -- --check clean. The ignored set is Chrome-gated and fails in teardown on this machine for the environmental reason described in an earlier review, at this head and earlier ones alike.

@prk-Jr
prk-Jr requested a review from aram356 September 14, 2026 08:29
aram356 added a commit that referenced this pull request Sep 18, 2026
# Conflicts:
#	crates/trusted-server-cli/src/commands/config/ad_templates.rs

@aram356 aram356 left a comment

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.

Summary

Both blocking findings from the previous round are resolved, and I verified each independently rather than from the diff.

Deploy-validation output. Both error arms of validate_source_config are sanitized, and the misleading "not valid TOML" wording for schema errors is gone. Probed end to end through the real ts binary across five failure shapes: the raw core errors still embed literal values (a #[validate(url)] field yields {"value": String("SENTINEL_URL")}), while CLI stderr, stdout, and notes are clean in both the refusal and the warning path. The coverage gap that let this half slip through last round is closed and provably so: reverting the one-line sanitization fails one unit test and both browser-gated integration tests.

GPT dwell floor. Now settle_quiet + 2 * SETTLE_POLL_INTERVAL behind a documented gpt_polling_budget helper. An independent probe against a registry that never changes, with the shared budget exhausted, returns warned=false at every quiet value from 100ms to 3000ms, versus warned=true at every value before. Common-case wall time is unchanged at quiet + 250ms, since the loop returns the moment the dwell completes, and the per-page bound stays flat rather than multiplying with phase count. The coupled Chrome test moved from a 600ms to an 1100ms budget assertion without losing any of its four assertions, and two new unit tests preserve the negative case so the floor cannot have simply muted the warning.

New work in a5da1b89d. The ordering fix is correct and both halves are load-bearing. Reverting the collector guard reproduces the masked diagnostic, refusing cross-origin root redirect from https://example.com to null, which told operators to change their URL when the browser had never reached the network; reverting the mod.rs half replaces the specific cause with the generic missing-root error. All four navigation branches check out, and neither new message interpolates a URL that could carry credentials.

CI wiring. scripts/test-cli.sh is invoked at .github/workflows/test.yml:99 and :239 with Chrome provisioned, both new filters resolve to real tests, and the script's zero-match guard means a renamed test cannot silently stop being exercised.

Approving. Two non-blocking observations are inline, plus one follow-up below.

The inline suggestion was verified in a scratch worktree: cargo fmt --all -- --check, cargo clippy -p trusted-server-cli --all-targets --all-features -- -D warnings, and the full CLI suite (520 passed, 0 failed) all clean, with a byte-exact drift check.

Non-blocking

  • 🤔 Failed navigation burns the settle budget before the guard fires — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:686
  • ⛏ Orphaned short line in the reflowed doc comment — see inline at crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1046 (suggestion)

Cross-cutting

  • 📝 Follow-up worth filing, not fixing here. The CLI now trades the failing field path for safety, which the code's doc comment acknowledges honestly. The real fix belongs upstream in crates/trusted-server-core/src/config.rs: render field paths without validator's params map, so every caller gets a diagnostic that names the field and cannot carry a value. That would let this CLI path report the field again.
  • 📝 Squash-merge reminder. HEAD is clean of real-world identifiers, but the branch's authored commits still carry customer names in their commit messages. A squash keeps them out of main's history.

CI Status

All 20 checks PASS at 66d1b8022, including the four required (cargo fmt, cargo test, format-typescript, format-docs).

Local verification at 66d1b8022: 482 passed / 0 failed / 9 ignored in the CLI lib target plus the integration binaries; 520 passed with the suggestion applied; clippy -D warnings clean; cargo fmt --all -- --check clean. Two Chrome-gated tests were run directly and passed, exhausted_page_settle_budget_still_polls_gpt_registry and generate_unavailable_proxy_reports_navigation_failure, without hitting the teardown signature seen on this machine in earlier rounds.

@aram356
aram356 merged commit 30084db into main Sep 21, 2026
20 checks passed
@aram356
aram356 deleted the feature/ts-cli-ad-templates branch September 21, 2026 04:34
aram356 added a commit that referenced this pull request Sep 21, 2026
- Remove the documentation-refresh decision and evidence records: they
  declare rc/202608 as the delivery target and describe code decisions
  (Spin logger, workflow scoping, policy split) that this docs-only diff
  does not carry; both stay accurate on spec-docs-refresh-rc
- Keep the full ts audit and ad-templates command index: PR #823 landed
  on main and the merge from main restored those subcommands, so every
  documented chain is re-verified against a freshly built binary
- Add the missing js_asset_proxy integration to the configuration
  inventory (with a field-level section), the integrations overview, and
  both API-reference tables; the deploy-validated set has 15 entries
- Correct CONTRIBUTING.md: TESTING.md is an auction-orchestration runbook
  that repeats the adapter aliases, not a link index, and the gate link
  points at AGENTS.md
- Give each smoke script a distinct default origin port outside every
  claimed adapter port range, and make smoke_stop_process capture child
  PIDs via pgrep, wait for them to exit after signaling, and escalate to
  KILL on timeout; pgrep is now a declared requirement in all four
prk-Jr added a commit that referenced this pull request Sep 21, 2026
Resolve conflicts from the squashed #823 and #1150 merges.
aram356 added a commit that referenced this pull request Sep 24, 2026
* Bring the documentation refresh to main, scoped to documentation surfaces

Transplant the reader-facing content of the rc/202608 documentation
refresh (PR #1049) onto main so the docs ship ahead of the release merge:

- Rebuilt guides (adapters, configuration, testing, creative processing,
  integrations inventory, telemetry, tsjs, CLI, API reference), the
  restored public onboarding page, ten crate READMEs, and the internal
  decision and evidence records
- The four adapter smoke scripts plus scripts/README.md, and the
  documentation-snippet compile test
- docs/public/CNAME placeholder removed; VitePress config updated

Re-pointed at main where the release branch differs: the rc-only
`ts audit ad-templates` and `ts config ad-templates` command families are
removed from the CLI guide (every remaining documented command probed
against a main-built binary); CI-gate links target AGENTS.md; the
documentation-checks aggregate script is left out because main's rustdoc
does not yet pass with warnings denied, and testing.md lists the two
commands that do run here; scripts/README.md lists only scripts present
on main. Merge conflicts with main's newer CHANGELOG, README, TESTING,
and auction README resolved to main's side.

Verified: VitePress lint/format/build (dead links fail the build),
documentation_snippets compiles against main's core, smoke-axum.sh boots
main's adapter, and a claims scan checked every referenced script, repo
path, and route against this tree.

* Address retarget review: drop rc records, fix inventory and smoke claims

- Remove the documentation-refresh decision and evidence records: they
  declare rc/202608 as the delivery target and describe code decisions
  (Spin logger, workflow scoping, policy split) that this docs-only diff
  does not carry; both stay accurate on spec-docs-refresh-rc
- Keep the full ts audit and ad-templates command index: PR #823 landed
  on main and the merge from main restored those subcommands, so every
  documented chain is re-verified against a freshly built binary
- Add the missing js_asset_proxy integration to the configuration
  inventory (with a field-level section), the integrations overview, and
  both API-reference tables; the deploy-validated set has 15 entries
- Correct CONTRIBUTING.md: TESTING.md is an auction-orchestration runbook
  that repeats the adapter aliases, not a link index, and the gate link
  points at AGENTS.md
- Give each smoke script a distinct default origin port outside every
  claimed adapter port range, and make smoke_stop_process capture child
  PIDs via pgrep, wait for them to exit after signaling, and escalate to
  KILL on timeout; pgrep is now a declared requirement in all four
aram356 added a commit that referenced this pull request Oct 1, 2026
…through evidence gate (#1169)

* Add design for closing out issue #852

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.

* Add implementation plan for the predicate split and cache observability

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.

* Correct the PR 1 plan and spec against the code

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.

* Name the missing test_observation helper in the plan

Tasks 2 and 3 both use it and neither module has one; they must build the
context identically or the two tasks' assertions diverge.

* Restructure issue #852 as a single pull request

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.

* Add plan parts 2 and 3: probe, purge, and the readthrough gate

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.

* Carry cache outcomes on auction telemetry

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.

* Declare cache outcome columns on the auction events datasource

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.

* Add a publisher test harness with both a template cache and a telemetry 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.

* Split origin shareability out of template eligibility, and record it

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.

* Record the template-cache bypass reason from both sources

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.

* Drop template_cache_state from auction telemetry

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.

* Correct the documented CI gate list

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.

* Document the cache telemetry caveats and record two implementation findings

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.

* Narrow this branch to issue #852's own scope

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.

* Apply review findings

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.

* Add a portable HTTP client to the operator CLI

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.

* Add a portable loop-accept fixture origin for probe tests

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.

* Add ts origin probe-shareability

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.

* Key template cache entries on the reader-facing URL as well

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.

* Add a surrogate-key purge to the template cache trait

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.

* Make the reader-url purge handle independent of query parameter order

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.

* Stop the probe confounding two of its own axes

`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.

* Fail the probe when a cache answered for the origin

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.

* Make the planning documents describe what was actually built

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.

* Pin ESI mode and reader support as individually necessary

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.

* Add the admin cache-purge endpoint on Fastly

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.

* Answer cache purge with 501 on the non-Fastly adapters

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.

* Assert cache-purge parity across the three non-Fastly adapters

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.

* Add ts cache purge, driving the service's own endpoint

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.

* Add a purge leg to the local template-cache harness

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`.

* Model platform cache intent as one enum rather than two flags

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.

* Gate the origin cache bypass on shareability, not on the ad stack

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.

* Put the origin readthrough loosening behind an operator opt-in

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.

* Stop a reader's own cache semantics from blocking origin readthrough

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.

* Document origin readthrough, its weaker guarantees, and its rollback

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.

* Promote the shared template cache out of spike status

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.

* Drop the unverified latency figure and record what was measured

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.

* Stop the probe judging a bot wall's challenge page

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.

* Stop failing axes the origin honestly declares in Vary

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.

* Apply full-branch review findings

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.

* Fix origin caching and operator safety checks

* Reject incomplete origin shareability evidence

* Resolve caching probe and purge review findings

* Probe bot and prefetch representations, compare cached headers, and keep probe cookies out of argv

Bots and prefetches are excluded from the ad stack but not from shareability:
neither classification reaches origin_response_is_shareable, so a crawler,
challenge, or prefetch document served without Vary could be stored and handed
to a human navigation. Add a bot axis (crawler User-Agent) and a prefetch axis
(Sec-Purpose), each varying one signal against the same baseline. An axis is
now named for the request class rather than the header it sends, so
vary_covers_axis maps bot to user-agent and prefetch to sec-purpose.

Compare a canonical representation instead of the body alone. Fastly stores
response headers with the body, so two responses with identical HTML, a
per-audience Content-Security-Policy, and no matching Vary are cross-served
policies. Fetched::canonical() renders the cache-visible policy headers in a
fixed order followed by the body, and every axis compares that, so header
differences are judged by the same Vary rules as body differences. The policy
set is an allowlist rather than a denylist of volatile fields: a denylist fails
an origin for every Date, request id, or trace header it emits, and a probe
that cries wolf is one an operator learns to rerun until it passes. The printed
limits state what is outside the set.

Read probe cookies from the environment and validate transport before reading
them. --cookie and --admission-cookie accepted credentials on the command line,
where they are visible through ps and land in shell history, and no scheme
check stood between them and a remote plaintext origin. They are now
TRUSTED_SERVER_PROBE_COOKIES and TRUSTED_SERVER_PROBE_ADMISSION_COOKIE,
matching the admin password in ts cache purge. The scheme, loopback, and
userinfo checks move from purge_endpoint into url_guard so both commands share
one implementation.

* Refuse unparseable purge URLs and limit readthrough to document requests

A URL purge accepted any non-empty string. reader_url_surrogate_key hashes
whatever it is given, so "/article", "example.com/article" and "ftp://..." were
each acknowledged as purged: true under a key no stored object can carry — the
silent no-op that key's own documentation calls the failure that matters, and
one a CMS webhook sending paths would never find out about. Require an absolute
http(s) URL with a host, and answer 400 otherwise.

Apply the readthrough gate to document requests only. It answers a question
about pages, but it was reached by every publisher request, including
subresources: a cookie-bearing or conditional asset request bypassed the edge
cache, and every shareable asset was tagged ts-template, so the template
rollback purge became an origin-wide asset flush. Browsers send first-party
cookies on subresources, so that covered most repeat-visitor asset traffic.
Documents keep the shareability policy, including those that skip the ad stack.

Document that readthrough page purges are unreliable when one page is served
under several reader-facing spellings. Reader URLs that rewrite to one origin
URL share a single stored object tagged with the reader URL of whichever
request filled it first, so multi-host and dual-scheme deployments must purge
with --all.

Also fix a probe message that still named the removed --admission-cookie flag,
reword the origin_cache_shareable doc as the request predicate it records, and
drop ALL_ADMIN_METHODS in favour of the identical LEGACY_ADMIN_DENY_METHODS the
Axum and Spin adapters already use for this route.

* Correct origin probe safety checks and address review feedback

* Address origin caching review findings

---------

Co-authored-by: AG <132480+aram356@users.noreply.github.com>
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.

Build a standalone validation tool for configuring creative opportunities

3 participants