Skip to content

Add instream video auction API design spec - #1437

Open
jevansnyc wants to merge 4 commits into
mainfrom
spec/361-instream-video
Open

jevansnyc wants to merge 4 commits into
mainfrom
spec/361-instream-video

Conversation

@jevansnyc

Copy link
Copy Markdown
Collaborator

Summary

  • First take of the design spec for issue Support Video in OpenRTB requests #361: linear instream video requested directly through POST /auction, auctioned through standard and prebid-server providers that opt in, and returned as inline VAST to the caller's player.
  • Uses the implementation in Support instream video through the auction API #1325 as input for the request contract and admission rules, and restates them in the shape the rest of the auction subsystem uses (validated domain types, explicit bid media, per-provider media opt-in).
  • Opened as a draft: round 1 of agent review found blocking issues, which are fixed, and CONTRIBUTING requires another round before review.

Changes

File Change
docs/superpowers/specs/2026-10-20-361-instream-video-auction-api-design.md New spec, ending in a review log

Key decisions in the spec:

  • Video is off by default. Each provider opts in with media = ["banner", "video"], validated against the media its profile declares. A service with no video provider behaves as main.
  • Banner admission keeps every bid main admits, apart from bids that explicitly declare non-banner media.
  • VAST is returned byte for byte after a structural check (quick-xml, new dependency).
  • A configured mediator plus a video provider is a configuration error.
  • First-price requests (at: 1) are a blocking prerequisite, because the caller substitutes ${AUCTION_PRICE} with the returned price.
  • PBS video impressions clear every field outside the contract so stored impressions cannot reintroduce them.

Closes

Refs #361. Target design for #1325.

Spec

Spec: docs/superpowers/specs/2026-10-20-361-instream-video-auction-api-design.md

Test plan

  • Docs format: Prettier check passes on the spec
  • Other: review round 2 in a fresh agent session

Checklist

  • Feature PRs: spec linked above, ending in a review log of two or more agent review rounds (one round so far)
  • No secrets or credentials committed

@aram356
aram356 marked this pull request as ready for review October 25, 2026 04:56
@aram356 aram356 added this to the 202611 milestone Oct 25, 2026

@aram356 aram356 left a comment

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.

Summary

Spec-only PR for #361, reviewed against main at 7aa5c993b. The design holds together and most of its current-behavior claims check out in the code. Several contract points are wrong when checked against PBS, quick-xml, and the source, and the caller contract does not yet carry the security and macro obligations the design relies on.

23 of the 30 inline comments carry a one-click GitHub suggestion. Each was applied alone, and all together, to a scratch copy and passes prettier --check with docs/.prettierrc. Use Commit suggestion (or Add suggestion to batch) to apply them. The other comments describe the change in prose because it spans several sections; where a suggestion also needs a matching edit elsewhere, the comment says "apply manually".

Blocking

🔧 wrench

  • 🔧 The caller contract does not support allowing sanitize_creatives = true (inline at L769-L772)
  • 🔧 PBS stored-impression clearing only works on PBS Go, and imp-level fields still leak (inline at L527-L535)
  • 🔧 The macro contract covers only ${AUCTION_PRICE}, and the caller cannot resolve the rest (inline at L779-L782)
  • 🔧 Adjusted or converted PBS bids are cheap to detect, so enforce the restriction (inline at L551-L554)
  • 🔧 VAST 1.0 protocols can never pass the VAST root check (inline at L271)
  • 🔧 11.4 is written against the pre-0.38 quick-xml API (inline at L645-L649)
  • 🔧 The prolog rule admits malformed XML (inline at L640-L641)
  • 🔧 The measurement gate contradicts 18.5, and the cost bound can be stated now (inline at L665-L667)
  • 🔧 The routing diagnostics claims are false (inline at L469-L472)
  • 🔧 AdSlot.video has a second producer (inline at L411-L414)
  • 🔧 Config compatibility understates the blast radius, and the kill switch fails for video-only providers (inline at L208-L210)
  • 🔧 The invalid-traffic text and the page-bids gate description are inaccurate (inline at L814-L818)
  • 🔧 Mixed-slot classification is ambiguous (inline at L588)
  • 🔧 The link to the browser-blocked domain rewriting design is broken (inline at L1061-L1064)

