Skip to content

July release candidate (DO NOT MERGE)#919

Draft
ChristianPavilonis wants to merge 95 commits into
mainfrom
rc/july
Draft

July release candidate (DO NOT MERGE)#919
ChristianPavilonis wants to merge 95 commits into
mainfrom
rc/july

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Consolidates the July release-candidate changes for review and merge to main.
  • Includes auction transport stability, Fastly origin streaming, Prebid User ID diagnostics/EID preservation, APS OpenRTB, server-side ad-stack improvements, and React hydration safety on Next.js App Router.

Included pull requests

Test plan

  • cargo fmt --all -- --check
  • cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin
  • cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm
  • cd crates/trusted-server-js/lib && npx vitest run

prk-Jr and others added 30 commits July 8, 2026 14:46
Fastly dynamic backend names embed the first-byte and between-bytes
timeouts so a registration can never be silently reused with a different
transport configuration. Deriving those timeouts from the remaining
wall-clock auction budget minted a new backend name on nearly every
request, defeating cross-request TCP/TLS connection reuse (Fastly pools
connections per backend name) and accumulating registrations toward the
per-service dynamic backend limit.

Compute the effective transport timeout from the configured provider
timeout verbatim when the budget allows, floor the budget-bound value to
250ms buckets otherwise, and pass sub-quantum remainders through exactly
so publishers with sub-250ms configured budgets keep launching. The
quantized value feeds both the backend name and the registered
configuration, so they cannot diverge. Rounding down never extends a
transport cap past the auction deadline, which the mediator and
dispatched-collect paths rely on to bound the </body> hold.

Also add a Fastly platform test pinning predict_name == ensure for the
same spec, since the orchestrator maps responses back to providers by
predicted backend name.

Fixes #847
Publisher pages were fully buffered before the first byte reached the
client: the platform client materialized the origin body (10 MiB cap),
the rewrite pipeline ran over an in-memory cursor, and the EdgeZero
finalize buffered the assembled response while awaiting auction
collection. TTFB therefore tracked full origin transfer plus the
auction instead of origin first byte.

- Add supports_streaming_responses() to PlatformHttpClient (default
  false, Fastly true) and request with_stream_response() on the
  publisher origin fetch only where honored
- Teach the pipeline to consume Body::Stream asynchronously:
  BodyChunkSource (cumulative raw-byte cap via
  publisher.max_buffered_body_bytes), push-style
  BodyStreamDecoder/BodyStreamEncoder in streaming_processor
- Replace the Fastly buffered finalize with
  publisher_response_into_streaming_response: a lazy Body::Stream that
  commits headers at origin first byte, streams rewritten chunks, and
  holds only the </body> tail for auction collection; bids still
  inject before body close
- Share one hold implementation (hold_step_decoded_chunk /
  hold_finish_segments) between the lazy body and the writer-driven
  loop so the paths cannot drift; collect_non_html_auction dedupes the
  collect-before-stream path
- Finalize brotli decode with close() so truncated origin streams
  error instead of silently truncating; decode failures emit
  stream_decode_error telemetry
- Guard bodiless (HEAD/204/304) responses and log wasted auction
  dispatch, matching the buffered finalizer

Local A/B on a 183 KB gzip publisher page with a live 3-slot auction
(release builds, 20 interleaved rounds): TTFB median 741 ms buffered
vs 161 ms streamed (-78%); guest wall time and wasm heap unchanged.
Address the deep-review findings on the streaming cutover:

- Cap cumulative decoded bytes in BodyStreamDecoder against
  publisher.max_buffered_body_bytes: the chunk source only bounds raw
  compressed bytes, so a decompression bomb could expand ~1000x past it
  and push unbounded decoded volume through the rewrite pipeline
- Detect truncated deflate streams: write::ZlibDecoder::try_finish
  accepts truncated input silently, so the deflate arm now drives
  flate2::Decompress directly and requires Status::StreamEnd at
  finalization; trailing bytes after the end marker stay ignored.
  Add truncated-gzip and truncated-deflate regression tests
- Make BodyChunkSource::next_chunk cancellation-safe by polling the
  body in place instead of moving it out across an await; a cancelled
  pull no longer turns into a silent EOF
- Log dispatched auctions dropped uncollected (client disconnect
  mid-stream or never-polled body) via DispatchedAuctionGuard; the
  guard is created before the lazy stream so unpolled drops log too
- Share the pull+decode step between the lazy publisher body and the
  write-sink drivers (hold_step_next_chunk / passthrough_step),
  removing the unreachable!() error plumbing and the triplicated
  processor selection; document body_close_hold_loop_stream as
  groundwork for the buffered adapters' streaming cutover
