Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,363 @@
# Server-Side Ad Templates Design

*April 2026*
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — Italics convention around the date.

Other specs in docs/superpowers/specs/ use a header like _Author · YYYY-MM-DD_ or omit the date entirely. Pick a convention so spec metadata is grep-able.


---

## 1. Problem Statement

Today's display ad pipeline on most publisher sites is structurally sequential
and browser-bound:

1. Page HTML arrives at browser
2. Prebid.js (~300KB) downloads and parses
3. Smart Slots SDK scans the DOM to discover ad placements
4. `addAdUnits()` registers slot definitions
5. Prebid auction fires from the browser (~80–150ms RTT to SSPs)
6. Bids return (~1,000–1,500ms window)
7. GPT `setTargeting()` + `refresh()` fires
8. GAM creative renders

**Total time to ad visible: ~3,100ms.**

The browser is the slowest possible place to run an auction. It must first download and parse
multiple SDKs, scan the DOM to discover what ad slots exist, and then fire SSP requests over
a consumer internet connection with high and variable latency.

Trusted Server sits at the Fastly edge — milliseconds from the user, with data-center-to-data-center
RTT to Prebid Server (~20–30ms vs ~80–150ms from a browser). The server knows, from the request
URL alone, exactly which ad slots are available on any given page. There is no reason to wait for
the browser.

---

## 2. Goal

Enable Trusted Server to:

1. Match an incoming page request URL against a set of pre-configured slot templates
2. Immediately fire the full server-side auction (all providers: PBS, APS, future wrappers) in
parallel with the origin HTML fetch — before the browser receives a single byte
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — "Auction in parallel with origin fetch" is incompatible with the current publisher path.

publisher.rs:527 calls req.send(&backend_name) synchronously inside a non-async handle_publisher_request. Real concurrency requires send_async + a select (or join) between the orchestrator future and the pending origin request, and propagating async up through publisher handling.

Spec §3 lists "no orchestrator changes" and §7 lists this as a one-line "Request handler modification" — the actual lift is a meaningful restructuring of the publisher request path. List it explicitly in §7 as a multi-step migration including the async propagation.

3. Inject GPT slot definitions into `<head>` so the client can define slots without any SDK
4. Return pre-collected winning bids to the browser's lightweight `/auction` POST before the
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/auction POST role after this ships

This goal says "return pre-collected winning bids to the browser's lightweight /auction POST." Sections 4.4 and 4.5 say "no /auction POST needed — bids are already in the page." These are contradictory. The JS client currently always POSTs to /auction. Definitive answer needed: does __ts_bids replace the POST entirely, or does /auction remain as a fallback for URLs that don't match any slot template?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. _ts_bids replaces the POST for URLs that match templates. /auction also stays as the fallback path for URLs without matching templates (preserves backward compatability for non-templated pages and for publishers who haven't adopted creative-opportunities.toml yet)

browser would have even finished parsing Prebid.js
5. Eliminate Prebid.js from the client entirely
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question — Who fires nurl / burl and what replaces Prebid analytics adapters?

Eliminating Prebid.js drops every Prebid analytics adapter publishers may depend on for revenue reporting. Where does win/billing notification (nurl/burl in auction/types.rs:149-152) get fired in the new flow — server-side from the edge after GAM impression, browser-side beacon, or PBS? And how do publishers reconstruct the analytics surface they relied on?

Either commit to a replacement (server beacon at the edge, separate tsjs analytics module) or note this in §3 Non-Goals.


**Target time to ad visible: ~1,200ms. Net saving: ~2,000ms.**

---

## 3. Non-Goals

- Eliminating client-side GPT / Google Ad Manager — GAM remains in the rendering pipeline
for Phase 1. The GAM call (`securepubads.g.doubleclick.net`) moves server-side in a future phase.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 seedling — Server-side GAM is the real win.