❓ question

  • ❓ Open question 1 (video when ec_allowed() is false) needs an answer before approval (inline at L1074)

Non-blocking

  • 🤔 Should banner and instream be one unit at all? (inline at L132)
  • 🤔 The sound-off cross-field rule is stricter than AdCOM (inline at L294-L296)
  • ♻️ api: [] is stated three different ways (inline at L277)
  • ♻️ VideoPlacement serde does not round-trip (inline at L356)
  • ♻️ The 11.3 out-of-range rules conflict (inline at L625-L626)
  • ♻️ The Fastly-only reason and open question 3 misdescribe deadlines (inline at L1078-L1079)
  • ♻️ Caller lifecycle: expiry during wrapper load, notice transport, late responses (inline at L783-L785)
  • ♻️ Consent synchronization names nothing the caller can check (inline at L773-L776)
  • ♻️ Integrators need response examples and status codes (inline at L700)
  • ♻️ 7.5 error mapping and paths are underspecified (inline at L317)
  • ♻️ Video is invisible to operators of the experimental feature (inline at L984)
  • ♻️ 18.5 validation cannot inspect what it claims (inline at L954)
  • 🤔 Banner nurl/burl for every service (inline at L974)
  • ⛏ The first-use-case reason contradicts section 1 (inline at L125)
  • ⛏ "Resolved:" renders inside item 4 (inline at L1080-L1083)

Cross-cutting / body-level findings

  • 📝 Docs deliverables in 19.3 are incomplete. Add docs/guide/telemetry.md, docs/guide/error-reference.md, docs/guide/integrations/prebid.md:440 ("video and native are currently not emitted" becomes wrong), the Cloudflare and Spin guides (video not supported in production), api-reference.md for the new banner mtype/nurl/burl, and auction-orchestration.md's "Floor prices can be set per-slot in the auction request", which is already false (/auction sets floor_price: None). 18.6 should point at the AGENTS.md CI gates, not CLAUDE.md.
  • 📝 Review process. The review log has one round and the status line says "draft v3"; CONTRIBUTING requires at least two agent rounds, ending with one that finds nothing blocking. Please record this review in the log.
  • 📝 Unpublished source. The Related list and open question 1 cite an earlier draft by name that reviewers cannot read. Since this spec supersedes it, drop the reference and state open question 1's alternative directly.

CI Status

  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS
  • Analyze (rust): 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) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • CLAUDE.md symlink guard: PASS
  • CodeQL: PASS
  • docs-proposal scripts: PASS
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS

Comment on lines +769 to +772
- Disable VPAID in the player or ad SDK before initialization (for IMA,
`VpaidMode.DISABLED`). Declared APIs cannot exclude VPAID, because a VAST can
carry an undeclared VPAID media file or a wrapper can resolve to one.
- Never render `adm` as HTML.

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: The caller contract does not support allowing sanitize_creatives = true

The decision (4, 12.4, 22) rests on VAST never being rendered as HTML on the publisher origin, but section 13 only says "Never render adm as HTML". IMA's documented companion path writes companionAd.getContent() into the page DOM, GPT companion slots auto-render HTMLResource and iFrameResource companions, OMID full access gives verification scripts page access, and a SIMID creative needs a cross-origin iframe. videojs-ima's vpaidMode defaults to enabled. Please also reword the reason in 4, 12.4, and 22: sanitization covers HTML that Trusted Server delivers, HTML inside VAST is safe only under the section 13 rules, and the sanitize_creatives guarantee does not extend to video. Turning this into a config error instead would push sanitizing operators to switch banner sanitization off, so caller rules plus documentation is the better fix.