- Pass identity-encoded chunks through zero-copy and finish encoders
  by consuming them instead of allocating a throwaway replacement
- Add a Fastly dispatch test asserting the publisher fallback returns
  Body::Stream without a stale Content-Length, plus a comment on why
  the publisher fetch gates streaming on capability while the asset
  path does not

Behavior note: gzip bodies with trailing garbage after the trailer now
error mid-stream; the old read-path decoder ignored them.
The buffered finalizer abandons a dispatched auction with
processor_init_error telemetry when HTML processor construction fails;
the streaming finalizer dropped the in-flight SSP responses silently.
Make publisher_response_into_streaming_response async and emit the same
abandonment before returning the construction error.
Publisher-specific bundles need diagnostics based on the modules they
actually contain. Otherwise, missing identity integrations can be hidden
by the default preset.

Refresh the comparison when auctions begin so late publisher
configuration is visible without repeating warnings.

Resolves: #886
The server-side auction stream path only emitted a summary counter
(ssp/mediator/winning/time), so an operator seeing winning=0 could not
tell whether prebid returned nothing, errored, or bid below the floor.

Serialize the full provider_responses (and mediator_response) into the
ts-debug HTML comment so the SSAT surfaces the same prebid server
response detail available from the /auction endpoint. Bid creative and
metadata are attacker/partner-influenced, so neutralize the '-->' and
'--!>' comment terminators before embedding to keep the dump inside the
comment and out of the live DOM.
When Prebid Server returns a non-2xx status, the parser returned a bare
AuctionResponse::error with empty metadata — indistinguishable in the
ts-debug dump from a transport, parse, or timeout failure, all of which
tag error_type. An operator seeing status=error with metadata={} had no
way to know the upstream HTTP code without log access.

Attach error_type=http_status, the status code, and a 512-byte body
snippet to the error response metadata so the auction dump shows exactly
why prebid errored (e.g. a 4xx from a PBS rejecting the request).
Address PR review of the SSAT debug-dump change:

- The PBS non-2xx response body was attached to AuctionResponse.metadata,
  which ProviderSummary clones verbatim into ext.orchestrator.provider_details
  on the public /auction response — violating the documented invariant in
  auction/orchestrator.rs. Drop the body from metadata, keep only the numeric
  status, and log the snippet server-side at warn.
- Register ERROR_TYPE_HTTP_STATUS and match it in provider_status so PBS HTTP
  errors get their own telemetry bucket instead of the transport_error
  fallback.
- Bound the ts-debug dump: compact serialization capped at 256 KiB, and skip
  the mediator_response line when no mediator ran.
- Correct the auction_html_comment and prepend_auction_debug_comment docs to
  state the comment now embeds raw SSP creative markup (never enable in prod).
- Keep the targeted two-replace terminator neutralisation: a single
  replace("--", ...) re-forms -->/--!> at odd dash-run junctions and is not
  equivalent. Add a table-driven test over the comment-terminator vectors.
- Hoist test-local imports to module scope per CLAUDE.md.
…debug' into feat/ssat-write-prebid-response-debug
- Drop render_adm param: always include adm when creative present
- Gate GAM-bypass on per-bid debug_bid instead of a global window.tsjs flag
  (removes SPA-staleness edge case, TsjsApi change, and whole flag-emit task)
- Correct fallback scope: cache fallback only when adm absent; render failure
  after adm is supplied is not detectable
- Qualify sandbox claim: TS guarantees script-context escaping; bridge frame
  isolation depends on the Prebid Universal Creative
- Reconcile with existing ad_init.test.ts coverage (rename, no duplicates)
- Fix test assertion messages to expect("should ..."); list exact clippy gates
- Task 3: assert observable DOM (GAM iframe src) instead of spying on the
  module-private injectAdmIntoSlot
- Use existing make_bid helper + set .creative (make_test_bid_with_creative
  does not exist on this branch)
- Hostile-adm regression test covers both U+2028 and U+2029
- Add 'cd docs && npm run format' to verification
build_bid_map now always inserts the winning creative as adm so the pbRender
bridge can render it locally (no PBS Cache round trip); the verbose debug_bid
blob and the GAM-bypass gate stay behind inject_adm_for_testing. Rename the
param include_adm -> include_debug_bid and thread it through write_bids_to_state.
Reconcile the by-default test to the new behavior, drop the now-redundant
debug-only-adm test, and pin script-context escaping for a hostile adm
(</script> + U+2028/U+2029).
The direct GAM-replace path (injectAdmIntoSlot) now fires only when the bid
carries debug_bid, which is present only under inject_adm_for_testing. In
production the always-present adm is rendered by the pbRender bridge and GAM
stays in the loop. Add observable-DOM tests (GAM iframe src unchanged without
debug_bid, rewritten with it), strengthen the bridge test to prove inline adm
is preferred even when cache coords are present, and rename debug-adm
terminology to inline adm.
Resolve five correctness and resource-safety findings from the PR #867
review of the end-to-end Fastly publisher streaming path.

