Add ts CLI ad-template config diagnostics and browser audit - #823
Add ts CLI ad-template config diagnostics and browser audit#823prk-Jr wants to merge 256 commits into
Conversation
Replace the head-injected __ts_bids design with a server-cached bid delivery model fetched by the client via a new /ts-bids endpoint. The auction never blocks page rendering — </head> flushes immediately, body parses without waiting for bids, and the client fetches bids in parallel with content paint. Key changes: - §2 Goal: bid delivery decoupled from page rendering; FCP unchanged from no-TS baseline - §4.3 Auction Trigger: drop buffered/streaming dichotomy; single mode forces chunked encoding on all origins (WordPress, NextJS, etc.) - §4.4 Head Injection: only __ts_ad_slots and __ts_request_id injected at <head> open; bid results moved to /ts-bids endpoint - §4.6 Client Residual: __tsAdInit defines slots immediately, fetches bids via /ts-bids, applies targeting and fires refresh() after resolve - §4.7 (new) Caching Behavior: explicit cacheability table for HTML, JS, CSS, tsjs bundle, bid results; Fastly edge HTTP cache leveraged for origin HTML - §5 Request-Time Sequence: full mermaid diagram covering content + creative + burl flow with cache-hit and cache-miss branches; separate text sequences for cache-hit (~80ms FCP, ~900ms ad-visible) and cache-miss (~250ms FCP, ~1,050ms ad-visible) - §6 Performance Summary: cache-hit and cache-miss columns; FCP added as a tracked metric - §7 Implementation Scope: add bid_cache.rs, /ts-bids endpoint, force chunked encoding step - §8 Edge Cases: origin-agnostic entries; new entries for /ts-bids 404 and client-never-fetches-/ts-bids Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pivot from the /ts-bids fetch endpoint + in-process bid_cache design to
inline __ts_bids injection before </body>. The earlier design relied on
shared state that doesn't reliably survive Fastly Compute's per-request Wasm
isolate model — body injection achieves the same FCP property in a single
response with no shared-state requirement.
Key changes:
- §4.3: replace /ts-bids long-poll with bounded </body> hold tied to
A_deadline. Body content above </body> paints first; close-tag held
until auction completes or A_deadline fires (graceful __ts_bids = {}
fallback).
- §4.3: add auction-eligibility gating (consent, bot UA, prefetch hints,
HEAD method, slot match) so auctions fire on real first-page-load
impressions only.
- §4.4: replace __ts_request_id + /ts-bids machinery with two inline
<script> blocks — __ts_ad_slots at <head> open, __ts_bids before
</body> via lol_html el.on_end_tag().
- §4.5: move both nurl and burl to client-side firing from
slotRenderEnded after hb_adid match. Server-side firing rejected to
avoid billing inflation on bids that never render.
- §4.6: replace fetch+Promise pattern with synchronous __ts_bids read.
Add lazy slim-Prebid loader (post-window.load) for scroll/refresh
auctions and Phase B identity warm-up. Add ts_initial=1 slot-ownership
sentinel.
- §4.7: switch Cache-Control from private, no-store to private,
max-age=0 to preserve browser BFCache eligibility while still
preventing intermediate-cache leaks.
- §4.8 (new): document the EC/KV identity model as load-bearing auction
input — Phase A retrieval at request time, Phase B post-render
enrichment via slim-Prebid userID modules. Add bare-EC first-impression
caveat and auction_eid_count metric. Note federated-consortium
passphrase property and clickstream-compounding speed win.
- §5: update mermaid + cache-hit/miss timelines for bounded body hold;
ad-visible converges to ~870ms (hit) / ~1,020ms (miss).
- §6: drop /ts-bids RTT row; add DCL row; add clickstream-compounding,
TS-overhead, identity-coverage, and confidence-interval framing.
- §7: drop bid_cache.rs and /ts-bids endpoint from scope; add
auction-eligibility gating and slim-Prebid bundle build target. Add
explicit "Deleted" subsection.
- §8: drop /ts-bids edge cases; add SPA/pushState, bare-EC, bot/prefetch,
HEAD, BFCache restoration cases.
- §9.6: server-side GAM downgraded from "Phase 2 commitment" to
aspirational and contingent on Google agreement. §9.8 (slim-Prebid
bundle composition), §9.9 (Privacy Sandbox), §9.10 (per-bidder consent)
added as follow-ups.
Implementation plan at docs/superpowers/plans/2026-04-30-server-side-ad-templates.md
is now stale relative to this spec; needs regenerating before code lands.
…ities.toml Adds the creative_opportunities field to Settings struct to deserialize configuration for the server-side ad auction feature. Includes build.rs stubs for types required during build-time configuration validation. Creates creative-opportunities.toml with example slot configuration and updates trusted-server.toml with the [creative_opportunities] section defining GAM network ID, auction timeout, and price granularity settings. Tests pass with proper TOML parsing of the creative_opportunities section.
…ared auction state
- Add `ad_slots_script: Option<String>` and `ad_bids_state: Arc<RwLock<Option<String>>>` fields to `HtmlProcessorConfig`
- Update `from_settings` to initialize both new fields with safe defaults
- Prepend `ad_slots_script` inside the existing `<head>` handler before integration inserts
- Add `element!("body", ...)` handler that uses `end_tag_handlers()` to inject `__ts_bids` before `</body>`; falls back to empty `{}` when auction state is `None`
- Add `IntegrationRegistry::empty_for_tests()` test helper
- Add three new tests covering all injection paths
…gibility gates; max-age=0 - Make handle_publisher_request async; add orchestrator and slots_file params - Dispatch origin request with send_async before running auction in parallel - Gate auction on GET, no prefetch, no bot, matched slots, TCF purpose-1 consent - Run server-side auction and write bucketed bids to ad_bids_state Arc<RwLock> - Compute ad_slots_script after response headers; set Cache-Control: private, max-age=0 - Fix Stream arm to thread actual ad_slots_script and ad_bids_state through - Add build_auction_request, build_bid_map, build_bids_script, build_ad_slots_script helpers - Update route_tests.rs to pass empty slots_file to route_request
…m slotRenderEnded
- build_bid_map now returns serde_json::Map with full bid objects (hb_pb,
hb_bidder, hb_adid, nurl, burl) instead of a plain CPM string map
- build_bids_script / build_ad_slots_script now emit full <script> tags
using JSON.parse("…") for safe inline embedding; add html_escape_for_script helper
- build_ad_slots_script uses correct property names (gam_unit_path, div_id,
formats, targeting) matching the client-side TSJS bundle expectations
- Replace map_or(false, …) with is_some_and(…) on lines 546, 549, 567
- Add # Panics doc sections to handle_publisher_request and create_html_processor
…nities.toml at startup
… from slotRenderEnded; slim-Prebid lazy loader
- Enable APS and adserver_mock in auction config; set providers and mediator - Increase auction_timeout_ms from 500ms to 3000ms — 500ms was too tight for HTTPS round-trips to mocktioneer, leaving the mediator zero budget - Fix mediation request: send numeric price instead of opaque encoded_price; mocktioneer requires a decoded price field and does not support encoded_price - Expand creative-opportunities slot page_patterns to include /news/**
Define SlotRenderEndedEvent, SlotRenderEvent, and TestWindow types to eliminate all @typescript-eslint/no-explicit-any violations in gpt/index.ts and gpt/index.test.ts. Extend GptWindow with __tsjs_slim_prebid_url so installSlimPrebidLoader avoids the any cast.
Set gam_network_id to 88059007 (autoblog production network). Update atf_sidebar_ad slot to /88059007/autoblog/news with div_id ad-atf_sidebar-0-_r_2_ (desktop ATF sidebar, 300x250); restrict page_patterns to article paths only (/20**, /news/**) since that div does not exist on the homepage. Add homepage_header_ad slot targeting /88059007/autoblog/homepage with ad-header-0-_R_jpalubtak5lb_ for 970x90/728x90/970x250 leaderboard formats. Reduce auction_timeout_ms from 3000 to 500 to cap TTFB at the spec-recommended ceiling.
The bids script set window.__ts_bids but never invoked the __tsAdInit function, leaving GPT slots undefined and server-side targeting (hb_pb, hb_bidder) never applied. Both the winning-bid path (build_bids_script) and the no-auction fallback (html_processor None branch) now guard-call the function after the assignment.
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.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Reviewed the locked head revision 4c9777d14bf1f541f25cedd37770aa800e8668c6. CI and local validation pass. I found two medium-priority correctness issues in generation/merge behavior and one documentation-policy issue; details are inline.
Apply the requested origin boundary to every collected page, not just the root navigation. A section page that redirected off the audited origin previously folded its slots, formats and ad-unit paths into the generated config, and a later device profile's own root redirect was never checked at all. Both sites now skip such a page with a path-only note, and the later profile stops counting it towards profile coverage, so the existing zero-coverage refusal still fires when every page is lost. Restrict slot prefix reconciliation to the operator's original configured slots. matching_slot_index searched the whole mutable merged list, so a slot appended during this run became a prefix candidate for later discoveries: ad-top absorbed a later ad-top-sidebar, discarding its unit path and provider state while emitting no broad-prefix diagnostic. Run additions now match by exact identity instead, making the result order independent. Replace the real publisher named in the scroll and staleness design document with generic wording, per the documentation policy in CLAUDE.md. Tests cover a redirected section page, a later-profile root redirect, and an order-sensitive merge with an unrelated existing slot alongside ad-top and ad-top-sidebar. Reverting the two production changes fails exactly these three tests and nothing else.
# Conflicts: # crates/trusted-server-core/src/publisher.rs
Main added the dedicated `[creative_opportunities].enabled` template switch (#1008) with its own publisher-local ad-stack gate, while this branch moved the same gate into core as `evaluate_ad_stack_gate`. Resolve in favor of the shared gate and give it the new switch, so the CLI diagnostics keep reporting the same verdict the runtime reaches: - Drop `ServerSideAdStackConfig`/`should_run_server_side_ad_stack` and route the publisher call site through `evaluate_ad_stack_gate` with `ad_templates_enabled`. - Keep `is_server_side_ad_eligible_navigation`; the inactive-template cache policy needs the request-only half of the gate. - Add `AdStackGateName::AdTemplatesEnabled` and widen the exhaustive gate tests to the eighth gate, absorbing the coverage of main's deleted unit test. - Feed the switch from both CLI gate call sites, add the `Gates` JSON field, the `explain` gate row, and the `lint` switch line and status.
# Conflicts: # crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs
aram356
left a comment
There was a problem hiding this comment.
Summary
Round-6 re-review at fec84adef. Round 5 is fully resolved — every finding closed, two beyond what was asked (cli_definition_is_valid, the ErrorKind upgrade), and the borrowed-root precision question settled by a disjointness argument that was independently verified. The two new feature series are largely sound: the scroll pass is genuinely unified rather than tripled, the div-id reconciliation is order-independent and idempotent, the round-3 prefix hazard is confirmed not reintroduced (every runtime consumer resolves exact/longest-first), and HEAD passes the real-world token sweep. Requesting changes for two reproduced false positives in the new stale-slot diagnostic — an ambiguity-refused placement and a live broad prefix are both reported "not observed during this crawl" with advice that cannot help — plus a set of doc-accuracy and hardening items, most with one-click fixes.
12 of the inline comments below carry a one-click GitHub
suggestion. Every suggestion was verified in a scratch worktree, in isolation and as a batch:cargo fmt --all -- --check,cargo clippy -p trusted-server-cli --target aarch64-apple-darwin --all-targets -- -D warnings, the CLI test suites, anddocsprettier, all clean with byte-exact drift checks (the Chrome-gated fixture behind the timing-margin suggestion was not executed; that one is arithmetic-derived). The remaining comments describe fixes in prose because the change spans non-adjacent regions or is a design call.
Blocking
🔧 wrench
- Ambiguity-refused placements reported "not observed", with advice that cannot help — see inline at
crates/trusted-server-cli/src/commands/audit/generate/mod.rs:746 - A live broad prefix is reported unobserved when its exact sibling is configured — see inline at
crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs:378
Non-blocking
♻️ refactor / 🤔 thinking / ⛏ nitpick
- ♻️ Split-off siblings silently lose the parent's hand-tuned fields — see inline at
crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs:353 - 🤔 The 8+8 token rule refuses stable date-plus-word div ids — see inline at
crates/trusted-server-cli/src/commands/audit/generate/gpt_slots.rs:285 - ♻️
ScrollFailureduplicateseval_discard's warning vocabulary — see inline atcrates/trusted-server-cli/src/commands/audit/browser_scroll.rs:8 - ♻️ The Linux build fix papers over
derive_more's macOS-only gating;ScrollFailurelacksimpl Error— see inline atcrates/trusted-server-cli/src/commands/audit/browser_scroll.rs:11 - ⛏
collect_open_pagestill takes 7 positionals after the grouping commit — see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:574 - ⛏ Refusal table missing the new cross-origin page skip — see inline at
docs/guide/cli.md:243(suggestion) - ⛏ Merge paragraph still describes pre-round-6 identity rules — see inline at
docs/guide/cli.md:277(suggestion) - ⛏ "Not by recognising token shapes" contradicted by the shape recognizer — see inline at
docs/guide/cli.md:386(suggestion) - ⛏ Shared-flag list omits
--scroll— see inline atdocs/guide/cli.md:455(suggestion) - ⛏ Broken
HOST_TARGETshell snippet, six occurrences — see inline atdocs/superpowers/plans/2026-08-24-ad-template-generate-scroll-staleness.md:61(suggestion) - ⛏ Ragged wrap left by the token scrub — see inline at
docs/superpowers/specs/2026-08-24-ad-template-generate-scroll-staleness-design.md:96(suggestion) - ⛏ Dead filter predicate in the observation seed — see inline at
crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs:231(suggestion) - ⛏ Staleness note's
--replaceguidance understates what it discards — see inline atcrates/trusted-server-cli/src/commands/audit/generate/mod.rs:759(suggestion) - ⛏
observed_div_idssuperset contract undocumented — see inline atcrates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs:208(suggestion) - ⛏ Bare assertions on the wording-split test and scroll default — see inline at
crates/trusted-server-cli/src/commands/audit/generate/mod.rs:2254andcrates/trusted-server-cli/src/run.rs:520(suggestions) - ⛏ Chrome fixture's ~250 ms timing margin — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1133(suggestion)
Cross-cutting / body-level findings
- 📝
--scrollis unreachable fromts audit generateand the legacy alias — both build the collector with no.with_scroll(...). This matches the design doc's scope statement, so it reads as intentional; recording the asymmetry since the collector now carries a knob one of its two callers cannot reach. - 📝 "Autoblog" survives in branch history — HEAD is clean (verified across every added line), but the name exists in commits
28881e053,f4f9a6e66, and11b013005before the2f5cae6dascrub. If the repository policy extends to history, squash-merge this PR rather than merge-committing it.
CI Status
- Analyze (actions): PASS
- Analyze (javascript-typescript): PASS (×2)
- Analyze (rust): PASS
- CodeQL: PASS
- browser integration tests: PASS
- cargo check (cloudflare native + wasm32-unknown-unknown): PASS
- cargo check/build/test (spin native + wasm32-wasip1): PASS
- cargo fmt: PASS (required)
- cargo test: PASS (required)
- cargo test (axum native): PASS
- cargo test (cross-adapter parity): PASS
- cargo test (ts CLI, native): PASS
- format-docs: PASS (required)
- format-typescript: PASS (required)
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- prepare integration artifacts: PASS
- vitest: PASS
Local verification at fec84adef: CLI suites 251 + 217 + 30 audit/generate/run-scoped tests passed, full crate green; clippy -D warnings clean; fmt clean; docs prettier clean.
|
Round-six review follow-up:
|
# Conflicts: # crates/trusted-server-core/src/publisher.rs
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.Settings::from_toml, the same load path the runtime uses at startup, on the--dry-runpath too. An unloadabletrusted-server.tomlis a full-site outage once pushed, not a degraded ad stack.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
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.rsSettings::from_tomlbefore 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, and the deploy-ordering contractdocs/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>— 347 passedcd crates/trusted-server-js/lib && npx vitest run(829 passed)cd 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 and scroll-phase attributionts dev proxy— template and section policy inferred, per-render div-id fragments refused, generated config loads throughSettings::from_tomlNotable 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)
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)