Suggested change
- Disable VPAID in the player or ad SDK before initialization (for IMA,
`VpaidMode.DISABLED`). Declared APIs cannot exclude VPAID, because a VAST can
carry an undeclared VPAID media file or a wrapper can resolve to one.
- Never render `adm` as HTML.
- Disable VPAID in the player or ad SDK before initialization (for IMA,
`VpaidMode.DISABLED`; for videojs-ima, set `vpaidMode` to `DISABLED`).
Declared APIs cannot exclude VPAID, because a VAST can carry an undeclared
VPAID media file or a wrapper can resolve to one.
- Dispatch on `mtype`. Never write the `adm` of an `mtype: 2` winner into an
HTML sink (`innerHTML`, `document.write`, `srcdoc`, or a same-origin iframe).
- Render no companion or nonlinear creative. With IMA, do not call
`getCompanionAds`, and do not declare GPT companion slots on pages that play
these ads.
- Keep OMID verification at limited access, the IMA default. Never grant full
access through `omidAccessModeRules` or the videojs-ima equivalent.
- Load SIMID creatives only in a cross-origin iframe.

Comment on lines +527 to +535
- Other impression-level fields (`imp.ext`, `tagid`, `bidfloor`) are left as
they are, so stored bidder configuration and floors still apply.

PBS treats `null` as removal and replaces arrays whole when it merges. The
generated types skip `None` and empty `Vec` fields on serialization, so the PBS
profile writes the authoritative form as a `serde_json::Value` step after
serialization. A unit test asserts the complete `imp.video` key set against the
generated type, so a codegen update that adds a field fails the test until the
field is handled.

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: PBS stored-impression clearing only works on PBS Go, and imp-level fields still leak

Checked in both servers' source. PBS Go merges jsonpatch.MergePatch(storedImps[id], impData.Imp) (endpoints/openrtb2/auction.go:1755), so null removes keys. PBS Java merges a typed Imp through JsonMerger.merge (StoredRequestProcessor.java:300), which calls valueToTree on a mapper configured with JsonInclude.Include.NON_NULL (ObjectMapperProvider.java:26). The nulls are dropped before the merge, so stored banner, placement, skip fields, audio, and native survive. Separately, stored instl, rwdd, ssai, exp, and pmp survive on both implementations: they relabel the break, and a deal's at overrides first price. Clearing stored battr also removes a block list.

Suggested change
- Other impression-level fields (`imp.ext`, `tagid`, `bidfloor`) are left as
they are, so stored bidder configuration and floors still apply.
PBS treats `null` as removal and replaces arrays whole when it merges. The
generated types skip `None` and empty `Vec` fields on serialization, so the PBS
profile writes the authoritative form as a `serde_json::Value` step after
serialization. A unit test asserts the complete `imp.video` key set against the
generated type, so a codegen update that adds a field fails the test until the
field is handled.
- `imp.instl` is `0`, `imp.rwdd` is `0`, `imp.ssai` is `1`, and `imp.exp` is
`video_max_exp_seconds`, so stored values cannot relabel the break as
interstitial, rewarded, or server-stitched. `imp.pmp` is `null`, and stored
impressions used by video units carry no deals, because a deal's `at`
overrides the request's first-price declaration (12.3).
- `imp.video.battr` is omitted rather than cleared, so a stored block list
survives.
- Other impression-level fields (`imp.ext`, `tagid`, `bidfloor`) are left as
they are, so stored bidder configuration and floors still apply.
PBS Go merges the raw incoming impression as an RFC 7386 merge patch, so `null`
removes a key and arrays replace whole. PBS Java deserializes the request and
serializes it back without nulls before merging, so there only explicit values
and `[]` override storage and `null` clears nothing. Video units sent to a PBS
Java provider must not use the stored-impression fallback unless the stored
impression holds only `imp.ext`. Trusted Server cannot tell the two apart, so
this is an operator rule, and 18.5 runs against both.
The generated types skip `None` and empty `Vec` fields on serialization, so the
PBS profile writes the authoritative form as a `serde_json::Value` step after
serialization. The patch destructures the generated `Video` exhaustively (no
`..`), so a codegen update that adds a field fails to compile until the field
is handled.