- Drive the deflate decoder to StreamEnd at finalization so a valid
  stream that exactly fills the internal output buffer is no longer
  rejected as truncated; the inflater is also drained after all input is
  consumed within a chunk.
- Decode concatenated (multi-member) gzip bodies via MultiGzDecoder on
  both the streaming decoder and the buffered read pipeline so adapters
  agree.
- Enforce the decoded-body cap during decompression through a bounded
  sink shared by the gzip and brotli codecs, so a compression bomb errors
  before its expanded bytes are buffered instead of after a full chunk
  expands; the deflate codec charges each produced block as it is emitted.
- Drop the body of bodiless responses (HEAD, 204, 205, 304) in both the
  streaming and buffered finalizer Buffered arms, and add RESET_CONTENT
  to response_carries_body, so a buffered-unmodified stream body is never
  streamed to the client for a response that must be bodiless.
- Keep the dispatched-auction guard armed across the collection await and
  disarm it only once collection reaches a terminal result, so a body
  dropped while collection is pending still logs the discarded SSP work.

Add regression tests for the deflate output-buffer boundary, multi-member
gzip, bodiless buffered stream bodies, and the auction guard sentinel.
Resolve the PR review by making transport-timeout canonicalization a
platform capability and hardening auction backend-name correlation.

- Move quantization behind PlatformBackend::canonicalize_transport_timeout_ms.
  Fastly floors budget-derived timeouts to a 250ms quantum with a bounded
  sub-quantum ladder [200,150,100,50]; other adapters use the exact remaining
  budget so bidder deadlines (Prebid tmax, APS timeout) are not shortened
  where no connection-pooling benefit exists.
- Bound sub-quantum backend-name cardinality: exact 1-249ms values no longer
  pass through, capping the budget-derived names a single origin can mint
  toward the per-service dynamic backend limit.
- Add a provider discriminator to PlatformBackendSpec, folded into every
  adapter's backend name, so two providers sharing one origin no longer
  collide on the response-correlation key. Reject a duplicate
  backend_to_provider insertion with an attributed launch failure instead of
  silently overwriting and misattributing a response.
- Make the orchestrator call-site tests deterministic: record predicted and
  registered transport timeouts separately and assert exact equality via a
  controllable platform backend, and enumerate the sub-quantum ladder to
  assert a bounded name cardinality.
- Correct the timeout-semantics comments that overstated absolute-deadline
  enforcement; the Fastly connect/first-byte/between-bytes timeouts bound
  connection, first-byte, and inactivity, not total response time. A true
  absolute deadline carried through the platform HTTP API remains follow-up
  work (#849).
Prebid non-2xx responses were reduced to bare provider errors, making intermittent failures difficult to diagnose. Surface safe HTTP metadata and bounded debug details while correlating server logs with the auction ID.
A configured bidder with no inline params and no matching override
expanded to `"bidder": {}`, which PBS rejects. After applying overrides,
drop fabricated empty bidders, preserve an explicitly supplied empty
object so genuine misconfiguration stays visible, and fall back to the
stored-request path when no eligible bidders remain.
ChristianPavilonis and others added 10 commits July 17, 2026 10:51
# Conflicts:
#	CHANGELOG.md
#	crates/trusted-server-core/src/auction/formats.rs
#	crates/trusted-server-core/src/auction/orchestrator.rs
#	crates/trusted-server-core/src/integrations/aps.rs
On the SSAT proxy path the browser calls /auction against the
trusted-server edge domain (e.g. ts.example.com), which was leaking
into ext.trusted_server.request_host on the outbound Prebid Server
request. That field must track the publisher's own domain instead,
matching site.domain/publisher.domain and what PBS's trusted_server
verification module expects.
# Conflicts:
#	crates/trusted-server-core/src/ec/prebid_eids.rs
#	crates/trusted-server-core/src/integrations/prebid.rs
#	crates/trusted-server-core/src/publisher.rs
#	crates/trusted-server-core/src/settings.rs
#	crates/trusted-server-js/lib/src/integrations/prebid/index.ts
#	crates/trusted-server-js/lib/test/build-prebid-external.test.mjs
#	crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts
#	docs/guide/integrations/prebid.md
aram356 and others added 19 commits July 20, 2026 15:45
build_auction_request derived publisher.domain, site.domain, and the page
URL host from the incoming request Host header. On the SSAT proxy path that
header is the trusted-server edge host (e.g. the staging domain), which then
leaked into the outbound OpenRTB bid request and, through it, into injected
creatives and the IAS brand-safety pixel.

Source these fields from settings.publisher.domain instead, matching what
convert_tsjs_to_auction_request already does on the /auction endpoint path.

Closes #936
…n requests) into rc/july