Phase 1 keeps GAM in the browser, capping the savings ceiling. Briefly outline the Phase 2 server-side-GAM approach (securepubads proxy, creative bundling) so reviewers can evaluate whether the Phase 1 architecture is shape-compatible with the eventual end state.

- Dynamic slot discovery (reading the DOM) — this design commits to pre-defined, URL-matched
slot templates. Smart Slots' dynamic injection behavior is replaced by server knowledge.
- Changing the `AuctionOrchestrator` internally — the orchestrator already handles parallel
provider fan-out. This design adds a new trigger point, not new auction logic.

---

## 4. Architecture

### 4.1 New File: `creative-opportunities.toml`

A new config file at the repo root, alongside `trusted-server.toml`. It holds all slot templates:
page pattern matching rules, ad formats, floor prices, and GAM targeting key-values. Bidder-level
params (placement IDs, account IDs) live in Prebid Server stored requests, keyed by slot ID — not
in this file.

Loaded at build time via `include_str!()`, parsed into `Vec<CreativeOpportunitySlot>` at startup.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

creative-opportunities.toml: compile-time include_str!() or Fastly KV at runtime?

include_str!() bakes the file into the WASM binary — every slot config change requires a full rebuild + Fastly deploy (~15 min). The phrase "ad ops can edit this file independently" (line 71) does not hold under that model. The RuntimeServices abstraction already exposes services.kv_store(). Which model is intended: compile-time (fast reads, deploy required per change) or runtime KV (live edits, no rebuild)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fastly deploy's don't take 15 minutes first of all. That said, lets reduce dependence on KV store where we can for timing and publisher cost perspective and keep it simple in the WASM binary with include_str!()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 seedling — Plan for Edge Dictionary / KV-backed config swap.

Even if Phase 1 ships with include_str!(), sketch the migration path to dictionary/KV-backed config (read at request time, hot-reload, validation contract). Prevents painting yourselves into a corner when ad ops actually does want to make changes between deploys.

Ad ops can edit this file independently of server configuration.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchinclude_str!() contradicts "ad ops can edit independently."

include_str!() bakes the file into the WASM binary at compile time. Every change requires cargo build --target wasm32-wasip1 --release + fastly compute publish. That is the opposite of "ad ops can edit independently."

Fix: Either drop the claim and own that ad-ops changes are deploys, or move to a Fastly Edge Dictionary / KV-backed config with a documented hot-reload story. Pick one — the spec contradicts itself today.


`floor_price` is the publisher-owned hard floor per slot — the source of truth for the minimum
acceptable bid price, enforced at the edge before bids reach the ad server. Any bid below the
floor is discarded at the orchestrator level before it enters `__ts_bids`. SSPs may apply their
own dynamic floors independently within their platforms; this floor is the publisher's baseline
that supersedes all other floor logic by virtue of being enforced earliest in the pipeline.

**Schema:**

```toml
[[slot]]
id = "atf_sidebar_ad"
page_patterns = ["/20*/"]
formats = [{ width = 300, height = 250 }]
floor_price = 0.50

[slot.targeting]
pos = "atf"
zone = "atfSidebar"

[[slot]]
id = "below-content-ad"
page_patterns = ["/20*/"]
formats = [{ width = 300, height = 250 }, { width = 728, height = 90 }]
floor_price = 0.25

[slot.targeting]
pos = "btf"
zone = "belowContent"

[[slot]]
id = "ad-homepage-0"
page_patterns = ["/", "/index.html"]
formats = [{ width = 970, height = 250 }, { width = 728, height = 90 }]
floor_price = 1.00

[slot.targeting]
pos = "atf"
zone = "homepage"
slot_index = "0"
```

**Rust type:**