Comment on lines +779 to +782
- Treat `nurl` as a win notice, not a VAST tag URL. Fire `burl` at the VAST
Impression event. Resolve `${AUCTION_PRICE}` in both, and in VAST, at the
returned `price`, which is the clearing price under first-price requests
(12.3).

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: The macro contract covers only ${AUCTION_PRICE}, and the caller cannot resolve the rest

OpenRTB 2.6 section 4.4 says exchanges must support every macro for which data is available, and unavailable optional values become empty strings. Players such as IMA and videojs-ima resolve VAST [MACROS], not ${...}. In the code, every provider receives BidRequest.id = AuctionRequest.id (auction/openrtb.rs:407), the same value as the response id (auction/formats.rs:481), so ${AUCTION_ID} is resolvable. The upstream response bidid is never retained, and the response seat is Bid.bidder ("unknown" when absent, the bidder code for PBS), so ${AUCTION_BID_ID} and ${AUCTION_SEAT_ID} are not. Returning the upstream bidid and seat in bid ext would be the alternative to empty strings.

Suggested change
- Treat `nurl` as a win notice, not a VAST tag URL. Fire `burl` at the VAST
Impression event. Resolve `${AUCTION_PRICE}` in both, and in VAST, at the
returned `price`, which is the clearing price under first-price requests
(12.3).
- Treat `nurl` as a win notice, not a VAST tag URL. Fire `burl` at the VAST
Impression event. Players do not substitute OpenRTB `${...}` macros, so before
firing either, and before passing `adm` to the player, replace
`${AUCTION_PRICE}` with `price`, `${AUCTION_ID}` with the response `id`
(Trusted Server sends the same ID to every provider), `${AUCTION_IMP_ID}` with
`impid`, `${AUCTION_AD_ID}` with `adid`, and `${AUCTION_CURRENCY}` with `USD`.
Replace every other OpenRTB macro, including `${AUCTION_BID_ID}`,
`${AUCTION_SEAT_ID}`, and encoded `${NAME:X}` forms, with an empty string, as
OpenRTB 2.6 section 4.4 directs for unavailable values. `price` is the
clearing price under first-price requests (12.3).

Comment on lines +551 to +554
PBS accounts that adjust or convert video bids are unsupported in this phase.
The configuration guide and the Prebid integration guide state this. Trusted
Server does not detect or reject these bids. A settlement price returned
alongside `price` is a future-state option (20.3).

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: Adjusted or converted PBS bids are cheap to detect, so enforce the restriction

PBS Go writes ext.origbidcpm whenever it is known (exchange/exchange.go:1385) and origbidcur whenever it is non-empty, and PBS Java sets both in BidAdjustmentsProcessor. "Documented, not enforced" means wrong billing figures ship silently. Please also add VideoPriceAdjusted / video_price_adjusted to the 11.5 table (apply manually; it is outside this range).

Suggested change
PBS accounts that adjust or convert video bids are unsupported in this phase.
The configuration guide and the Prebid integration guide state this. Trusted
Server does not detect or reject these bids. A settlement price returned
alongside `price` is a future-state option (20.3).
PBS accounts that adjust or convert video bids are unsupported in this phase.
Both PBS implementations write `ext.origbidcpm` and `ext.origbidcur` on every
bid, so detection is one comparison: a PBS video bid whose `ext.origbidcpm` is
present and differs from `price`, or whose `ext.origbidcur` is present and is
not `USD`, rejects as `video_price_adjusted`. The configuration guide and the
Prebid integration guide state the restriction. A settlement price returned
alongside `price` is a future-state option (20.3).


## 21. Open questions

1. Should video be withheld when `ec_allowed()` is false? Aram's draft says yes.

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: Open question 1 (video when ec_allowed() is false) needs an answer before approval