# Conflicts:
#	crates/trusted-server-core/src/publisher.rs
hb_adid is not unique per bid: absent PBS Cache it falls back to a
creative id a bidder may reuse across slots (observed: three IX bids
sharing one id). The bridge matched the first bid whose hb_adid equalled
the requested adId, then rejected on the slot-ownership guard, so every
slot but the first rendered blank.

Resolve the bid by the requesting slot and verify its hb_adid matches
the request, so each slot renders its own creative regardless of
duplicate ids. The adId check still blocks a slot A iframe from pulling
slot B's creative and beacons.
The pbRender bridge sized every inline response from the first configured
slot format, while the winning creative's own width/height were emitted
only inside the testing-only debug_bid. A multi-size slot whose winner is
not the first format therefore rendered at the wrong size (clipping or
whitespace).

Emit w/h in the normal bid map and AuctionBidData, and prefer them in the
inline bridge response, falling back to the first slot format only when
absent.
The inline render path forwarded adm without resolving the auction-price
macro. URL rewriting then serialized query pairs, encoding the literal
${AUCTION_PRICE} into %24%7BAUCTION_PRICE%7D inside the signed proxy/click
URL — so trackers received an encoded macro rather than the clearing
price, and signing locked the wrong value.

Add expand_auction_price_macro and call it from build_bid_map before
sanitize_creative_html and rewrite_inline_creative_html, using the exact
winning CPM. Only the clear-price token is expanded; the encrypted
${AUCTION_PRICE:B64} variant is left for the DSP.
rewrite_inline_creative_html hard-coded https://{publisher.domain},
discarding the incoming scheme, subdomain, and port. publisher.domain
cannot carry a port, deployments may serve a subdomain, and Axum/Viceroy
dev runs over HTTP with a port — so inline proxy/click URLs resolved
against the wrong origin with no render-time fallback.

Thread the trusted request origin (scheme://host, host including any
port) through write_bids_to_state and build_bid_map into
rewrite_inline_creative_html. Initial navigation derives it from the
buffered request host/scheme; SPA page-bids from RequestInfo. Falls back
to the configured publisher domain only when the origin is unknown.

Covers HTTP localhost with a port, a request subdomain differing from
publisher.domain, and a non-default HTTPS port.
extractCachedAdm reduced the cached bid to its adm string, so the cache
fallback sized every render from the first slot format and left price
macros unresolved. Replace it with parseCachedBid, which retains the
cached creative dimensions (w/h or width/height) and clearing price.

The fallback now sizes from the cached dimensions (slot format only when
absent) and expands ${AUCTION_PRICE} from the cached price before
responding. Raw-markup bodies stay supported as the adm-only variant.

Firing a cached win-notification URL is deferred: it is a billing side
effect and the exact cache field/dedup contract needs a real PBS Cache
payload to verify before emitting.
The design named rewrite_creative_html and omitted the render-metadata
requirements the code now enforces; the plan's tasks were unchecked and
predated the shipped divergences. Add an "Implementation reconciliation"
section to the design (inline rewriter, request-origin URLs, w/h and
${AUCTION_PRICE} render metadata, structured cache decode) and update the
components table; mark the plan superseded-but-completed pointing at it.
Threading the request origin pushed the private collect_stream_auction
helper to 8 arguments. Its arguments mirror the AuctionCollectCtx fields
the caller destructures, so a parameter struct would only duplicate that
context; suppress the lint instead.
Missing OpenRTB w/h parse to 0, and build_bid_map emitted them as w:0/h:0.
The bridge nullish-coalesces (matchedBid.w ?? fallback), so 0 was kept
rather than falling back to the slot format — sizing the frame to 0.
Omit a non-positive dimension server-side, and treat a zero cached
dimension as absent in parseCachedBid, so the bridge falls back.
The cache-fallback concurrency guard keyed on adId (hb_adid), which is not
unique per bid — two distinct slots sharing an hb_adid would have the
second's render cross-blocked. Not reachable today (a duplicate hb_adid
means no cache coordinates, so the cache path never runs), but the guard
relied on that invariant silently. Key the in-flight set on slotId|adId so
it dedups a genuine same-render race without colliding across slots.
…pp Router