```rust
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CreativeOpportunitySlot {
pub id: String,
pub page_patterns: Vec<String>,
pub formats: Vec<AdFormat>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Schema mismatch: formats = [{ width, height }] won't deserialize into AdFormat.

auction/types.rs:47-52 defines AdFormat { media_type: MediaType, width: u32, height: u32 }media_type is required and MediaType has no default. The TOML examples and Rust struct in §4.1 omit media_type.

Fix: Either change the schema/struct to include media_type (or default to Banner via #[serde(default)]), or define a separate CreativeOpportunityFormat plus an explicit converter to AdFormat.

pub floor_price: Option<f64>,
pub targeting: HashMap<String, serde_json::Value>,
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — Add validation for slot IDs at startup.

Define the legal alphabet (e.g., ^[a-zA-Z0-9_-]{1,64}$) and validate at TOML parse time. Prevents both XSS and ambiguous cross-references with PBS stored-request keys.

Surface invalid IDs as startup failures per CLAUDE.md ("Invalid enabled integrations/providers must not be silently logged-and-disabled").

```

### 4.2 URL Pattern Matching

At request time, TS matches the request path against each slot's `page_patterns`. Patterns are
glob-style strings:

- `/20*/` — matches all date-prefixed article paths (e.g., `/2024/01/my-article/`)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Glob example is wrong against standard glob libraries.

/20*/ is described as matching /2024/01/my-article/. Under common glob semantics (globset, wax, shell), * does not cross /, so /20*/ matches /2024/ but not /2024/01/my-article/.

Fix: Pin the pattern semantics — use **/ for cross-segment, or adopt URLPattern, or document the dialect explicitly — and fix the example. This affects whether the auction even fires on article pages.

- `/` — matches the homepage exactly
- `/index.html` — exact match

Multiple slots can match a single URL. All matching slots are collected and fed into a single
auction as separate impressions. Pattern matching is purely in-memory against the pre-parsed
config — sub-millisecond.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — Pattern matching cost grows with inventory.

"Sub-millisecond" holds at small N, but per-request matching is O(slots × patterns).

Suggestion: Use globset::GlobSetBuilder (single combined matcher) or a matchit::Router with normalized prefixes. Cheap to add now; expensive to retrofit when there are thousands of slots.


### 4.3 Auction Trigger

When slots are matched, TS immediately calls `AuctionOrchestrator::run_auction()` with the
matched slots converted to `AdSlot` objects. This happens at request receipt time — in parallel
with the origin fetch.

The orchestrator's existing behaviour is unchanged:
- All providers (PBS, APS, any configured wrappers) are dispatched simultaneously
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APS in Phase 1: included or excluded, and param source

APS bidder params currently flow from the browser's AdRequest POST — there is no client for a server-triggered auction. creative-opportunities.toml has no [slot.providers.aps] section and APS does not use PBS stored requests. Is APS in scope for Phase 1? If yes, where do per-slot APS params come from?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, APS in scope. Lets add them as [slot.provider.aps] in the toml. They should complete with all the other demand unless you see something i don't?

- Per-provider timeout budgets are enforced from the remaining auction deadline
- Floor price filtering, bid unification, and winning bid selection are applied as today
- PBS resolves bidder params from its stored requests by slot ID — no bidder params travel
through TS or the browser
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Consent / GDPR flow is missing for the new auction trigger.

Today's /auction handler in auction/endpoints.rs:66-77 builds a ConsentContext (TCF, GPP, US Privacy, GPC) before running an auction. The spec triggers the auction at request receipt — before the page's consent UI (Didomi) can update preferences — and never describes how consent is propagated, suppressed, or reconciled.

Required additions:

  • Explicit consent-gating decision (when auction is suppressed entirely)
  • Handling of mid-page consent revocation when bids are already in <head>
  • EC-ID / cookie behavior at the new trigger
  • Treatment in §8 Edge Cases as a first-class "no consent" case

Without this the design is a regulatory regression.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — APS bidder isn't keyed by PBS stored requests.

The spec says "PBS resolves bidder params from its stored requests by slot ID — no bidder params travel through TS." But APS is a separate provider in this codebase (integrations/aps.rs) dispatched from TS, not via PBS — APS slot IDs and parameters live in TS configuration.

