Repository navigation
Description
Description
A blank APS creative render is currently indistinguishable from a delivered one, and nothing on the render path says why.
An APS bid that wins Prebid targeting is served by Ad Manager as a 1x1 universal creative that resizes itself only once the creative draws. Ad Manager reports a non-empty 1x1 render either way, and the tester framework reports the slot as "filled". So a slot that never drew and a slot that drew correctly look identical from the outside.
Two blind spots make this close to undiagnosable:
-
Every guard on the APS render path returns silently. In the sandboxed renderer document (
crates/trusted-server-core/src/integrations/aps.rs,APS_RENDERER_DOCUMENT), a frame that rejected the descriptor, one that never received it, and one that timed out all produce the same observable: an iframe that loaded and did nothing. -
The APS capability handshake never reports to GPT diagnostics. The APS path runs on the publisher's own Prebid ad units, which never pass through Trusted Server slot mapping, so no creative opportunity is recorded for them. The store rejects the attempt as
creative_request_without_slotand the request cycle staysdelivery: unknown. On one publisher page under investigation that was 24 of 24 cycles unattributed, while APS bids were rendering blank.
Background
PR #1052 attempted this and is being replaced rather than revived. Its head sat unchanged from 2026-09-01, accumulated five CHANGES_REQUESTED reviews from three reviewers with none addressed, and now conflicts with main, which has since rewritten large parts of gpt/index.ts. Its anchor, #1019, is closed unmerged.
The security shape of that attempt was sound and is being carried forward: fixed reason categories rather than echoed descriptor content, one-shot reporting per frame, and a null-prototype frozen allowlist for reasons crossing the origin boundary.
Review #pullrequestreview-5262485758 records the six defects that stopped it, each with a reproduction. Three of them share one root cause worth stating plainly: the bridge reused diagnostics-store APIs against their contracts.
Scope: carried into the replacement PR
Reason codes that actually reach the console:
- The sandboxed renderer document names the guard that stopped it instead of returning silently.
- The Universal Creative source labels its own frame timeout and frame load error, and relays whichever reason it holds to the top window.
- Reasons resolve through a null-prototype allowlist, so an unlisted or hostile value from the cross-origin creative frame is dropped.
- The runtime allowlist in
gpt_diagnostics/store.tsand the overlay's fact renderer both learn the new categories, so a reason is recorded and displayed rather than silently discarded. - Fix the direct render path's failure teardown, which the added
reasonfield breaks:renderApsCreative's listener exact-matches two keys, so a three-key failure message is dropped and the hidden frame survives for the full 10s timeout over publisher content.
Scope: deferred, tracked here
These are the parts that need design work rather than a port, and are deliberately not in the replacement PR:
-
A narrow APS-shaped store association API. The bridge needs to associate a GPT slot so an attempt can be attributed, but
recordTrustedServerOpportunitymeans "Trusted Server supplied this slot's next GPT request" and plants a pending request intent consumed by the nextslotRequested. Using it at render time mis-attributes the following cycle:unattributedbecomestrusted_server_direct, a genuinepublisher_refreshbecomescompeting, anddeliveryflipsunknowntopending. What is needed is a call that populatestrustedServerSlotswithout planting a request intent. -
A post-completion failure state.
recordTrustedServerCreativeResponsemarks the attemptcompletedand clears its cycle, after whichrecordTrustedServerCreativeFailurereturns early. The bridge records a response the moment the descriptor is posted, but every relayed render failure arrives later (the frame timeout is 10s). So late evidence is dropped and the cycle reports a clean confirmed delivery. Either the response recording moves to a genuine render-confirmed signal, or the store gains a state that lets late evidence downgrade a completed attempt. -
Opportunity revision on failure.
renderable_candidateis asserted before any render is attempted and never revised when the render fails. -
Tombstone and attempt lifetimes disagree. A tombstone lives for the renderer entry TTL (300s default) while a creative attempt expires after 30s, so
attemptIdis handed out stale for minutes. Harmless today, but once the two items above are fixed, theexpiredbranch starts emittingcreative_attempt_expiredinto a 128-entry ring buffer on exactly the long-tail replay case. -
Source validation on the relay branch. The relay accepts a failure report with no check on
e.source, so any frame with atophandle and a live ad ID can forge one, including the security-signal categories. The ad ID is not secret from the ad-serving chain: it is published ashb_adidtargeting. This must land together with the store allowlist — the allowlist is the only reason the forgery is currently inert. -
Behavioural coverage for the renderer document. Its inline JS is asserted only by Rust substring checks over the source string, which pass against a document that never calls
report(). The JS suite already evaluates a sibling document in jsdom, so the same technique would let these assertions test the guards rather than the source text.
Notes
Related to #1081, which covers TS Console wording and observability UX (creative numbering, auction timing metrics, auction labelling). That issue is about how the console presents what it knows; this one is about the APS render path producing the evidence in the first place. Kept separate deliberately.
Activity
aram356 commentedon Oct 12, 2026
The carried-forward scope is implemented in #1300. The deferred items in this issue stay open after it merges.