Repository navigation
Add ts CLI ad-template config diagnostics and browser audit - #823
Conversation
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
…all HTML responses per spec §4.7 + §8
… Purpose 1 when gdpr_applies
…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
left a comment
There was a problem hiding this comment.
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
suggestionblocks 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_subis dead arithmetic — see inline atcrates/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 atcrates/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.
aram356
left a comment
There was a problem hiding this comment.
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
suggestionblocks, 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_configechoes raw config bytes, which can be a literal secret — see inline atcrates/trusted-server-cli/src/commands/audit/generate/validate.rs:50(suggestion)- GPT settle phase still has no floor, so
--settle-max-msstays non-monotonic — see inline atcrates/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 atcrates/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_verdictreads as self-contradictory — see inline atcrates/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.
aram356
left a comment
There was a problem hiding this comment.
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
suggestionblocks, 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 withoutvalidator'sparamsmap, 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 leavevalidate.rsalone. - 📝 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.
# Conflicts: # crates/trusted-server-cli/src/commands/config/ad_templates.rs
aram356
left a comment
There was a problem hiding this comment.
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
suggestionwas 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 withoutvalidator'sparamsmap, 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.
- 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
* 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
…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>
Summary
[creative_opportunities]) configuration: static path/slot diagnostics viats config ad-templates …, and browser-backed live verification viats audit …(local Chrome/Chromium over CDP).ts audit ad-templates generate <url>to bootstrap[creative_opportunities]from a live site. One run crawls the publisher's sections (sitemap viarobots.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 existingtrusted-server.tomlin place, preserving every other section and comment.{network_id}/{section}ad-unit template plus thesection_root/section_segmentpolicy 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.--dry-runpath too. The candidate is deserialized asTrustedServerAppConfigand checked throughTrustedServerAppConfig::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.chromiumoxide) are excluded from thewasm32-wasip1build, and the runtime ad-stack gate is shared withpublisher.rsso the CLI cannot drift from server behavior.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-msplus 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
trusted-server-core/src/creative_opportunities.rs[creative_opportunities]config types,match_slots, sharedevaluate_ad_stack_gate;compile_page_patternas the single glob definition;derive_sectionmade public so tooling checks inference against the runtime's own derivation rather than a second implementationtrusted-server-core/src/publisher.rsshould_run_server_side_ad_stackthrough the shared gate (behavior-preserving)trusted-server-cli/src/commands/config/ad_templates.rsts config ad-templates {lint,match,check,explain}static diagnosticstrusted-server-cli/src/app_config.rstrusted-server-cli/src/ad_templates/{expected,compare,output}.rstrusted-server-cli/src/commands/audit/{mod,page,collector,browser,ad_templates}.rs,commands/audit/ad_template_collector.jsts audit page+ts audit ad-templates verify: chromiumoxide collector, read-only GPT/APS/DOM init script, verifier orchestration, cross-origin refusaltrusted-server-cli/src/commands/audit/generate/crawl_plan.rstrusted-server-cli/src/commands/audit/generate/evidence.rstrusted-server-cli/src/commands/audit/generate/unit_template.rs{network_id}/{section}inference with positional network binding, a single-varying-segment rule, the witness rule, and replay through the runtime's own renderertrusted-server-cli/src/commands/audit/generate/page_patterns.rs/newsand/news/*) without extrapolating past a witnessed sectiontrusted-server-cli/src/commands/audit/generate/validate.rsTrustedServerAppConfig::newsource-config validation before it replaces the file; a pre-existing failure downgrades to a warning so an already-broken config can still be updatedtrusted-server-cli/src/commands/audit/generate/{mod,gpt_slots}.rs_R_/_r_ids,-container, hex UUIDs)trusted-server-cli/src/commands/audit/generate/{browser_collector,collector,analyzer}.rstrusted-server-cli/src/run.rs,src/lib.rsauditnamespacetrusted-server-cli/Cargo.tomledgezero-core+serde_jsondeps (cfg-gated off wasm, like the existing browser deps)docs/guide/cli.mdts audit ad-templates generatedocumented: crawl behavior, refusal table, consent platforms, proxy auditing, the deploy-ordering contract, and generation’s shared settle budget with GPT stabilization before metadata extractiondocs/superpowers/{specs,plans}/2026-06-26-server-side-ad-template-cli*Test plan
Per CLAUDE.md, a bare
cargo test/cargo clippy --workspacefails 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 -- --checkcargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasmcargo clippy -p trusted-server-cli --target <host-triple> --all-targets --all-features -- -D warningscargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spincargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity(13 passed)cargo test -p trusted-server-cli --target <host-triple>cd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1(pluscargo build -p trusted-server-cli --target wasm32-wasip1— browser deps stay out of wasm)./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 timerts dev proxy— template and section policy inferred, per-render div-id fragments refused, generated config loads throughSettings::from_tomlGeneration 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
Configure slots
In your (gitignored)
trusted-server.toml— fictional values shown:Generate slots from a live site (needs local Chrome/Chromium)
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_pathtemplate is preserved.--replacediscards 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-consentobserves the un-consented page instead.Auditing a production hostname served locally.
ts dev proxyserves 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 thanlocalhost: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
section_rootis unknownStatic diagnostics (no browser)
Browser-backed audit (needs local Chrome/Chromium)
Generation shares one
--settle-max-msbudget 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-msplus 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-mscan 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)
Exit behavior
verifyis auditor-assist: exits0even with missing/partial evidence.--strictexits 1 when a confirmable matched slot is missing or partially confirmed; video, native, and out-of-page slots areunconfirmableand do not fail the gate. A page-level navigation failure, or a redirect that leaves the requested origin, also exits non-zero.[auction].enabled = false) mark a page "skipped" so--strictdoes not fail it.Local live test (deterministic, no external site)
Many large ad publishers block headless/non-evasive browsers, so
verifyagainst them sees a challenge page rather than the article (this tool does not evade bot detection —--cookieforwards a clearance a human already earned, and--headfulruns a visible browser). When a page comes back without slots, the run now reports GPT's observable state — whether the library reachedapiReady, 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:
For a realistic end-to-end generation run,
ts dev proxyin front of a local Trusted Server is the reliable path — see the proxy example above.Checklist
unwrap()in production code — useexpect("should ...")println!/eprintln!in library code (CLI output useswriteln!; errors uselog)