Fix: Specify how APS slot IDs are resolved from creative-opportunities.toml (and how that maps to APS slotID/slotName/mediaType entries). Otherwise APS silently no-bids on every slot.


**On NextJS 14 (buffered mode):** TS must buffer the full origin response before forwarding.
This gives the auction the entire origin response time (~150–400ms typical) to run before
any HTML is forwarded. In practice, bids are often collected before origin even responds.

**On NextJS 16 (streaming mode):** TS streams HTML chunks to the browser immediately. The
auction runs in parallel. Bid injection into `<head>` must complete before the `</head>` tag
is forwarded. If the auction has not returned by the time `</head>` is encountered, TS waits
up to the remaining auction budget, then flushes with whatever bids have arrived (partial
results) or no targeting if timed out. Content after `</head>` is never held.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchIntegrationHeadInjector is synchronous; spec assumes it can wait on async results.

The current trait at integrations/registry.rs:392-397 is fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec<String> and runs inside a lol_html element callback (html_processor.rs:236-267) — fully sync, no .await.

Pick one approach and document it:

  1. Run the auction before building the HtmlProcessorConfig and pass pre-resolved bids in (simpler — but means buffering origin until auction completes), or
  2. Introduce a new async/late-injection mechanism plus a chunk-holding state machine in the streaming pipeline (which today has no "hold and wait" primitive — see html_processor.rs:46-128).

Also note lol_html element!("head", ...) fires on the opening tag; "before </head>" requires el.on_end_tag(). Spec must call this out and pick an approach.


### 4.4 Head Injection

TS injects two separate `<script>` blocks into `<head>`:

**First injection — `window.__ts_ad_slots`** — emitted immediately from config, before the
origin fetch even returns. No auction needed. Available to GPT the moment the browser parses `<head>`:

```json
[
{
"id": "atf_sidebar_ad",
"formats": [[300, 250]],
"targeting": { "pos": "atf", "zone": "atfSidebar" }
},
{
"id": "below-content-ad",
"formats": [[300, 250], [728, 90]],
"targeting": { "pos": "btf", "zone": "belowContent" }
}
]
```

**Second injection — `window.__ts_bids`** — injected once auction results are available, just
before `</head>`. Keyed by slot ID. The client reads this directly — no `/auction` POST needed:

```json
{
"atf_sidebar_ad": { "hb_pb": "2.50", "hb_bidder": "kargo", "hb_adid": "abc123" },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hb_pb price bucketing: granularity table and full key set

hb_pb is a Prebid price bucket string (discretized CPM bin), not a raw price. No bucketing logic exists in the codebase — this is a net-new component. Which granularity table: low / medium / high / auto / dense / custom? Is it per-publisher configurable? And what is the complete __ts_bids key set — just hb_pb, hb_bidder, hb_adid, or also hb_size, hb_deal, hb_format?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets make "dense" the default with a publisher override on the granularity setting. Keep the key set as is to match GAM standard.

"below-content-ad": { "hb_pb": "1.00", "hb_bidder": "appnexus", "hb_adid": "def456" }
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Untrusted bid strings injected as inline JSON without an escape contract.

hb_bidder / hb_adid come from external SSPs and end up serialized into an inline <script>. The existing prebid head injector (integrations/prebid.rs:386) already does .replace("</", "<\\/") for a reason.

Required: Spec must specify the escape contract for __ts_bids: at minimum </, U+2028, U+2029; ideally also reject control chars and validate that slot IDs / bidder names are [A-Za-z0-9_-]+. Without this the change is an XSS vector that didn't exist on the /auction POST path.

```
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Per-request bid injection without a cache contract is unsafe.

__ts_bids is per-request, per-user data injected into a publisher HTML body that flows through Fastly. The spec never states the cache contract. If the response is cacheable (default for HTML on Compute unless explicitly suppressed), bids leak across users.