On a Next.js App Router publisher, Trusted Server's injection ran against
the SSR HTML before React hydrated, so the mutated DOM disagreed with the
RSC/flight payload and React threw #418, recovering by re-rendering the
affected subtrees (visible flashes/reflow). Live A/B against prod (gated by
the ts-tester cookie) isolated the actual trigger: pure publisher throws 0
#418, TS activation introduces it, and the count tracks whether adInit
processes ad slots.

Three hydration-safety changes, most-to-least load-bearing:

- Defer the initial adInit() call until after hydration. adInit defines GPT
  slots on the publisher's `-container` wrappers, mutating those ad-slot
  subtrees; the </body> bids bootstrap called it synchronously at parse time,
  landing the mutation inside React's hydration window. It now runs after
  window `load` plus a double requestAnimationFrame (run-once, no retry
  timer). This is the change that drives #418 to 0 while ads still render.

- Append the tsjs head bundle at the end of <head> instead of prepending it.
  React hydrates <head> children in order; a prepended <script> shifts the
  origin's first child (`<meta>`) and mismatches. Appending keeps the origin
  head children in their original positions. Golden tests updated to assert
  head-end injection.

- Rewrite third-party integration hosts inside the streamed RSC flight to
  match the first-party `/integrations/...` paths already applied to the DOM.
  Previously the DOM was rewritten but the flight still carried the original
  host, so the two disagreed on those tags. Runs per <script> chunk, gated on
  __next_f/__next_s, preserving streaming; the nextjs integration stays
  disabled.

Verified end to end through the dev proxy against the live publisher: #418
goes from 1-2 to 0 across runs with TS still defining its 3 container slots
and ads rendering.

Refs #938
Resolve prebid.rs conflict from #934: rc/july already uses the publisher
domain for ext.trusted_server.request_host; keep main's explanatory comment
on the canonical https scheme.
Brings the three hydration-safety changes: defer the initial adInit() until
after hydration, append the tsjs head bundle at the end of <head>, and rewrite
integration hosts in the streamed RSC flight. Merged cleanly with no conflicts.
Integrates the SSAT inline-creative work with rc/july's APS renderer and
render-trace additions. The two branches evolved build_bid_map in
different directions, so the merge unions both rather than taking a side:

- build_bid_map now takes (settings, request_origin, include_debug_bid,
  auction_id). rc/july's trace fields (hb_auction_id, hb_crid,
  hb_adm_hash) and renderer_bid_id-aware hb_adid are kept alongside the
  inline-creative pipeline: ${AUCTION_PRICE} expansion, sanitize, and
  rewrite_inline_creative_html against the request origin, plus winning
  w/h (omitted when zero so the bridge falls back).
- rc/july's raw `adm` insert is replaced by that sanitize/rewrite
  pipeline, so partner creative is no longer injected unsanitized.
- The request origin is threaded through the restructured stream path
  (AuctionHoldCollectRefs, collect_non_html_auction, collect_stream_auction).
- The pbRender bridge keeps rc/july's APS renderer + Prebid-APS paths and
  recordBridgeRender, with the slot-scoped bid resolution, winning-bid
  dimensions, parseCachedBid decode, and slotId|adId in-flight guard.
On a Next.js App Router publisher, `adInit()` defines GPT slots on the
publisher's `-container` wrappers, mutating those ad-slot subtrees. The
`</body>` bids bootstrap called it synchronously at parse time, landing that
mutation inside React's hydration window, so React threw #418 and re-rendered
the affected subtrees (visible flashes/reflow).

A live A/B — toggling TS on the same page via the tester cookie — isolated the
trigger: the pure publisher throws 0 #418, TS activation introduces it, and the
count tracks whether adInit processes ad slots (not the injection position).

adInit is now deferred to after hydration: gated on window `load`, then a double
`requestAnimationFrame`. Run-once, no retry timer.

Deferring opens a window in which an SPA navigation can commit a new route (and
run its own adInit via the SPA auction hook) before the callback fires, so the
callback captures the route it was scheduled for and no-ops when the route has
changed — otherwise it would re-run adInit against the newer route's live
slots/bids, destroying and redefining that route's TS slots and refreshing it
twice. Browser globals are window-qualified so a page-level lexical binding
cannot shadow them.

Verified end to end through the dev proxy against a live App Router publisher:
#418 goes from 1-2 to 0 with TS still defining its container slots and ads
rendering.

Refs #938
…uly"

This reverts commit eaa8fed, reversing
changes made to 41345fc.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants