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 272 commits into
Conversation
- 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.
…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.
|
Round-seven body-level follow-up:
|
aram356
left a comment
There was a problem hiding this comment.
Summary
Round-8 re-review at 0ede1a1fd. c484d7158 resolves all six round-7 findings with evidence: the observed/literals split is plumbed through a new EvidenceTable::observed_literals() and makes the A/B routing probe symmetric, the token gate now catches the four hex and single-case hashes while dropping both CamelCase false positives, and the three nitpicks (iterator return, cli.md sentence, timeout-constant unification) landed with no behavior change at any call site. Both commit messages follow the sentence-case convention. Requesting changes for an over-correction in that same token fix — ALL-CAPS placement labels including four standard IAB format names are now refused as volatile — and for three structural issues in 03743c153, which adds a GPT-stability wait that sits outside --settle-max-ms, exits on a weaker criterion than the one implemented twenty lines below it, and returns partial evidence silently.
1 inline comment carries a one-click
suggestion, verified in a scratch worktree:cargo fmt --all -- --checkclean,cargo clippy -p trusted-server-cli --all-targets -- -D warningsclean, full CLI suite green at 435 passed / 0 failed — with the one companion fixture edit named in that comment, which a single-range suggestion cannot carry. The remaining comments describe fixes in prose because they span files, need a new test seam, or are design calls.
Blocking
🔧 wrench
- ALL-CAPS placement labels refused as volatile families — see inline at
crates/trusted-server-cli/src/commands/audit/generate/gpt_slots.rs:309(suggestion) - GPT-stability wait escapes
--settle-max-ms, up to 5s per page per profile — see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:966 - Stability criterion exits on two samples 250ms apart, weaker than the sibling idiom — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:999 - Bound expiry returns partial evidence silently; timeout warning misattributes the cause — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:976
❓ question
- Should
verify/pageget the same wait, or is the asymmetry structural? — see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:966
Non-blocking
♻️ refactor / 🤔 thinking / ⛏ nitpick
- ♻️ New behavior has zero deterministic coverage (all three fixtures are Chrome-gated) — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1435 - 🤔 The new fixture cannot detect the early-exit weakness — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1195 - ⛏ Order-sensitivity undocumented; the wait reaches no docs — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:962 - ⛏
distinct_ascii_bytesallocates 256 bytes for a ≤64-byte question — see inline atcrates/trusted-server-cli/src/commands/audit/generate/gpt_slots.rs:321 - ⛏ Two assertions without messages in the multi-ancestor split test — see inline at
crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs:1838
Cross-cutting / body-level findings
- 📝 No progress output during the stability wait:
CollectionProgressemits nothing whilecollect_stable_gpt_slotspolls, so a page that burns the full budget shows as a frozenAuditing … [n/m]: /pathline for 5s with no explanation. Worth a phase line or a note once the budget question above is settled. - 📝 Squash-merge reminder stands: HEAD remains clean of real-world tokens, but the branch's authored commit messages still carry customer names from earlier rounds. A squash-merge keeps them out of main's history.
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 0ede1a1fd: CLI 436 lib tests + integration suites passed, 0 failed; clippy -D warnings clean; fmt clean.
The single-case branch of `looks_random_suffix` returned before the word-shape check ever ran, so any all-caps run of four or more distinct letters read as a hash. Four standard IAB format names were among the casualties: a div id such as `promo-20260820BILLBOARD-sidebar` had its whole family dropped from the generated config, with a warning telling the operator to change markup that is in fact stable. The mirror case was missing entirely, since a lowercase hash like `zzqxwvkm` passed. Make the branch case-symmetric and gate it on vowel density instead of case, keeping the pure-hex check ahead of it so `deadbeef` and `ABCD1234` still catch. Swap the `ABCDEFGH` fixture for `XKMPQRST`: a sequential alphabet run carries a word's vowel spread and is no longer a hash shape. Also replace the 256-byte flag array in `distinct_ascii_bytes` with a four-word bitset, and give the two message-less assertions in the multi-ancestor split test the wording the third one already had.
The wait budgeted against the hardcoded five-second page-operation constant and started its own clock after both settle passes had already finished, so worst-case page time grew by an amount no flag could tune. Take the budget from `settle_max` and the dwell from `settle_quiet` instead, and give each CDP read back its own fixed bound, so a wedged evaluate is reported distinctly from an exhausted budget. Exit on a dwell rather than on one matching pair. Two reads 250ms apart can both observe the same registration burst and miss the next, which is the batching the wait exists to survive; mirror the `quiet_since` idiom of `wait_for_page_settle` and require the reading to repeat for the dwell window. Warn when the budget expires with a snapshot still in flight. Both expiry paths returned silently while every other exit warned, so the one condition the wait exists to detect produced a partial slot list indistinguishable from a stabilized read. An empty registry stays silent, because the caller's googletag-state diagnostic names the actual cause. Extract the classification into `gpt_registry_reading` so the empty-reset, order-sensitivity and repeat-match rules have deterministic coverage off the Chrome-gated path, and document why order sensitivity is kept: slot discovery takes the network id from the first usable entry and emits slots in registry order. Add a fixture whose second burst lands on a real timer rather than on observation, which the previous criterion cannot pass, and a note at the call site recording why verification needs no counterpart wait.
Three conflicts, all from main's EdgeZero v0.0.7 upgrade (0f8b44d). `run.rs` needed a union rather than a side. Main added the `active-version`, `healthcheck`, `rollback` and `config gc` commands against a `dispatch` returning `Result<(), String>`, while this branch changed that signature to `Result<RunOutcome, String>` to carry assertion exit codes. Main's four new arms therefore take the branch's `.map(|()| RunOutcome::Success)` wrapper. Two of those arms had auto-merged outside the conflict markers without it, which would not have compiled. Kept both test sets, placed at main's own insertion points so the merge does not reorder the tests main already had. `parses_audit_with_default_outputs` and `parses_audit_with_custom_outputs` stay deleted: they cover the merge-base audit surface this branch replaced, and main never touched them. `Cargo.lock` needed the `toml_edit` entry version-qualified, because main's upgrade puts a second `toml_edit` in the graph; the workspace pins 0.23.10, so the CLI keeps that one alongside this branch's `tracing`. `docs/guide/cli.md` had both sides adding a section at the same anchor. Main's `config gc` prose continues the `config push` discussion above it, so it leads, followed by this branch's ad-template diagnostics heading.
aram356
left a comment
There was a problem hiding this comment.
Summary
Round-8 review at 8f0715c59. The two newest commits independently resolved four of this round's concerns before they were posted: the GPT stability wait is now bounded by the operator's --settle-max-ms with the per-read CDP bound kept separate, the ALL-CAPS placement-label false positives are fixed by a case-symmetric vowel gate (probed in both directions: LEADERBOARD-class labels eligible, XKMPQRST/deadbeef/ABCD1234 still refused), the extracted registry-reading helpers gained deterministic tests, and the wait is documented in cli.md. The earlier cold-start Chrome flake did not recur across five runs. Requesting changes for the two defects that survived: the empty-registry path still burns the full budget — now 12s instead of 5s per GPT-less page (measured 19.2s end-to-end on a googletag: undefined page) — and the refused-plus-written stem gap still lets a configured prefix swallow a sibling with an inherited floor price (reproduced through the real merge path), plus one unanswered direction question on the multi-ancestor split warnings.
1 of the inline comments below carries a one-click GitHub
suggestion, verified in a scratch worktree (fmt, clippy-D warnings, full CLI suite, byte-exact drift check). The two blocking fixes are delivered in prose: the empty-registry exit needs loop-state changes across three regions, and theobserved_literalsfilter change must land together with the test atevidence.rs:462-483that codifies the current behavior.
Blocking
🔧 wrench
- Empty-registry burn survived the re-bounding and grew 5s → 12s per GPT-less page — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1060 - A refused-plus-written stem still drops out of
observed_literals, re-arming the configured prefix — see inline atcrates/trusted-server-cli/src/commands/audit/generate/evidence.rs:215
❓ question
- Split warnings name every tuned ancestor while routing is longest-wins — which direction? — see inline at
crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs:294
Non-blocking
🤔 thinking / ⛏ nitpick
- 🤔 Worst-case page time is now 3×
--settle-max-ms, documented as design — see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:711 - 🤔 The vowel gate still refuses vowel-free consonant-abbreviation labels — see inline at
crates/trusted-server-cli/src/commands/audit/generate/gpt_slots.rs:317 - ⛏
--settle-max-ms 0silently skips the GPT read — see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1031 - ⛏ The "do not port this into
audit::browser" comment overclaims its scope — see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:705(suggestion) - ⛏ Third name for the same 5-second CDP bound persists — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:37
CI Status
- Analyze (actions): PASS
- Analyze (javascript-typescript): PASS
- Analyze (javascript-typescript): PASS
- 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 8f0715c59: CLI suites 457 lib + 35 integration tests passed, 0 failed; Chrome-gated generate set 4 passed under TS_AUDIT_BROWSER_TESTS=1; clippy -D warnings clean; fmt clean.
Preserve JavaScript asset proxy candidate generation in the relocated audit generator while retaining ad-template slot discovery.
Chrome opens several sockets per navigation: the document request, socket pool preconnects that close without sending anything, and speculative /favicon.ico, /robots.txt and /sitemap.xml fetches. The GPT fixture served exactly one accepted connection and panicked on a connection that carried no request, so whenever a preconnect won the accept race the listener was gone before the navigation landed and the collector failed with net::ERR_CONNECTION_REFUSED. Replace the one-shot fixture with a server that accepts until the returned guard is dropped, answers each connection on its own thread so a silent socket cannot stall the document request, and treats a request-less socket as normal. Cover both failure modes with tests that need no browser.
aram356
left a comment
There was a problem hiding this comment.
Summary
Review at c18e59813. Five of the eight findings carried into this pass are resolved with tests that pin the new behavior: the GPT-registry poll now exits on a repeated empty read (re-measured, not inferred — the lazy-slot Chrome test dropped from 18.8s to 6.5s), observed_literals keeps a stem that was refused on one page but written as a concrete slot from another, split warnings follow longest-wins routing, a zero settle budget still takes one snapshot, and the duplicated 5-second CDP constant is down to one definition. The observed_literals fix is worth singling out for how it was done: the test that encoded the old behavior was renamed and re-pointed to assert the written-slot overlap it had never covered, and the end-to-end merge probe now lives in-tree and runs in both refusal orders.
Requesting changes for one issue this round introduced: the browser-fixture server answers Chrome's speculative requests with the full HTML document, which I bisected to a ~25% flake in the Chrome-gated tests.
The single inline
suggestionbelow was verified in a scratch worktree: applied, thencargo fmt --all -- --check,cargo clippy -p trusted-server-cli --all-targets --all-features -- -D warnings, and the full 509-test CLI suite, all clean with a byte-exact drift check — plus four Chrome-gated runs of the affected test (3.2–4.0s, 4/4 pass) on top of the six the analysis pass ran.
Blocking
🔧 wrench
- Fixture server answers speculative browser requests with the full document, flaking the Chrome-gated tests — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1496(suggestion)
Non-blocking
🤔 thinking / ⛏ nitpick
- 🤔 Worst-case page time is still 3×
--settle-max-ms— see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:712 - 🤔 The two-empty-read exit is a magic constant with no escape, and
--settle-max-msno longer covers the GPT-less path — see inline atcrates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1080 - 🤔 Vowel-gate residue closed by concession rather than fix — see inline at
crates/trusted-server-cli/src/commands/audit/generate/gpt_slots.rs:316 - ⛏ Fixture acceptor polls on a 5ms sleep instead of blocking — see inline at
crates/trusted-server-cli/src/commands/audit/generate/browser_collector.rs:1441
Cross-cutting / body-level findings
- 📝 The tokio gating change is correct and needs no action. Moving
tokio = { features = ["test-util"] }fromcfg(target_os = "macos")tocfg(not(target_arch = "wasm32"))dev-dependencies is necessary for the six new#[tokio::test(start_paused = true)]tests, which are not macOS-gated. As a dev-dependency it cannot reach a production build, and the CLI is only ever built for host triples with an explicit--target, so the predicate excludes nothing in practice. No feature-unification risk into the wasm adapters. - 📝 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 c18e59813 (Analyze actions / javascript-typescript ×2 / rust, CodeQL, browser integration tests, cargo check cloudflare + spin, cargo fmt (required), cargo test (required), cargo test axum / cross-adapter parity / ts CLI native, format-docs (required), format-typescript (required), integration tests, integration tests Fastly EC lifecycle, prepare integration artifacts, vitest).
Local verification at c18e59813: 509 tests passing across the CLI lib and integration targets, 13 ignored, 0 failed; clippy -D warnings clean; cargo fmt --all -- --check clean; Chrome-gated set passes as a batch but is flaky standalone at HEAD — see the blocking finding.
Return empty 404 responses for speculative fixture requests and wake the blocking acceptor on shutdown. Pin routing with real speculative paths. Share the generation settle budget across initial, post-scroll, and GPT waits while retaining a snapshot after exhaustion. Document the shared budget and the two-empty-poll shortcut, with Chrome regression coverage.
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.
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.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 and takes one snapshot even with a zero settle budget. A nonempty registry still needs the configured dwell window; late bursts reset that window. Generation shares one settle budget across initial settling, optional post-scroll settling, and GPT stabilization. GPT polling precedes metadata extraction so those reads cannot consume its remaining budget. 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_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)
Generation shares one
--settle-max-msbudget across the initial settle, optional post-scroll settle, and GPT registry wait. Scrolling consumes the same budget; metadata extraction follows GPT polling. An exhausted budget still takes one GPT snapshot, and two consecutive empty polls end the registry wait early. Navigation and individual browser operations retain separate timeouts, so this is not a total page deadline. Verification applies its settle budget separately to each page-settle phase. GPT can finish registering after the document and resource stream have gone quiet, so a single read there would sometimes miss a later batch. Raise--settle-max-msif a run warns that slot registration did not hold still.verifyneeds no such wait: its collector is injected ahead of publisher scripts and records each slot as it is defined.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)