Fix: Add a section that mandates Cache-Control: private, no-store (or equivalent surrogate-control + cache-key segmentation) on any response with __ts_bids injected, and explain what happens when the publisher origin sends conflicting cache headers. This is a P0 omission — the entire design rests on it.


If a slot receives no bid above floor, its entry is omitted from `__ts_bids`. The client
treats absence as no pre-set targeting for that slot — GPT fires without bid targeting,
GAM falls back to its standard auction.

### 4.5 Client Residual

Prebid.js is eliminated. The client-side ad bootstrap is replaced by a small inline script
(~20 lines) that reads `__ts_ad_slots` and `__ts_bids` and drives GPT directly:

```javascript
window.__tsAdInit = function() {
var slots = window.__ts_ad_slots || [];
var bids = window.__ts_bids || {};
googletag.cmd.push(function() {
slots.forEach(function(slot) {
var gptSlot = googletag.defineSlot(slot.id, slot.formats, slot.id)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slot.id as GPT adUnitPath

GPT's defineSlot(adUnitPath, size, optDiv) first argument must be the full GAM network path (e.g., /21765378893/homepage-banner). Using a short key like atf_sidebar_ad silently produces a non-functional slot — GAM will not serve to it. Is slot.id the full GAM path, or does creative-opportunities.toml need a separate field (e.g., gam_unit_path)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're correct. We should add an optional gam_unit_path field per slot, plus a top-level gam_network_id config. Default behavior: gam_unit_path = "/{network_id}/{slot.id}". Publishers can override per-slot for non-standard paths.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactorgoogletag.defineSlot(slot.id, ..., slot.id) is wrong.

defineSlot(adUnitPath, sizes, divId)adUnitPath is the GAM ad-unit path (e.g., /12345/network/article-atf) and divId is the page DOM container ID. Reusing slot.id for both will break GAM trafficking.

Fix: Add separate gam_ad_unit_path and div_id fields to the slot config.

.addService(googletag.pubads());
// Apply static targeting from config
Object.entries(slot.targeting).forEach(function([k, v]) {
gptSlot.setTargeting(k, v);
});
// Apply pre-won bid targeting if available
var bidTargeting = bids[slot.id] || {};
Object.entries(bidTargeting).forEach(function([k, v]) {
gptSlot.setTargeting(k, v);
});
});
googletag.pubads().enableSingleRequest();
googletag.enableServices();
googletag.pubads().refresh();
});
};
```

This script is part of the `tsjs-gpt` integration bundle, injected by TS into every matching
page response alongside the existing GPT integration.

---

## 5. Request-Time Sequence

```
t=0ms GET ts.publisher.com/article arrives at Fastly edge

t=1ms URL matched against creative-opportunities.toml
Slots matched: [atf_sidebar_ad, below-content-ad, section_ad]

t=2ms AuctionOrchestrator.run_auction() called
PBS + APS dispatched in parallel
Edge→PBS RTT: ~20–30ms

t=2ms Origin fetch dispatched in parallel

t=150ms Origin HTML arrives at edge (NextJS 14: buffered)

t=502ms Auction timeout fires (500ms budget)
Winning bids collected

t=502ms <head> injection assembled:
- window.__ts_ad_slots (from config, available at t=1ms)
- window.__ts_bids (from auction results)

t=502ms HTML forwarded to browser with injected <head>

t=652ms HTML arrives at browser (150ms network)
window.__ts_ad_slots and window.__ts_bids already in <head>
tsjs bundle tag in <head> (~30KB)

t=682ms tsjs downloads + executes (30ms, edge-served CDN)
__tsAdInit() reads __ts_ad_slots + __ts_bids directly
No /auction POST needed — bids are already in the page

t=702ms googletag.pubads().refresh() fires

t=822ms GET /gampad/ads

t=922ms Creative fetch

t=1222ms Creative sub-resources + paint

AD VISIBLE ~1200ms
```

---

## 6. Performance Summary

| Stage | Client-side today | With TS templates | Saving |
|---|---|---|---|
| Script load chain | ~700ms | ~40ms (tsjs only) | -660ms |
| Script parse/JIT | ~280ms | ~10ms | -270ms |
| Sequential SDK hops | ~200ms | 0 | -200ms |
| Auction window | ~1,500ms | ~500ms | -1,000ms |
| GAM + creative | ~570ms | ~570ms | — |
| **Total** | **~3,250ms** | **~1,200ms** | **~2,000ms** |
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — Latency numbers are modeled, not measured.

The "1,200ms" is built from optimistic point estimates. Real SSP p99 frequently exceeds 800ms; creatives with multiple sub-resources push past 1,500ms. Tag the table as "modeled with budget X" or cite RUM data; otherwise readers will use it as a commitment.

Also acknowledge the buffered-mode TTFB regression: today's buffered NextJS 14 path can stream at ~150ms; §5 has TTFB at t=502ms.


Auction RTT improvement: browser fires SSP requests at 80–150ms RTT; edge fires at 20–30ms.
Auction timeout can drop from 1,000–1,500ms to 500ms while still collecting more complete
results, because edge→PBS latency is ~5–7x lower.

---

## 7. Implementation Scope

### New

- `creative-opportunities.toml` — slot template config file
- `crates/trusted-server-core/src/creative_opportunities.rs` — config types, TOML parsing,
URL pattern matching, slot-to-`AdSlot` conversion
- `build.rs` update — `include_str!()` for `creative-opportunities.toml`
- Request handler modification — match slots at request receipt, trigger orchestrator immediately,
hold result for head injection
- `tsjs-gpt` integration update — `__tsAdInit` bootstrap replaces Prebid.js ad unit setup
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — No telemetry contract for the new trigger.

Today's handle_auction logs "Auction completed: N providers, M winning bids, Tms total" (auction/endpoints.rs:107-112). The new trigger needs equivalent observability: per-trigger timing, "would-have-bid-but-timed-out" counters, head-injection success/skip, the cache-control branch taken, etc.

Add a Telemetry section to §7.


### Modified

- `crates/trusted-server-core/src/integrations/prebid.rs` head injector — emit
`window.__ts_ad_slots` from matched slots
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor__ts_ad_slots belongs in a dedicated head injector, not stuffed into prebid.rs.

The §7 plan modifies integrations/prebid.rs to emit __ts_ad_slots. But the spec also says "Prebid.js is eliminated." Prebid integration shouldn't own the new GPT-facing global.

Fix: Put the head injection in a new creative_opportunities integration (or extend the existing gpt integration at integrations/gpt.rs) and decouple it from prebid.

- `crates/trusted-server-core/src/html_processor.rs` — inject `window.__ts_bids` once auction
results are available, before `</head>`
- `trusted-server.toml` — add `creative_opportunities_path` config key pointing to the new file

### Unchanged

- `AuctionOrchestrator` — no internal changes; new call site only
- PBS stored request configuration — bidder params remain in PBS, keyed by slot ID
- GAM line item configuration — targeting key-values pass through unchanged

---

## 8. Edge Cases

**No slots match the URL** — auction is not fired. Head injection emits neither global. GPT
bootstrap detects empty `__ts_ad_slots` and skips initialization. Page loads normally with no
ad stack.

**Auction times out with partial results** — `__ts_bids` is populated with whatever bids arrived
before the deadline. Slots with no bid omitted. GPT fires without pre-set targeting for those slots;
GAM falls back to its own auction.

**Auction times out with zero results** — `__ts_bids` is an empty object `{}`. All slots fire
GAM without bid targeting. No revenue impact beyond the timeout scenario itself (same as today's
fallback).

**Origin is slow (NextJS 14, buffered)** — auction has more time; results more likely to be
complete. No change to streaming behavior.