Consent is on the CONTRIBUTING spec checklist, so this needs a decision rather than an open question. I'd withhold video when ec_allowed() is false (US-state GPC, sale or share opt-out, no signals). Banner in that state sends no EC or EIDs, and with rewrite_creatives on its static assets go through /first-party/proxy, which drops the client's XFF and strips Set-Cookie. Video does the opposite: the browser hands its IP and cookies directly to every wrapper hop, tracker, media host, OMID vendor, and notice host, which is the disclosure an opt-out of sale or share is meant to prevent. The fill cost on US no-signal traffic is acceptable for an experimental feature, and the rule can be relaxed once first-party VAST delivery (20.3) exists. Proposed text: "When ec_allowed() is false, the video half of each slot is removed before routing (mixed to banner, video-only to no bid), counted as video_withheld_consent."


### 19.2 Deploy and rollback

1. Deploy the binary with no config change. Video is off everywhere. Confirm

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: Video is invisible to operators of the experimental feature

Rejections appear only in the per-response ext; the debug logs are invisible at production Info level; an invalid-video 400 creates no telemetry row, because conversion fails before the observation exists; no row counts requested video slots; and auction_bid_stats_mv and seat_yield have no media_type dimension, so video CPMs blend into the banner guardrail. The 24-hour "unchanged" check here also cannot hold, because 11.1 and the at: 1 prerequisite move banner numbers by design. Proposed: ship at: 1 in an earlier release and let it soak; filter the guardrails to media_type = 'banner'; add one Info line per provider with rejection counts by reason; move the 20.2 event kinds ahead of production opt-in.


- VAST check timing under Viceroy for a 10 KiB and a 1 MiB fixture, recorded in
the implementation PR.
- A repeatable run against a real Prebid Server with stored auction responses

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: 18.5 validation cannot inspect what it claims

A PBS storedauctionresponse returns stored seatbids without calling bidders, so there is no resolved bidder request to inspect; use storedbidresponse, or ext.debug.resolvedrequest on staging. The parity harness defines no providers (tests/parity.rs:33-52), so it never sees a video-enabled service. The reference-player run has no pass criterion (proposed: exactly one nurl, burl, and Impression request per played ad, and none when unplayed), and it loads IMA from Google's CDN, so mark it as manual evidence attached to the implementation PR.


### 19.1 Changes for every service

- Banner API winners gain `mtype: 1`, `nurl`, and `burl`.

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: Banner nurl/burl for every service

Neither tsjs consumer fires them, so for services that only use /auction this adds a billing trigger that any HTTP client can fire, and banner ends up with two macro contracts: server-expanded on SSAT, caller-expanded on the API. Consider limiting the new banner fields to video-enabled services, and in any case return only absolute https: notice URLs of at most 4096 bytes.


| Decision | Choice | Reason |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| First use case | Linear instream, `plcmt` 1 or 2, direct API only | Smallest contract with a real consumer; browser paths need their own render design. |

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: The first-use-case reason contradicts section 1

Section 1 says no publisher is lined up to adopt it.

Suggested change
| First use case | Linear instream, `plcmt` 1 or 2, direct API only | Smallest contract with a real consumer; browser paths need their own render design. |
| First use case | Linear instream, `plcmt` 1 or 2, direct API only | Smallest contract a direct-API player can consume; browser paths need their own render design. |

Comment on lines +1080 to +1083
4. Should the experimental label come off on fixed adoption criteria (provider
acceptance of `imp.video`, delivered-to-impressed conversion, time to first
frame, eCPM against the publisher's previous path)?
Resolved:

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: "Resolved:" renders inside item 4

Making it a heading separates the resolved list from the open questions.

Suggested change
4. Should the experimental label come off on fixed adoption criteria (provider
acceptance of `imp.video`, delivered-to-impressed conversion, time to first
frame, eCPM against the publisher's previous path)?
Resolved:
4. Should the experimental label come off on fixed adoption criteria (provider
acceptance of `imp.video`, delivered-to-impressed conversion, time to first
frame, eCPM against the publisher's previous path)?
### Resolved

This branch has not been deployed

No deployments
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.

2 participants