**NextJS 16 streaming** — TS must flush `<head>` before `</head>` tag passes through. If auction
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streaming mode </head> boundary: Phase 1 or deferred?

The current streaming pipeline (merged in #562) buffers until end-of-document when post-processors run. "Buffer only until </head>, inject bids, then resume streaming immediately" is a new mode not currently implemented — it requires new infrastructure in the HTML processor. Is this required for Phase 1 launch, or is injecting at document end acceptable as an initial release?

not yet complete, TS waits up to `auction_timeout_ms` from the config, then flushes. Content
streaming resumes immediately after `</head>` regardless of bid state.

**`creative-opportunities.toml` missing or malformed** — startup fails with a clear error.
No silent degradation.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question — How is the new auction trigger gated when creative-opportunities.toml is empty or absent?

Spec says startup fails on missing/malformed config. Is this opt-in per environment? Per integration registration? How do publishers without server-side templates still run today's /auction flow?

Add a kill-switch / rollback flag (e.g., creative_opportunities.enabled = false) that disables all new behavior and falls back to client-side, and describe how it is exercised in production (env var, feature gate, ramp).


---

## 9. Open Questions

1. **URL pattern coverage** — does `/20*/` cover all article paths, or are there
non-date-prefixed article URLs? Publisher to confirm.
2. **PBS stored request setup** — slot IDs in `creative-opportunities.toml` must have
corresponding stored requests configured in the publisher's PBS instance before this goes live.
3. **Homepage slot count** — the example shows slots 0 and 1. Are there slots 2–5 following
the same pattern? Slot IDs and count to be confirmed with ad ops.
4. **Auction timeout for server-side trigger** — current `[integrations.prebid].timeout_ms`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auction timeout: new config key or reuse existing? (Section 9, Q4)

The spec recommends 500ms for server-triggered auctions vs the current 1,000ms client-side budget. There are currently three overlapping timeout values: [auction].timeout_ms, [integrations.prebid].timeout_ms, [integrations.aps].timeout_ms. Does the server-triggered path get a new dedicated key (e.g., [creative_opportunities].auction_timeout_ms) or does it override an existing one?

Copy link
Copy Markdown
Collaborator Author

@jevansnyc jevansnyc Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets use an optional dedicated key with fallback that makes sense:

[creative_opportunities]
"# Optional. Defaults to [auction].timeout_ms if not set.
"# Recommended: 500ms (vs client-side 1000-1500ms) due to lower edge→PBS RTT.
auction_timeout_ms = 500"

  • Optional + fallback keeps it backward compatible. Publishers not setting it inherit [auction].timeout_ms
  • Per-provider config should stay untouched. [integrations.prebid].timeout_ms and [integrations.aps].timeout_ms continue to define provider-level ceilings ... the orchestrator's existing min(remaining_global_budget, provider_timeout) enforcement applies as today.
  • Single source of truth for 4.6 streaming. A_deadline (the buffer deadline) = T₀ + creative_opportunities.auction_timeout_ms (or fallback). The same value gates both the auction and the head-boundary buffer hold ... they're the same deadline.

is 1,000ms. Recommend reducing to 500ms for server-side triggered auctions given the
lower edge→PBS RTT. Separate config key or override on the new trigger path?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Spec references the wrong timeout config key.

Spec mentions auction_timeout_ms (§8) and [integrations.prebid].timeout_ms (§9.4). The current orchestrator-wide budget lives at settings.auction.timeout_ms (auction/endpoints.rs:95); the prebid timeout key is the per-provider timeout used by PrebidAuctionProvider. These are different knobs that govern different things.

Fix: Pin which knob is authoritative for the new server-triggered auction and fix the references in §4.3, §8, and §9.4.

5. **`tsjs-gpt` bootstrap delivery** — the `__tsAdInit` script needs to fire after GPT.js
loads. Confirm injection order with the existing GPT integration head injection.
Loading