diff --git a/.github/workflows/aps-real-gam.yml b/.github/workflows/aps-real-gam.yml
new file mode 100644
index 000000000..1b7d2a044
--- /dev/null
+++ b/.github/workflows/aps-real-gam.yml
@@ -0,0 +1,134 @@
+name: "APS real-GAM attestation"
+run-name: >-
+ APS real-GAM / ${{ inputs.evidence_id }} / ${{ inputs.release_id }}
+
+permissions:
+ contents: read
+
+on:
+ workflow_call:
+ inputs:
+ release_id:
+ description: Exact TSJS release id deployed to the protected test network
+ required: true
+ type: string
+ evidence_id:
+ description: Unique cutover evidence identifier
+ required: true
+ type: string
+ previous_artifact_id:
+ description: Immutable artifact identifier used for rollback
+ required: true
+ type: string
+ workflow_dispatch:
+ inputs:
+ release_id:
+ description: Exact TSJS release id deployed to the protected test network
+ required: true
+ type: string
+ evidence_id:
+ description: Unique cutover evidence identifier
+ required: true
+ type: string
+ previous_artifact_id:
+ description: Immutable artifact identifier used for rollback
+ required: true
+ type: string
+
+jobs:
+ attest:
+ name: Chromium, Firefox, and WebKit attestation
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ environment: aps-real-gam
+ env:
+ TS_REAL_GAM_PAGE_URL: ${{ secrets.TS_REAL_GAM_PAGE_URL }}
+ TS_REAL_GAM_AUTH_HEADER: ${{ secrets.TS_REAL_GAM_AUTH_HEADER }}
+ TS_REAL_GAM_EXPECTED_RELEASE_ID: ${{ vars.TS_REAL_GAM_EXPECTED_RELEASE_ID }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate protected inputs and release binding
+ env:
+ DISPATCH_EVIDENCE_ID: ${{ inputs.evidence_id }}
+ DISPATCH_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }}
+ DISPATCH_RELEASE_ID: ${{ inputs.release_id }}
+ run: |
+ test -n "$TS_REAL_GAM_PAGE_URL"
+ test -n "$TS_REAL_GAM_AUTH_HEADER"
+ test -n "$TS_REAL_GAM_EXPECTED_RELEASE_ID"
+ test -n "$DISPATCH_RELEASE_ID"
+ test "$DISPATCH_RELEASE_ID" = "$TS_REAL_GAM_EXPECTED_RELEASE_ID"
+ test -n "$DISPATCH_EVIDENCE_ID"
+ test -n "$DISPATCH_PREVIOUS_ARTIFACT_ID"
+
+ - name: Read Node.js version
+ id: node-version
+ run: echo "version=$(awk '$1 == \"nodejs\" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.node-version.outputs.version }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-integration-tests/browser/package-lock.json
+
+ - name: Install isolated browser-test dependencies
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: npm ci
+
+ - name: Install all required browsers
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: npx playwright install --with-deps chromium firefox webkit
+
+ - name: Run protected real-GAM contract
+ id: real-gam
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: >-
+ npm exec -- playwright test
+ --config=playwright.real-gam.config.ts
+ tests/shared/aps-real-gam.spec.ts
+ --project=chromium --project=firefox --project=webkit
+
+ - name: Write release attestation
+ if: always()
+ env:
+ ATTESTATION_EVIDENCE_ID: ${{ inputs.evidence_id }}
+ ATTESTATION_RELEASE_ID: ${{ inputs.release_id }}
+ ATTESTATION_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }}
+ ATTESTATION_TEST_OUTCOME: ${{ steps.real-gam.outcome }}
+ run: >-
+ node -e 'const fs=require("node:fs");
+ const path="crates/trusted-server-integration-tests/browser/real-gam-evidence/evidence-manifest.json";
+ fs.mkdirSync(require("node:path").dirname(path),{recursive:true});
+ fs.writeFileSync(path,JSON.stringify({schemaVersion:1,evidenceId:process.env.ATTESTATION_EVIDENCE_ID,
+ releaseId:process.env.ATTESTATION_RELEASE_ID,previousArtifactId:process.env.ATTESTATION_PREVIOUS_ARTIFACT_ID,
+ commitSha:process.env.GITHUB_SHA,runId:process.env.GITHUB_RUN_ID,
+ conclusion:process.env.ATTESTATION_TEST_OUTCOME},null,2)+"\n",{mode:384});'
+
+ - name: Scrub browser evidence before upload
+ if: always()
+ env:
+ REAL_GAM_TEST_OUTCOME: ${{ steps.real-gam.outcome }}
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: >-
+ node -e 'const fs=require("node:fs"),path=require("node:path");
+ const roots=["real-gam-evidence","playwright-report","test-results"];
+ const forbiddenExt=new Set([".har",".zip",".webm"]), secrets=[process.env.TS_REAL_GAM_PAGE_URL,process.env.TS_REAL_GAM_AUTH_HEADER].filter(Boolean);
+ const files=[]; const walk=p=>{if(!fs.existsSync(p))return; for(const e of fs.readdirSync(p,{withFileTypes:true})){const q=path.join(p,e.name); e.isDirectory()?walk(q):files.push(q)}}; roots.forEach(walk);
+ for(const file of files){if(forbiddenExt.has(path.extname(file)))throw Error("native capture forbidden: "+path.extname(file)); const body=fs.readFileSync(file); for(const secret of secrets){if(body.includes(Buffer.from(secret)))throw Error("protected value found in browser evidence")}}
+ const traces=files.filter(file=>file.endsWith("sanitized-trace-v1.json"));
+ for(const file of traces){const text=fs.readFileSync(file,"utf8"); if(/"(?:accountId|aaxResponse|adm|authorization|capabilities?|creativeBody|descriptor|lifecycleTicket|nonce|postData|requestHeaders|responseBody|responseHeaders)"\s*:/.test(text)||/".to_string());
- let unpriced = make_bid("unpriced", "invalid", None);
let ordinary = make_bid("ordinary", "appnexus", Some(2.75));
let mut renderer = make_bid("renderer", "aps", Some(2.5));
- renderer.creative = Some("".to_string());
+ renderer.creative = Some(" ".to_string());
renderer.bid_id = Some("upstream-renderer-bid".to_string());
renderer.creative_id = None;
- renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
+ renderer.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "upstream-renderer-bid".to_string(),
@@ -1484,37 +1982,21 @@ mod tests {
winning_bids: HashMap::from([
(missing.slot_id.clone(), missing),
(whitespace.slot_id.clone(), whitespace),
- (rejected.slot_id.clone(), rejected),
- (unpriced.slot_id.clone(), unpriced),
(ordinary.slot_id.clone(), ordinary),
(renderer.slot_id.clone(), renderer),
]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
};
- let conversion =
- convert_to_openrtb_response_with_report(&result, &settings, &auction_request, false)
- .expect("should omit invalid winners and preserve valid slots");
- assert_eq!(
- conversion.delivery.delivered_winner_slots,
- HashSet::from(["ordinary".to_string(), "renderer".to_string()]),
- "should report only serialized winners as delivered"
- );
- assert_eq!(conversion.delivery.dropped_winner_count, 4);
- assert_eq!(
- conversion.delivery.dropped_winner_reasons["no_render_source"],
- 2
- );
- assert_eq!(
- conversion.delivery.dropped_winner_reasons["no_decoded_price"],
- 1
- );
- assert_eq!(
- conversion.delivery.dropped_winner_reasons["creative_processing_rejected"],
- 1
- );
- let json = response_json(conversion.response);
+ let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
+ .expect("should omit invalid winners and preserve valid slots");
+ let json = response_json(response);
let bids: Vec<&JsonValue> = json["seatbid"]
.as_array()
.expect("should include valid seatbids")
@@ -1523,30 +2005,16 @@ mod tests {
.collect();
assert_eq!(bids.len(), 2, "should omit only invalid winners");
- assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 4);
+ assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 2);
assert_eq!(
json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_render_source"],
2
);
- assert_eq!(
- json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_decoded_price"],
- 1
- );
- assert_eq!(
- json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"],
- 1
- );
let ordinary = bids
.iter()
.find(|bid| bid["impid"] == "ordinary")
.expect("should preserve ordinary winner");
- assert!(
- ordinary["adm"]
- .as_str()
- .is_some_and(|adm| adm.contains("
Ad
")),
- "should preserve ordinary creative markup: {}",
- ordinary["adm"]
- );
+ assert_eq!(ordinary["adm"], "Ad
");
let renderer = bids
.iter()
.find(|bid| bid["impid"] == "renderer")
@@ -1561,11 +2029,38 @@ mod tests {
}
#[test]
- fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() {
- let settings = make_settings();
+ fn convert_to_openrtb_response_drops_creative_rejected_by_processing() {
+ let mut settings = make_settings();
+ settings.auction.sanitize_creatives = true;
+ settings.auction.rewrite_creatives = false;
+ let auction_request = make_auction_request();
+ let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
+ bid.creative = Some("".to_string());
+ let result = make_result(bid);
+
+ let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
+ .expect("should omit a creative rejected by configured processing");
+ let json = response_json(response);
+
+ assert!(
+ json["seatbid"].as_array().is_none_or(Vec::is_empty),
+ "should not serialize an empty adm"
+ );
+ assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1);
+ assert_eq!(
+ json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"],
+ 1,
+ "should report the exact processing rejection"
+ );
+ }
+
+ #[test]
+ fn convert_to_openrtb_response_rejects_multiple_render_sources() {
+ let mut settings = make_settings();
+ settings.auction.rewrite_creatives = false;
let auction_request = make_auction_request();
let mut bid = make_bid("div-gpt-top", "aps", Some(2.75));
- bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
+ bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "fictional-bid".to_string(),
@@ -1579,20 +2074,16 @@ mod tests {
let result = make_result(bid);
let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
- .expect("should prefer ordinary creative markup");
+ .expect("should reject an ambiguous render source");
let json = response_json(response);
- let bid = &json["seatbid"][0]["bid"][0];
-
- // Rewriting is on by default and a body-less fragment still receives the
- // creative runtime, so the markup is carried rather than returned verbatim.
- let adm = bid["adm"].as_str().expect("should serialize adm");
assert!(
- adm.contains("Ad
"),
- "should carry the creative markup: {adm}"
+ json["seatbid"].as_array().is_none_or(Vec::is_empty),
+ "should not serialize an ambiguous winner"
);
- assert!(
- bid.get("ext").is_none(),
- "should omit renderer extension when creative markup wins precedence"
+ assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1);
+ assert_eq!(
+ json["ext"]["orchestrator"]["dropped_winner_reasons"]["multiple_render_sources"], 1,
+ "should report the exact ambiguous-source reason"
);
}
@@ -1605,7 +2096,7 @@ mod tests {
bid.bid_id = Some("fictional-bid".to_string());
bid.ad_id = Some("fictional-ad".to_string());
bid.creative_id = Some("fictional-creative".to_string());
- bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
+ bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "fictional-bid".to_string(),
@@ -1672,6 +2163,11 @@ mod tests {
provider_responses: vec![],
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
};
@@ -1694,7 +2190,8 @@ mod tests {
#[test]
fn convert_to_openrtb_response_serializes_multiple_winning_bids() {
- let settings = make_settings();
+ let mut settings = make_settings();
+ settings.auction.rewrite_creatives = false;
let auction_request = make_auction_request();
let top_bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
let mut sidebar_bid = make_bid("div-gpt-sidebar", "rubicon", Some(1.25));
@@ -1712,6 +2209,11 @@ mod tests {
(top_bid.slot_id.clone(), top_bid),
(sidebar_bid.slot_id.clone(), sidebar_bid),
]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 50,
metadata: HashMap::new(),
};
@@ -1746,12 +2248,10 @@ mod tests {
"should preserve top slot impid"
);
assert_eq!(top_bid["price"], json!(2.75), "should preserve top price");
- assert!(
- top_bid["adm"]
- .as_str()
- .is_some_and(|adm| adm.contains("Ad
")),
- "should preserve top creative: {}",
- top_bid["adm"]
+ assert_eq!(
+ top_bid["adm"],
+ json!("Ad
"),
+ "should preserve top creative"
);
let sidebar_seatbid = seatbids
@@ -1779,12 +2279,10 @@ mod tests {
json!(1.25),
"should preserve sidebar price"
);
- assert!(
- sidebar_bid["adm"]
- .as_str()
- .is_some_and(|adm| adm.contains("Sidebar
")),
- "should preserve sidebar creative: {}",
- sidebar_bid["adm"]
+ assert_eq!(
+ sidebar_bid["adm"],
+ json!("Sidebar
"),
+ "should preserve sidebar creative"
);
assert_eq!(
json["ext"]["orchestrator"]["total_bids"],
@@ -1823,9 +2321,15 @@ mod tests {
assert!(conversion.delivery.delivered_winner_slots.is_empty());
assert_eq!(conversion.delivery.dropped_winner_count, 1);
assert_eq!(
- conversion.delivery.dropped_winner_reasons["no_decoded_price"], 1,
+ conversion.delivery.dropped_winner_reasons[&AuctionDropReason::InvalidPrice],
+ 1,
"should report the omitted malformed winner"
);
+ assert_eq!(
+ conversion.response.status(),
+ StatusCode::OK,
+ "should still return a successful partial auction response"
+ );
}
#[test]
@@ -1850,10 +2354,16 @@ mod tests {
#[cfg(test)]
mod convert_tests {
use super::*;
+ use crate::auction::types::{
+ AdmRenderSourceV1, AuctionDecisionSetV1, BidRenderSourceV1, BrowserAuctionBidV1,
+ BrowserAuctionProjectionV1, MAX_BROWSER_AUCTION_PROJECTION_BYTES, SlotAuctionDecisionV1,
+ };
use crate::consent::ConsentContext;
use crate::platform::test_support::noop_services;
use crate::test_support::tests::crate_test_settings_str;
use http::Method;
+ use serde_json::json;
+ use std::collections::BTreeMap;
fn make_settings() -> Settings {
Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings")
@@ -2026,4 +2536,273 @@ mod convert_tests {
"3-element banner size should return an error"
);
}
+
+ fn projection_candidate_id(index: usize) -> String {
+ format!("{index:012x}")
+ }
+
+ fn projection_reservation_id(index: usize) -> String {
+ format!("r1_{index:022x}")
+ }
+
+ fn projection_adm_bid(index: usize, slot: &str, adm: String) -> BrowserAuctionBidV1 {
+ BrowserAuctionBidV1 {
+ candidate_id: projection_candidate_id(index),
+ slot: slot.to_string(),
+ provider: "prebid".to_string(),
+ upstream_bid_id: format!("upstream-{index}"),
+ cpm: index as f64,
+ currency: "USD".to_string(),
+ targeting: BTreeMap::from([
+ ("z_key".to_string(), "last".to_string()),
+ ("a_key".to_string(), "first".to_string()),
+ ]),
+ renderer_reservation_id: Some(projection_reservation_id(index)),
+ render_source: BidRenderSourceV1::Adm(AdmRenderSourceV1 {
+ version: 1,
+ adm,
+ width: 300,
+ height: 250,
+ }),
+ }
+ }
+
+ fn projection_with_adm_lengths(lengths: &[usize]) -> BrowserAuctionProjectionV1 {
+ let results = lengths
+ .iter()
+ .enumerate()
+ .map(|(index, _)| SlotAuctionDecisionV1::Winner {
+ slot: format!("slot-{index}"),
+ candidate_id: projection_candidate_id(index),
+ })
+ .collect();
+ let bids = lengths
+ .iter()
+ .enumerate()
+ .map(|(index, length)| {
+ projection_adm_bid(index, &format!("slot-{index}"), "x".repeat(*length))
+ })
+ .rev()
+ .collect();
+ BrowserAuctionProjectionV1 {
+ version: 1,
+ auction: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-1".to_string(),
+ results,
+ },
+ slots: Vec::new(),
+ bids,
+ }
+ }
+
+ #[test]
+ fn canonical_projection_orders_bids_and_targeting_by_contract() {
+ let input = projection_with_adm_lengths(&[1, 1]);
+ let mut permuted = input.clone();
+ permuted.bids.reverse();
+
+ let canonical =
+ canonicalize_browser_auction_projection_v1(input, "https://publisher.example")
+ .expect("valid projection should canonicalize");
+ let canonical_permuted =
+ canonicalize_browser_auction_projection_v1(permuted, "https://publisher.example")
+ .expect("response-order permutation should canonicalize");
+
+ assert!(!canonical.reduced_for_size);
+ assert_eq!(canonical.json, canonical_permuted.json);
+ assert_eq!(canonical.projection.bids[0].slot, "slot-0");
+ assert_eq!(canonical.projection.bids[1].slot, "slot-1");
+ let json = String::from_utf8(canonical.json).expect("canonical JSON should be UTF-8");
+ assert!(
+ json.find("\"a_key\"") < json.find("\"z_key\""),
+ "targeting keys should be lexically sorted"
+ );
+ assert!(
+ json.starts_with("{\"version\":1,\"auction\":{\"version\":1,\"auctionId\":"),
+ "top-level and decision-set fields should retain schema order: {json}"
+ );
+ }
+
+ #[test]
+ fn pbs_cache_wire_is_the_exact_thin_deny_unknown_carrier() {
+ let value = serde_json::json!({
+ "type": "pbs_cache",
+ "version": 1,
+ "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570",
+ "cacheHost": "cache.example:8443",
+ "cachePath": "/pbc/v1/cache/opaque%2Fpath",
+ "width": 0,
+ "height": u32::MAX
+ });
+ let source: BidRenderSourceV1 = serde_json::from_value(value.clone())
+ .expect("the final tagged union should admit the thin pbs_cache carrier");
+ assert_eq!(
+ serde_json::to_value(source).expect("cache carrier should serialize"),
+ value
+ );
+
+ let mut unknown = value;
+ unknown["fetchUrl"] = serde_json::Value::String(
+ "https://cache.example/pbc/v1/cache?uuid=not-authoritative".to_string(),
+ );
+ assert!(serde_json::from_value::(unknown).is_err());
+ }
+
+ #[test]
+ fn invalid_selected_winner_becomes_winner_not_renderable() {
+ let mut input = projection_with_adm_lengths(&[1]);
+ input.bids[0].renderer_reservation_id = Some("not-a-reservation".to_string());
+
+ let canonical =
+ canonicalize_browser_auction_projection_v1(input, "https://publisher.example")
+ .expect("selected projection failure should remain an explicit slot result");
+
+ assert!(canonical.projection.bids.is_empty());
+ assert_eq!(
+ canonical.projection.auction.results,
+ vec![SlotAuctionDecisionV1::Failed {
+ slot: "slot-0".to_string(),
+ reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable,
+ }]
+ );
+ }
+
+ #[test]
+ fn canonical_projection_enforces_exact_eight_mib_all_winner_reduction() {
+ let mut lengths = vec![512 * 1024; 15];
+ lengths.push(1);
+ let baseline = projection_with_adm_lengths(&lengths);
+ let baseline_len = serde_json::to_vec(&baseline)
+ .expect("typed baseline should serialize")
+ .len();
+ let exact_tail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baseline_len;
+ assert!(
+ exact_tail <= 512 * 1024,
+ "tail ADM should remain individually valid"
+ );
+
+ for (delta, should_reduce) in [(-1_isize, false), (0, false), (1, true)] {
+ lengths[15] = exact_tail
+ .checked_add_signed(delta)
+ .expect("positive exact tail");
+ let input = projection_with_adm_lengths(&lengths);
+ let canonical =
+ canonicalize_browser_auction_projection_v1(input, "https://publisher.example")
+ .expect("boundary projection should canonicalize or reduce");
+ assert_eq!(canonical.reduced_for_size, should_reduce, "delta {delta}");
+ assert!(canonical.json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES);
+ if should_reduce {
+ assert!(canonical.projection.bids.is_empty());
+ assert!(canonical.projection.auction.results.iter().all(|result| matches!(
+ result,
+ SlotAuctionDecisionV1::Failed {
+ reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable,
+ ..
+ }
+ )));
+ let wire: JsonValue = serde_json::from_slice(
+ &serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("reduced exact response should serialize"),
+ )
+ .expect("reduced exact response should be JSON");
+ assert_eq!(wire["seatbid"], json!([]));
+ } else {
+ assert_eq!(
+ canonical.json.len(),
+ MAX_BROWSER_AUCTION_PROJECTION_BYTES
+ .checked_add_signed(delta)
+ .expect("boundary size should remain positive")
+ );
+ if delta == 0 {
+ let wire = serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("exact-boundary response should serialize");
+ assert!(
+ wire.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES,
+ "exact response should not exceed the admitted projection cap"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn exact_openrtb_serializer_uses_reservation_and_trusted_server_join_only() {
+ let canonical = canonicalize_browser_auction_projection_v1(
+ projection_with_adm_lengths(&[7]),
+ "https://publisher.example",
+ )
+ .expect("projection should canonicalize");
+
+ let json: JsonValue = serde_json::from_slice(
+ &serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("exact response should serialize"),
+ )
+ .expect("exact response should be JSON");
+
+ let bid = &json["seatbid"][0]["bid"][0];
+ assert_eq!(bid["id"], projection_reservation_id(0));
+ assert_eq!(bid["impid"], "slot-0");
+ assert!(
+ bid.get("adm").is_none(),
+ "tagged render_source should be the sole browser authority"
+ );
+ assert_eq!(json["cur"], "USD");
+ assert_eq!(
+ bid["ext"]["trusted_server"],
+ json!({
+ "candidate_id": projection_candidate_id(0),
+ "slot_id": "slot-0",
+ "render_source": {
+ "type": "adm",
+ "version": 1,
+ "adm": "xxxxxxx",
+ "width": 300,
+ "height": 250,
+ }
+ })
+ );
+ assert_eq!(
+ json["ext"]["trusted_server"]["slot_results"],
+ serde_json::to_value(&canonical.projection.auction)
+ .expect("decision set should serialize")
+ );
+ }
+
+ #[test]
+ fn exact_openrtb_serializer_carries_identity_generation_failure_without_a_bid() {
+ let canonical = canonicalize_browser_auction_projection_v1(
+ BrowserAuctionProjectionV1 {
+ version: 1,
+ auction: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "auction-identity-failure".to_string(),
+ results: vec![SlotAuctionDecisionV1::Failed {
+ slot: "slot-0".to_string(),
+ reason: crate::auction::types::AuctionSlotFailureReason::IdentityGenerationFailed,
+ }],
+ },
+ slots: Vec::new(),
+ bids: Vec::new(),
+ },
+ "https://publisher.example",
+ )
+ .expect("identity failure decision should canonicalize");
+
+ let json: JsonValue = serde_json::from_slice(
+ &serialize_trusted_server_auction_response_v1(&canonical)
+ .expect("identity failure response should serialize"),
+ )
+ .expect("identity failure response should be JSON");
+
+ assert_eq!(json["seatbid"], json!([]));
+ assert_eq!(
+ json["ext"]["trusted_server"]["slot_results"]["results"][0],
+ json!({
+ "slot": "slot-0",
+ "outcome": "failed",
+ "reason": "identity_generation_failed",
+ })
+ );
+ }
}
diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs
index 1552ee4fd..cd2354507 100644
--- a/crates/trusted-server-core/src/auction/orchestrator.rs
+++ b/crates/trusted-server-core/src/auction/orchestrator.rs
@@ -12,9 +12,25 @@ use crate::error::TrustedServerError;
use crate::platform::{PlatformPendingRequest, RuntimeServices};
use super::config::AuctionConfig;
-use super::provider::{AuctionProvider, ProviderParseState, ProviderRequestOutcome};
+use super::provider::{
+ AuctionProvider, ProviderParseState, ProviderRequestOutcome, ProviderSlotDisposition,
+ ProviderSlotOutcome,
+};
use super::telemetry::AbandonedProviderCall;
-use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus};
+use super::types::{
+ AuctionContext, AuctionDecisionSetV1, AuctionDropReason, AuctionIdentityGenerator,
+ AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidStatus,
+ SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, mint_response_unique_base64url_identity,
+};
+
+const CANDIDATE_ID_BYTES: usize = 9;
+const CANDIDATE_ID_COLLISION_RETRIES: usize = 8;
+const MAX_UPSTREAM_BID_ID_BYTES: usize = 64;
+
+struct NormalizedProviderResponses {
+ outcomes: Vec,
+ candidates: HashMap,
+}
/// In-flight auction requests dispatched to SSP backends.
///
@@ -43,23 +59,6 @@ struct ProviderLaunchState {
parse_state: Option,
}
-/// Outcome of attempting to dispatch split-phase auction provider requests.
-pub enum DispatchAuctionOutcome {
- /// No provider request was started and no provider failure was observed.
- NotStarted,
- /// No provider request could be launched, but launch failures were observed.
- DispatchFailed {
- /// Original auction request.
- request: AuctionRequest,
- /// Provider launch-failure responses.
- provider_responses: Vec,
- /// Elapsed dispatch time.
- elapsed_ms: u64,
- },
- /// One or more providers produced an immediate response or started a request.
- Dispatched(DispatchedAuction),
-}
-
impl DispatchedAuction {
/// Consume the dispatch token without collecting provider responses.
#[must_use]
@@ -169,6 +168,23 @@ fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> Auct
.with_metadata("message", serde_json::json!("Provider request timed out"))
}
+fn canonical_provider_response(
+ expected_provider: &str,
+ response: AuctionResponse,
+) -> AuctionResponse {
+ if response.provider == expected_provider {
+ response
+ } else {
+ log::warn!(
+ "Provider '{}' returned response identity '{}'; rejecting mismatched response",
+ expected_provider,
+ response.provider
+ );
+ AuctionResponse::error(expected_provider, response.response_time_ms)
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse)
+ }
+}
+
/// Compute the remaining time budget from a deadline.
///
/// Returns the number of milliseconds left before `timeout_ms` is exceeded,
@@ -192,6 +208,7 @@ fn snapshot_context_request(request: &Request) -> Request {
pub struct AuctionOrchestrator {
config: AuctionConfig,
providers: HashMap>,
+ identity_generator: Arc,
}
impl AuctionOrchestrator {
@@ -201,6 +218,19 @@ impl AuctionOrchestrator {
Self {
config,
providers: HashMap::new(),
+ identity_generator: Arc::new(SystemAuctionIdentityGenerator),
+ }
+ }
+
+ #[cfg(test)]
+ fn with_identity_generator(
+ config: AuctionConfig,
+ identity_generator: Arc,
+ ) -> Self {
+ Self {
+ config,
+ providers: HashMap::new(),
+ identity_generator,
}
}
@@ -264,6 +294,366 @@ impl AuctionOrchestrator {
Ok(())
}
+ fn provider_is_eligible_for_slot(
+ &self,
+ provider_name: &str,
+ slot: &super::types::AdSlot,
+ ) -> bool {
+ self.providers.get(provider_name).is_some_and(|provider| {
+ provider.is_enabled()
+ && slot
+ .formats
+ .iter()
+ .any(|format| provider.supports_media_type(&format.media_type))
+ })
+ }
+
+ fn eligible_slot_ids(&self, provider_name: &str, request: &AuctionRequest) -> HashSet {
+ request
+ .slots
+ .iter()
+ .filter(|slot| self.provider_is_eligible_for_slot(provider_name, slot))
+ .map(|slot| slot.id.clone())
+ .collect()
+ }
+
+ fn valid_upstream_bid_id(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= MAX_UPSTREAM_BID_ID_BYTES
+ && !value.bytes().any(|byte| byte <= 0x1f || byte == 0x7f)
+ }
+
+ fn mint_candidate_id(&self, issued: &mut HashSet) -> Option {
+ let candidate_id = mint_response_unique_base64url_identity(
+ self.identity_generator.as_ref(),
+ issued,
+ "",
+ CANDIDATE_ID_BYTES,
+ CANDIDATE_ID_COLLISION_RETRIES,
+ )?;
+ debug_assert_eq!(candidate_id.len(), 12);
+ Some(candidate_id)
+ }
+
+ fn response_failure_reason(response: &AuctionResponse) -> Option {
+ if response.status == BidStatus::Error || response.status == BidStatus::Pending {
+ return match response
+ .metadata
+ .get("error_type")
+ .and_then(serde_json::Value::as_str)
+ {
+ Some(ERROR_TYPE_TIMEOUT) => Some(AuctionSlotFailureReason::ProviderTimeout),
+ Some(ERROR_TYPE_PARSE_RESPONSE) => {
+ Some(AuctionSlotFailureReason::InvalidProviderResponse)
+ }
+ _ => {
+ let invalid = response
+ .metadata
+ .get("drop_reasons")
+ .and_then(serde_json::Value::as_object)
+ .is_some_and(|reasons| reasons.contains_key("invalid_provider_response"));
+ Some(if invalid {
+ AuctionSlotFailureReason::InvalidProviderResponse
+ } else {
+ AuctionSlotFailureReason::ProviderError
+ })
+ }
+ };
+ }
+
+ None
+ }
+
+ fn normalize_provider_responses(
+ &self,
+ request: &AuctionRequest,
+ responses: &mut [AuctionResponse],
+ ) -> NormalizedProviderResponses {
+ let requested_slots: HashMap<&str, &super::types::AdSlot> = request
+ .slots
+ .iter()
+ .map(|slot| (slot.id.as_str(), slot))
+ .collect();
+ let mut issued_candidate_ids = HashSet::new();
+ let mut candidates = HashMap::new();
+ let mut outcomes = Vec::new();
+
+ for response in responses {
+ let eligible_slots = self.eligible_slot_ids(&response.provider, request);
+ let response_failure = Self::response_failure_reason(response);
+ let mut upstream_counts = HashMap::::new();
+ for bid in &response.bids {
+ if let Some(upstream_id) = bid.bid_id.as_deref()
+ && Self::valid_upstream_bid_id(upstream_id)
+ {
+ *upstream_counts.entry(upstream_id.to_string()).or_default() += 1;
+ }
+ }
+
+ let mut invalid_slots = response
+ .metadata
+ .get("invalid_slots")
+ .and_then(serde_json::Value::as_object)
+ .map(|slots| {
+ slots
+ .iter()
+ .filter_map(|(slot, reason)| {
+ (reason.as_str() == Some("invalid_provider_response")).then_some((
+ slot.clone(),
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ ))
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+ let mut global_invalid = response
+ .metadata
+ .get("global_invalid_provider_response")
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false);
+ let mut accepted = Vec::new();
+ for mut bid in core::mem::take(&mut response.bids) {
+ let requested_slot = requested_slots.get(bid.slot_id.as_str()).copied();
+ let slot_is_eligible = eligible_slots.contains(&bid.slot_id);
+ let dimensions_match = requested_slot.is_some_and(|slot| {
+ slot.formats.iter().any(|format| {
+ format.width == bid.width
+ && format.height == bid.height
+ && self
+ .providers
+ .get(&response.provider)
+ .is_some_and(|provider| {
+ provider.supports_media_type(&format.media_type)
+ })
+ })
+ });
+ let upstream_id = bid.bid_id.as_deref();
+ let upstream_is_valid = upstream_id.is_some_and(Self::valid_upstream_bid_id);
+ let upstream_is_unique = upstream_id.is_some_and(|upstream_id| {
+ upstream_counts.get(upstream_id).copied() == Some(1)
+ });
+ let bid_is_valid = response.status == BidStatus::Success
+ && slot_is_eligible
+ && dimensions_match
+ && upstream_is_valid
+ && upstream_is_unique
+ && bid.currency == "USD"
+ && bid
+ .price
+ .is_some_and(|price| price.is_finite() && price >= 0.0);
+
+ if !bid_is_valid {
+ if requested_slot.is_some() {
+ invalid_slots
+ .entry(bid.slot_id.clone())
+ .or_insert(AuctionSlotFailureReason::InvalidProviderResponse);
+ } else {
+ global_invalid = true;
+ }
+ continue;
+ }
+
+ let Some(candidate_id) = self.mint_candidate_id(&mut issued_candidate_ids) else {
+ invalid_slots
+ .insert(bid.slot_id.clone(), AuctionSlotFailureReason::InternalError);
+ continue;
+ };
+ bid.candidate_id = Some(candidate_id.clone());
+ bid.candidate_provider = Some(response.provider.clone());
+ bid.renderer_reservation_id = None;
+ candidates.insert(candidate_id, bid.clone());
+ accepted.push(bid);
+ }
+ let internally_failed_slots: HashSet<&str> = invalid_slots
+ .iter()
+ .filter_map(|(slot, reason)| {
+ (*reason == AuctionSlotFailureReason::InternalError).then_some(slot.as_str())
+ })
+ .collect();
+ if !internally_failed_slots.is_empty() {
+ accepted.retain(|bid| !internally_failed_slots.contains(bid.slot_id.as_str()));
+ candidates.retain(|_, bid| {
+ bid.candidate_provider.as_deref() != Some(response.provider.as_str())
+ || !internally_failed_slots.contains(bid.slot_id.as_str())
+ });
+ }
+ response.bids = accepted;
+
+ for slot in &request.slots {
+ if !eligible_slots.contains(&slot.id) {
+ continue;
+ }
+ let slot_candidates: Vec = response
+ .bids
+ .iter()
+ .filter(|bid| bid.slot_id == slot.id)
+ .cloned()
+ .collect();
+ let disposition = if !slot_candidates.is_empty() {
+ ProviderSlotDisposition::Candidates(slot_candidates)
+ } else if let Some(reason) = invalid_slots.get(&slot.id).copied() {
+ ProviderSlotDisposition::Failed(reason)
+ } else if global_invalid {
+ ProviderSlotDisposition::Failed(
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ )
+ } else if let Some(reason) = response_failure {
+ ProviderSlotDisposition::Failed(reason)
+ } else {
+ ProviderSlotDisposition::NoBid
+ };
+ outcomes.push(ProviderSlotOutcome {
+ provider: response.provider.clone(),
+ slot: slot.id.clone(),
+ disposition,
+ });
+ }
+ }
+
+ NormalizedProviderResponses {
+ outcomes,
+ candidates,
+ }
+ }
+
+ fn build_decision_set(
+ &self,
+ request: &AuctionRequest,
+ outcomes: &[ProviderSlotOutcome],
+ winning_bids: &HashMap,
+ mediation_failed: bool,
+ ) -> AuctionDecisionSetV1 {
+ let results = request
+ .slots
+ .iter()
+ .map(|slot| {
+ if let Some(winner) = winning_bids.get(&slot.id) {
+ return winner.candidate_id.as_ref().map_or_else(
+ || SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason: AuctionSlotFailureReason::WinnerNotRenderable,
+ },
+ |candidate_id| SlotAuctionDecisionV1::Winner {
+ slot: slot.id.clone(),
+ candidate_id: candidate_id.clone(),
+ },
+ );
+ }
+
+ let eligible_provider_count = self
+ .config
+ .provider_names()
+ .iter()
+ .filter(|provider| self.provider_is_eligible_for_slot(provider, slot))
+ .count();
+ if eligible_provider_count == 0 {
+ return SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason: AuctionSlotFailureReason::SlotNotEligible,
+ };
+ }
+
+ let mut failures: Vec = outcomes
+ .iter()
+ .filter(|outcome| outcome.slot == slot.id)
+ .filter_map(|outcome| match outcome.disposition {
+ ProviderSlotDisposition::Failed(reason) => Some(reason),
+ ProviderSlotDisposition::Candidates(_) | ProviderSlotDisposition::NoBid => {
+ None
+ }
+ })
+ .collect();
+ if mediation_failed {
+ failures.push(AuctionSlotFailureReason::MediationFailed);
+ }
+ failures.sort_by_key(|reason| reason.priority());
+ failures.first().copied().map_or_else(
+ || SlotAuctionDecisionV1::NoBid {
+ slot: slot.id.clone(),
+ },
+ |reason| SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason,
+ },
+ )
+ })
+ .collect();
+
+ AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results,
+ }
+ }
+
+ fn resolve_mediator_candidates(
+ mediator_response: AuctionResponse,
+ candidates: &HashMap,
+ ) -> Result {
+ if mediator_response.status == BidStatus::Error
+ || mediator_response.status == BidStatus::Pending
+ {
+ return Err(());
+ }
+
+ let mut seen = HashSet::new();
+ let mut seen_slots = HashSet::new();
+ let mut resolved = Vec::with_capacity(mediator_response.bids.len());
+ for selection in &mediator_response.bids {
+ let Some(candidate_id) = selection.candidate_id.as_deref() else {
+ return Err(());
+ };
+ if !seen.insert(candidate_id.to_string()) {
+ return Err(());
+ }
+ let Some(source) = candidates.get(candidate_id) else {
+ return Err(());
+ };
+ let Some(selected_price) = selection
+ .price
+ .filter(|price| price.is_finite() && *price >= 0.0)
+ else {
+ return Err(());
+ };
+ let source_authority_matches = selection.slot_id == source.slot_id
+ && selection.candidate_provider == source.candidate_provider
+ && selection.currency == source.currency
+ && selection.creative == source.creative
+ && selection.adomain == source.adomain
+ && selection.bidder == source.bidder
+ && selection.width == source.width
+ && selection.height == source.height
+ && selection.nurl == source.nurl
+ && selection.burl == source.burl
+ && selection.bid_id == source.bid_id
+ && selection.ad_id == source.ad_id
+ && selection.creative_id == source.creative_id
+ && selection.renderer == source.renderer
+ && selection.cache_id == source.cache_id
+ && selection.cache_host == source.cache_host
+ && selection.cache_path == source.cache_path;
+ if !seen_slots.insert(source.slot_id.as_str()) || !source_authority_matches {
+ return Err(());
+ }
+
+ let mut restored = source.clone();
+ restored.price = Some(selected_price);
+ resolved.push(restored);
+ }
+
+ Ok(AuctionResponse {
+ provider: mediator_response.provider,
+ status: if resolved.is_empty() {
+ BidStatus::NoBid
+ } else {
+ BidStatus::Success
+ },
+ bids: resolved,
+ response_time_ms: mediator_response.response_time_ms,
+ metadata: mediator_response.metadata,
+ })
+ }
+
/// Execute an auction using the auto-detected strategy.
///
/// Strategy is determined by mediator configuration:
@@ -281,6 +671,20 @@ impl AuctionOrchestrator {
) -> Result> {
let start_time = Instant::now();
+ if !self.config.enabled {
+ return Ok(OrchestrationResult {
+ provider_responses: Vec::new(),
+ mediator_response: None,
+ winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1::failed(
+ request,
+ AuctionSlotFailureReason::AuctionDisabled,
+ ),
+ total_time_ms: 0,
+ metadata: HashMap::new(),
+ });
+ }
+
// Auto-detect strategy based on mediator configuration
let (strategy_name, result) = if self.config.has_mediator() {
(
@@ -317,119 +721,125 @@ impl AuctionOrchestrator {
context: &AuctionContext<'_>,
) -> Result> {
let mediation_start = Instant::now();
- let provider_responses = self.run_providers_parallel(request, context).await?;
+ let mut provider_responses = self.run_providers_parallel(request, context).await?;
+ let normalized = self.normalize_provider_responses(request, &mut provider_responses);
let floor_prices = self.floor_prices_by_slot(request);
- let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator {
- let mediator = self.get_provider(mediator_name)?;
-
- log::info!(
- "Sending {} provider responses to mediator: {}",
- provider_responses.len(),
- mediator.provider_name()
- );
-
- // Give the mediator only the remaining time from the auction
- // deadline, not the full timeout — the bidding phase already
- // consumed part of it. Canonicalize the transport timeout so the
- // backend name remains stable across equivalent budget values.
- let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms);
- let mediator_timeout = context
- .services
- .backend()
- .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms());
-
- if mediator_timeout == 0 {
- log::warn!("Auction timeout exhausted during bidding phase; skipping mediator");
- let winning = self.select_winning_bids(&provider_responses, &floor_prices);
- return Ok(OrchestrationResult {
- provider_responses,
- mediator_response: None,
- winning_bids: winning,
- total_time_ms: 0,
- metadata: HashMap::new(),
- });
- }
-
- let mediator_context = AuctionContext {
- settings: context.settings,
- request: context.request,
- timeout_ms: mediator_timeout,
- provider_responses: Some(&provider_responses),
- services: context.services,
- };
-
- let start_time = Instant::now();
- let mediator_resp = match mediator
- .request_bids(request, &mediator_context)
- .await
- .change_context(TrustedServerError::Auction {
- message: format!("Mediator {} failed to launch", mediator.provider_name()),
- })? {
- ProviderRequestOutcome::Immediate(response) => response,
- ProviderRequestOutcome::Pending {
- request: pending,
- parse_state,
- } => {
- let platform_resp = mediator_context
- .services
- .http_client()
- .wait(pending)
- .await
- .change_context(TrustedServerError::Auction {
- message: format!(
- "Mediator {} request failed",
+ let mut mediation_failed = false;
+ let mut mediator_response = None;
+ let mut winning_bids = None;
+
+ if let Some(mediator_name) = &self.config.mediator {
+ if let Some(mediator) = self.providers.get(mediator_name) {
+ log::info!(
+ "Sending {} provider responses to mediator: {}",
+ provider_responses.len(),
+ mediator.provider_name()
+ );
+ let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms);
+ if remaining_ms == 0 {
+ log::warn!("Auction timeout exhausted during bidding phase; skipping mediator");
+ mediation_failed = true;
+ } else {
+ let mediator_context = AuctionContext {
+ settings: context.settings,
+ request: context.request,
+ timeout_ms: context
+ .services
+ .backend()
+ .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()),
+ provider_responses: Some(&provider_responses),
+ services: context.services,
+ };
+ let start_time = Instant::now();
+ let raw_response = match mediator.request_bids(request, &mediator_context).await
+ {
+ Ok(ProviderRequestOutcome::Immediate(response)) => Some(response),
+ Ok(ProviderRequestOutcome::Pending {
+ request: pending,
+ parse_state,
+ }) => match mediator_context.services.http_client().wait(pending).await {
+ Ok(platform_response) => mediator
+ .parse_response_with_context_and_state(
+ platform_response,
+ start_time.elapsed().as_millis() as u64,
+ request,
+ &mediator_context,
+ parse_state.as_deref(),
+ )
+ .await
+ .inspect_err(|error| {
+ log::warn!(
+ "Mediator '{}' parse failed: {error:?}",
+ mediator.provider_name()
+ );
+ })
+ .ok(),
+ Err(error) => {
+ log::warn!(
+ "Mediator '{}' request failed: {error:?}",
+ mediator.provider_name()
+ );
+ None
+ }
+ },
+ Err(error) => {
+ log::warn!(
+ "Mediator '{}' failed to launch: {error:?}",
mediator.provider_name()
- ),
- })?;
-
- mediator
- .parse_response_with_context_and_state(
- platform_resp,
- start_time.elapsed().as_millis() as u64,
- request,
- &mediator_context,
- parse_state.as_deref(),
- )
- .await
- .change_context(TrustedServerError::Auction {
- message: format!("Mediator {} parse failed", mediator.provider_name()),
- })?
- }
- };
+ );
+ None
+ }
+ };
- // Extract only mediator bids with comparable numeric prices.
- let winning = mediator_resp
- .bids
- .iter()
- .filter_map(|bid| {
- if bid.price.is_none() {
- log::warn!(
- "Mediator '{}' returned bid for slot '{}' without a price - skipping",
- mediator.provider_name(),
- bid.slot_id
- );
- None
+ if let Some(raw_response) = raw_response {
+ mediator_response = Some(raw_response.clone());
+ match Self::resolve_mediator_candidates(
+ raw_response,
+ &normalized.candidates,
+ ) {
+ Ok(resolved) => {
+ let selected = resolved
+ .bids
+ .iter()
+ .map(|bid| (bid.slot_id.clone(), bid.clone()))
+ .collect();
+ winning_bids =
+ Some(self.apply_floor_prices(selected, &floor_prices));
+ mediator_response = Some(resolved);
+ }
+ Err(()) => {
+ log::warn!(
+ "Mediator '{}' returned invalid candidate provenance",
+ mediator.provider_name()
+ );
+ mediation_failed = true;
+ }
+ }
} else {
- Some((bid.slot_id.clone(), bid.clone()))
+ mediation_failed = true;
}
- })
- .collect();
+ }
+ } else {
+ log::warn!("Mediator '{}' not registered", mediator_name);
+ mediation_failed = true;
+ }
+ }
- (
- Some(mediator_resp),
- self.apply_floor_prices(winning, &floor_prices),
- )
- } else {
- // No mediator - select best bid per slot from bidder responses
- let winning = self.select_winning_bids(&provider_responses, &floor_prices);
- (None, winning)
- };
+ let winning_bids = winning_bids
+ .unwrap_or_else(|| self.select_winning_bids(&provider_responses, &floor_prices));
+ let decision_set = self.build_decision_set(
+ request,
+ &normalized.outcomes,
+ &winning_bids,
+ mediation_failed,
+ );
Ok(OrchestrationResult {
provider_responses,
mediator_response,
winning_bids,
+ decision_set,
total_time_ms: 0, // Will be set by caller
metadata: HashMap::new(),
})
@@ -441,14 +851,18 @@ impl AuctionOrchestrator {
request: &AuctionRequest,
context: &AuctionContext<'_>,
) -> Result> {
- let provider_responses = self.run_providers_parallel(request, context).await?;
+ let mut provider_responses = self.run_providers_parallel(request, context).await?;
+ let normalized = self.normalize_provider_responses(request, &mut provider_responses);
let floor_prices = self.floor_prices_by_slot(request);
let winning_bids = self.select_winning_bids(&provider_responses, &floor_prices);
+ let decision_set =
+ self.build_decision_set(request, &normalized.outcomes, &winning_bids, false);
Ok(OrchestrationResult {
provider_responses,
mediator_response: None,
winning_bids,
+ decision_set,
total_time_ms: 0,
metadata: HashMap::new(),
})
@@ -466,9 +880,7 @@ impl AuctionOrchestrator {
let provider_names = self.config.provider_names();
if provider_names.is_empty() {
- return Err(Report::new(TrustedServerError::Auction {
- message: "No providers configured".to_string(),
- }));
+ return Ok(Vec::new());
}
// Reject multi-provider fan-out before any request launches when the
@@ -477,14 +889,14 @@ impl AuctionOrchestrator {
// blow the auction budget before a later `select` could reject it.
if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout()
{
- return Err(Report::new(TrustedServerError::Auction {
- message: format!(
- "{} auction providers configured, but this platform's HTTP \
- client executes requests sequentially — configure a single \
- provider, or use an adapter with concurrent fan-out support",
- provider_names.len(),
- ),
- }));
+ log::warn!(
+ "{} auction providers configured, but this platform's HTTP client executes requests sequentially",
+ provider_names.len(),
+ );
+ return Ok(provider_names
+ .iter()
+ .map(|provider_name| provider_launch_failed_response(provider_name, 0))
+ .collect());
}
log::info!(
@@ -500,8 +912,6 @@ impl AuctionOrchestrator {
let mut backend_to_provider: HashMap = HashMap::new();
let mut pending_requests: Vec = Vec::new();
let mut responses = Vec::new();
- let mut immediate_response_count = 0usize;
-
for provider_name in provider_names {
let provider = match self.providers.get(provider_name) {
Some(p) => p,
@@ -532,20 +942,23 @@ impl AuctionOrchestrator {
// budget skips every provider, including one that might respond immediately.
if effective_timeout == 0 {
log::warn!("Auction timeout exhausted before launching provider request; skipping");
+ responses.push(provider_timeout_response(provider.provider_name(), 0));
continue;
}
- // Immediate providers have no backend name and must remain eligible
- // to return a synchronous result. Pending providers are still
- // guarded before dispatch when their name can be predicted.
- let predicted_backend_name = provider.backend_name(context.services, effective_timeout);
- if let Some(backend_name) = predicted_backend_name.as_ref()
- && backend_to_provider.contains_key(backend_name)
+ // Pre-launch guard: `request_bids` fires the outbound send, and
+ // discarding the returned pending handle afterwards does not retract
+ // it. If another provider this auction already claimed the predicted
+ // backend name, skip *before* dispatching so a duplicate never hits
+ // the wire. The post-launch check below stays as a defense for a
+ // provider that resolves to an unexpected name.
+ if let Some(predicted) = provider.backend_name(context.services, effective_timeout)
+ && backend_to_provider.contains_key(&predicted)
{
log::warn!(
"Provider '{}' predicted backend name '{}' already belongs to another provider; skipping launch",
provider.provider_name(),
- backend_name,
+ predicted,
);
responses.push(provider_launch_failed_response(provider.provider_name(), 0));
continue;
@@ -572,14 +985,13 @@ impl AuctionOrchestrator {
parse_state,
}) => {
let request_backend_name = pending.backend_name().map(str::to_string).or_else(|| {
- if let Some(backend_name) = predicted_backend_name.as_ref() {
+ provider.backend_name(context.services, effective_timeout).inspect(|name| {
log::warn!(
"Provider '{}' pending request returned no backend name; using predicted name '{}'",
provider.provider_name(),
- backend_name,
+ name,
);
- }
- predicted_backend_name.clone()
+ })
});
let Some(request_backend_name) = request_backend_name else {
log::warn!(
@@ -592,6 +1004,21 @@ impl AuctionOrchestrator {
));
continue;
};
+ // Post-launch defense: a resolved backend name already
+ // claimed by another provider would misattribute that
+ // provider's response, so fail this launch attributably
+ // instead of overwriting the correlation entry.
+ if backend_to_provider.contains_key(&request_backend_name) {
+ log::warn!(
+ "Provider '{}' pending request has no backend name; response cannot be correlated",
+ provider.provider_name()
+ );
+ responses.push(provider_launch_failed_response(
+ provider.provider_name(),
+ start_time.elapsed().as_millis() as u64,
+ ));
+ continue;
+ };
if backend_to_provider.contains_key(&request_backend_name) {
log::warn!(
"Provider '{}' resolved backend name '{}' already belongs to another provider; skipping launch",
@@ -621,12 +1048,14 @@ impl AuctionOrchestrator {
);
}
Ok(ProviderRequestOutcome::Immediate(response)) => {
- immediate_response_count += 1;
log::debug!(
"Provider '{}' completed without an upstream request",
provider.provider_name()
);
- responses.push(response);
+ responses.push(canonical_provider_response(
+ provider.provider_name(),
+ response,
+ ));
}
Err(e) => {
let response_time_ms = start_time.elapsed().as_millis() as u64;
@@ -644,18 +1073,7 @@ impl AuctionOrchestrator {
}
if pending_requests.is_empty() {
- // An immediate response (for example, an APS-only Prebid no-bid) is
- // a completed provider outcome. Launch failures alone remain a
- // terminal auction error rather than being converted to a 200 no-bid.
- if immediate_response_count > 0 {
- return Ok(responses);
- }
- return Err(Report::new(TrustedServerError::Auction {
- message: format!(
- "All {} configured provider(s) skipped or failed to launch",
- provider_names.len()
- ),
- }));
+ return Ok(responses);
}
let deadline = Duration::from_millis(u64::from(context.timeout_ms));
@@ -672,7 +1090,7 @@ impl AuctionOrchestrator {
// some adapters, buffers the selected response body before returning.
// Backend first-byte and between-bytes timeouts are capped to the
// remaining auction budget in Phase 1. They are transport timers, not
- // absolute wall-clock limits, so connection setup and byte-trickling
+ // absolute wall-clock limits, so connection setup and byte trickling
// remain bounded operational risks rather than strict deadline proof.
let mut remaining = pending_requests;
@@ -729,7 +1147,10 @@ impl AuctionOrchestrator {
auction_response.status,
auction_response.response_time_ms
);
- responses.push(auction_response);
+ responses.push(canonical_provider_response(
+ &state.provider_name,
+ auction_response,
+ ));
}
Err(e) => {
// lgtm[rust/cleartext-logging]
@@ -837,9 +1258,20 @@ impl AuctionOrchestrator {
};
let should_replace = match winning_bids.get(&bid.slot_id) {
- Some(current_winner) => current_winner
- .price
- .is_none_or(|current_price| bid_price > current_price),
+ Some(current_winner) => current_winner.price.is_none_or(|current_price| {
+ bid_price > current_price
+ || (bid_price == current_price
+ && (
+ bid.candidate_provider.as_deref().unwrap_or(&bid.bidder),
+ bid.bid_id.as_deref().unwrap_or_default(),
+ ) < (
+ current_winner
+ .candidate_provider
+ .as_deref()
+ .unwrap_or(¤t_winner.bidder),
+ current_winner.bid_id.as_deref().unwrap_or_default(),
+ ))
+ }),
None => true,
};
@@ -906,23 +1338,6 @@ impl AuctionOrchestrator {
.collect()
}
- /// Get a provider by name.
- fn get_provider(
- &self,
- name: &str,
- ) -> Result<&Arc, Report> {
- self.providers.get(name).ok_or_else(|| {
- log::warn!(
- "Provider '{}' configured but not registered. Available providers: {:?}",
- name,
- self.providers.keys().collect::>()
- );
- Report::new(TrustedServerError::Auction {
- message: format!("Provider '{}' not registered", name),
- })
- })
- }
-
/// Dispatch SSP bid requests without blocking WASM.
///
/// Calls each enabled provider's [`AuctionProvider::request_bids`] (which
@@ -930,28 +1345,31 @@ impl AuctionOrchestrator {
/// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips
/// while WASM continues to `pending_origin.wait()`.
///
- /// Returns [`DispatchAuctionOutcome::NotStarted`] when no providers are configured or
- /// all providers are disabled / over budget. Returns
- /// [`DispatchAuctionOutcome::DispatchFailed`] when provider launch attempts
- /// happened but none could be started.
+ /// The token is returned even when no transport starts. Collection then
+ /// routes zero-budget, launch-failure, disabled, and unconfigured-provider
+ /// cases through the same exhaustive terminal decision builder as ordinary
+ /// responses instead of silently dropping their slot outcomes.
#[must_use]
pub async fn dispatch_auction(
&self,
request: &AuctionRequest,
context: &AuctionContext<'_>,
- ) -> DispatchAuctionOutcome {
+ ) -> DispatchedAuction {
let provider_names = self.config.provider_names();
- if provider_names.is_empty() {
- return DispatchAuctionOutcome::NotStarted;
- }
+ let auction_start = Instant::now();
+ let mut backend_to_provider: HashMap = HashMap::new();
+ let mut pending_requests: Vec = Vec::new();
+ let mut completed_responses: Vec = Vec::new();
+ let mut immediate_response_count = 0usize;
// Mirror run_providers_parallel: reject multi-provider fan-out before
// any request launches when the platform executes `send_async` eagerly
// (e.g. Cloudflare Workers, Spin). Sequential execution would accrue
// the sum of provider latencies before the origin fetch and then fail
// collection with empty bids.
- if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout()
- {
+ let fanout_supported = provider_names.len() <= 1
+ || context.services.http_client().supports_concurrent_fanout();
+ if !fanout_supported {
log::warn!(
"{} auction providers configured, but this platform's HTTP client \
executes requests sequentially — skipping initial-page auction \
@@ -959,16 +1377,14 @@ impl AuctionOrchestrator {
concurrent fan-out support",
provider_names.len(),
);
- return DispatchAuctionOutcome::NotStarted;
+ completed_responses.extend(
+ provider_names
+ .iter()
+ .map(|provider| provider_launch_failed_response(provider, 0)),
+ );
}
- let auction_start = Instant::now();
- let mut backend_to_provider: HashMap = HashMap::new();
- let mut pending_requests: Vec = Vec::new();
- let mut completed_responses: Vec = Vec::new();
- let mut immediate_response_count = 0usize;
-
- for provider_name in provider_names {
+ for provider_name in provider_names.iter().filter(|_| fanout_supported) {
let provider = match self.providers.get(provider_name) {
Some(p) => p,
None => {
@@ -1001,20 +1417,24 @@ impl AuctionOrchestrator {
context.timeout_ms,
provider.provider_name()
);
+ completed_responses.push(provider_timeout_response(
+ provider.provider_name(),
+ auction_start.elapsed().as_millis() as u64,
+ ));
continue;
}
- // Do not require a backend name before dispatch: an immediate
- // provider intentionally has none. Guard predicted names when
- // available; pending requests without either name fail below.
- let predicted_backend_name = provider.backend_name(context.services, effective_timeout);
- if let Some(backend_name) = predicted_backend_name.as_ref()
- && backend_to_provider.contains_key(backend_name)
+ // Pre-launch guard: skip before `request_bids` fires the outbound
+ // send when another provider this auction already claimed the
+ // predicted backend name (see the parallel path). Dropping the
+ // pending handle afterwards would not retract the request.
+ if let Some(predicted) = provider.backend_name(context.services, effective_timeout)
+ && backend_to_provider.contains_key(&predicted)
{
log::warn!(
"Provider '{}' predicted backend name '{}' already belongs to another provider; skipping dispatch",
provider.provider_name(),
- backend_name,
+ predicted,
);
completed_responses
.push(provider_launch_failed_response(provider.provider_name(), 0));
@@ -1035,16 +1455,10 @@ impl AuctionOrchestrator {
request: pending,
parse_state,
}) => {
- let backend_name = pending.backend_name().map(str::to_string).or_else(|| {
- if let Some(backend_name) = predicted_backend_name.as_ref() {
- log::warn!(
- "Provider '{}' pending request returned no backend name; using predicted name '{}'",
- provider.provider_name(),
- backend_name,
- );
- }
- predicted_backend_name.clone()
- });
+ let backend_name = pending
+ .backend_name()
+ .map(str::to_string)
+ .or_else(|| provider.backend_name(context.services, effective_timeout));
let Some(backend_name) = backend_name else {
log::warn!(
"Provider '{}' pending request has no backend name; response cannot be correlated",
@@ -1056,9 +1470,14 @@ impl AuctionOrchestrator {
));
continue;
};
+ // Post-launch defense: a resolved backend name already
+ // claimed by another provider would misattribute that
+ // provider's response, so fail this dispatch attributably
+ // instead of overwriting the correlation entry.
if backend_to_provider.contains_key(&backend_name) {
log::warn!(
- "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping dispatch",
+ "Provider '{}' resolved to backend name '{}' already claimed by another \
+ provider this auction; skipping launch to avoid response misattribution",
provider.provider_name(),
backend_name,
);
@@ -1088,7 +1507,10 @@ impl AuctionOrchestrator {
}
Ok(ProviderRequestOutcome::Immediate(response)) => {
immediate_response_count += 1;
- completed_responses.push(response);
+ completed_responses.push(canonical_provider_response(
+ provider.provider_name(),
+ response,
+ ));
}
Err(e) => {
let response_time_ms = start_time.elapsed().as_millis() as u64;
@@ -1105,18 +1527,6 @@ impl AuctionOrchestrator {
}
}
- if pending_requests.is_empty() && immediate_response_count == 0 {
- return if completed_responses.is_empty() {
- DispatchAuctionOutcome::NotStarted
- } else {
- DispatchAuctionOutcome::DispatchFailed {
- request: request.clone(),
- provider_responses: completed_responses,
- elapsed_ms: auction_start.elapsed().as_millis() as u64,
- }
- };
- }
-
log::info!(
"Dispatched {} SSP request(s) with {} immediate response(s) (timeout: {}ms)",
pending_requests.len(),
@@ -1124,7 +1534,7 @@ impl AuctionOrchestrator {
context.timeout_ms
);
- DispatchAuctionOutcome::Dispatched(DispatchedAuction {
+ DispatchedAuction {
pending_requests,
backend_to_provider,
completed_responses,
@@ -1133,7 +1543,7 @@ impl AuctionOrchestrator {
floor_prices: self.floor_prices_by_slot(request),
provider_request_context: Box::new(snapshot_context_request(context.request)),
request: request.clone(),
- })
+ }
}
/// Collect bid responses from a previously-dispatched auction.
@@ -1226,7 +1636,10 @@ impl AuctionOrchestrator {
auction_response.bids.len(),
auction_response.response_time_ms
);
- responses.push(auction_response);
+ responses.push(canonical_provider_response(
+ &state.provider_name,
+ auction_response,
+ ));
}
Err(e) => {
log::warn!(
@@ -1302,50 +1715,25 @@ impl AuctionOrchestrator {
));
}
backend_to_provider.clear();
-
- let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator {
- match self.providers.get(mediator_name.as_str()) {
- Some(mediator) => {
- // Cap the mediator at whichever is tighter: its own configured
- // timeout or the remaining auction budget (A_deadline). Backend
- // first-byte and between-bytes timeouts bound normal collection, but
- // they are transport timers rather than absolute wall-clock limits:
- // connection setup and byte-trickling can still consume more of the
- // auction budget. Recomputing the remaining budget here prevents the
- // mediator from extending that bounded response hold.
- let remaining = remaining_budget_ms(auction_start, timeout_ms);
+ let normalized = self.normalize_provider_responses(&request, &mut responses);
+ let mut mediation_failed = false;
+ let mut mediator_response = None;
+ let mut mediated_winners = None;
+
+ if let Some(mediator_name) = &self.config.mediator {
+ if let Some(mediator) = self.providers.get(mediator_name.as_str()) {
+ let remaining = remaining_budget_ms(auction_start, timeout_ms);
+ if remaining == 0 {
+ log::warn!(
+ "A_deadline exhausted before mediator '{}' — using direct fallback",
+ mediator.provider_name(),
+ );
+ mediation_failed = true;
+ } else {
let mediator_timeout = services
.backend()
.canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms());
- if mediator_timeout == 0 {
- log::warn!(
- "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation",
- mediator.provider_name(),
- responses.len(),
- );
- let winning = self.select_winning_bids(&responses, &floor_prices);
- return OrchestrationResult {
- provider_responses: responses,
- mediator_response: None,
- winning_bids: winning,
- total_time_ms: auction_start.elapsed().as_millis() as u64,
- metadata: HashMap::new(),
- };
- }
let mediator_start = Instant::now();
- log::info!(
- "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)",
- mediator.provider_name(),
- mediator_timeout,
- remaining,
- mediator.timeout_ms(),
- );
- // The mediator runs on the collect path. See the doc-comment on
- // `AuctionContext::request`: the real client request was already
- // consumed by `send_async` during dispatch, so we substitute a
- // canonical placeholder URL. Any future mediator that needs real
- // client headers must snapshot them at dispatch time onto
- // `DispatchedAuction` rather than reading `context.request` here.
let placeholder = http::Request::builder()
.uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL)
.body(edgezero_core::body::Body::empty())
@@ -1357,93 +1745,84 @@ impl AuctionOrchestrator {
provider_responses: Some(&responses),
services: context.services,
};
- let mediator_response =
+ let raw_response =
match mediator.request_bids(&request, &mediator_context).await {
Ok(ProviderRequestOutcome::Immediate(response)) => Some(response),
Ok(ProviderRequestOutcome::Pending {
request: pending,
parse_state,
- }) => match services.http_client().wait(pending).await.change_context(
- TrustedServerError::Auction {
- message: format!(
- "Mediator {} request failed",
- mediator.provider_name()
- ),
- },
- ) {
- Ok(platform_resp) => match mediator
+ }) => match services.http_client().wait(pending).await {
+ Ok(platform_response) => mediator
.parse_response_with_context_and_state(
- platform_resp,
+ platform_response,
mediator_start.elapsed().as_millis() as u64,
&request,
&mediator_context,
parse_state.as_deref(),
)
.await
- {
- Ok(response) => Some(response),
- Err(error) => {
+ .inspect_err(|error| {
log::warn!(
- "Mediator '{}' parse failed: {:?}",
- mediator.provider_name(),
- error
+ "Mediator '{}' parse failed: {error:?}",
+ mediator.provider_name()
);
- None
- }
- },
+ })
+ .ok(),
Err(error) => {
- log::warn!("Mediator request failed: {:?}", error);
+ log::warn!("Mediator request failed: {error:?}");
None
}
},
Err(error) => {
log::warn!(
- "Mediator '{}' failed to dispatch: {:?}",
- mediator.provider_name(),
- error
+ "Mediator '{}' failed to dispatch: {error:?}",
+ mediator.provider_name()
);
None
}
};
-
- if let Some(mediator_response) = mediator_response {
- let winning = mediator_response
- .bids
- .iter()
- .filter_map(|bid| {
- if bid.price.is_none() {
- log::warn!(
- "Mediator '{}' returned bid for slot '{}' without decoded price - skipping",
- mediator.provider_name(),
- bid.slot_id
- );
- None
- } else {
- Some((bid.slot_id.clone(), bid.clone()))
- }
- })
- .collect();
- let winning = self.apply_floor_prices(winning, &floor_prices);
- (Some(mediator_response), winning)
+ if let Some(raw_response) = raw_response {
+ mediator_response = Some(raw_response.clone());
+ match Self::resolve_mediator_candidates(
+ raw_response,
+ &normalized.candidates,
+ ) {
+ Ok(resolved) => {
+ let selected = resolved
+ .bids
+ .iter()
+ .map(|bid| (bid.slot_id.clone(), bid.clone()))
+ .collect();
+ mediated_winners =
+ Some(self.apply_floor_prices(selected, &floor_prices));
+ mediator_response = Some(resolved);
+ }
+ Err(()) => mediation_failed = true,
+ }
} else {
- (None, self.select_winning_bids(&responses, &floor_prices))
+ mediation_failed = true;
}
}
- None => {
- // lgtm[rust/cleartext-logging]
- // The mediator name is a static config identifier, not a secret.
- log::warn!("Mediator '{}' not registered", mediator_name);
- (None, self.select_winning_bids(&responses, &floor_prices))
- }
+ } else {
+ log::warn!("Mediator '{}' not registered", mediator_name);
+ mediation_failed = true;
}
- } else {
- (None, self.select_winning_bids(&responses, &floor_prices))
- };
+ }
+
+ let winning_bids =
+ mediated_winners.unwrap_or_else(|| self.select_winning_bids(&responses, &floor_prices));
+ let decision_set = self.build_decision_set(
+ &request,
+ &normalized.outcomes,
+ &winning_bids,
+ mediation_failed,
+ );
OrchestrationResult {
provider_responses: responses,
mediator_response,
winning_bids,
+ decision_set,
total_time_ms: auction_start.elapsed().as_millis() as u64,
metadata: HashMap::new(),
}
@@ -1465,6 +1844,8 @@ pub struct OrchestrationResult {
pub mediator_response: Option,
/// Winning bids per slot
pub winning_bids: HashMap,
+ /// Exact ordered decision for every requested slot.
+ pub decision_set: AuctionDecisionSetV1,
/// Total orchestration time in milliseconds
pub total_time_ms: u64,
/// Metadata about the auction
@@ -1501,12 +1882,14 @@ mod tests {
use web_time::Instant;
use crate::auction::config::AuctionConfig;
- use crate::auction::orchestrator::DispatchAuctionOutcome;
- use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome};
+ use crate::auction::provider::{
+ AuctionProvider, ProviderRequestOutcome, ProviderSlotDisposition,
+ };
use crate::auction::test_support::create_test_auction_context;
use crate::auction::types::{
- AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest,
- AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo,
+ AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionDropReason,
+ AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidRenderSourceV1,
+ BidStatus, MediaType, PublisherInfo, SlotAuctionDecisionV1, UserInfo,
};
use crate::error::TrustedServerError;
use crate::platform::test_support::{
@@ -1520,9 +1903,10 @@ mod tests {
use crate::test_support::tests::crate_test_settings_str;
use error_stack::{Report, ResultExt};
use std::collections::{HashMap, HashSet};
+ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
- use super::AuctionOrchestrator;
+ use super::{AuctionIdentityGenerator, AuctionOrchestrator};
// ---------------------------------------------------------------------------
// Minimal test double for AuctionProvider
@@ -1531,6 +1915,46 @@ mod tests {
struct StubAuctionProvider {
name: &'static str,
backend: &'static str,
+ configured_timeout_ms: u32,
+ predicted_timeouts: Option>>>,
+ request_timeouts: Option>>>,
+ }
+
+ impl StubAuctionProvider {
+ fn new(name: &'static str, backend: &'static str) -> Self {
+ Self {
+ name,
+ backend,
+ configured_timeout_ms: 125,
+ predicted_timeouts: None,
+ request_timeouts: None,
+ }
+ }
+
+ fn recording(
+ name: &'static str,
+ backend: &'static str,
+ configured_timeout_ms: u32,
+ predicted_timeouts: Arc>>,
+ request_timeouts: Arc>>,
+ ) -> Self {
+ Self {
+ name,
+ backend,
+ configured_timeout_ms,
+ predicted_timeouts: Some(predicted_timeouts),
+ request_timeouts: Some(request_timeouts),
+ }
+ }
+
+ fn record(slot: &Option>>>, timeout_ms: u32) {
+ if let Some(observed) = slot {
+ observed
+ .lock()
+ .expect("should lock observed timeouts")
+ .push(timeout_ms);
+ }
+ }
}
#[async_trait::async_trait(?Send)]
@@ -1544,6 +1968,7 @@ mod tests {
_request: &AuctionRequest,
context: &AuctionContext<'_>,
) -> Result> {
+ Self::record(&self.request_timeouts, context.timeout_ms);
let req = PlatformHttpRequest::new(
http::Request::builder()
.method("POST")
@@ -1594,82 +2019,18 @@ mod tests {
.with_metadata("context_timeout_ms", serde_json::json!(context.timeout_ms)))
}
- fn timeout_ms(&self) -> u32 {
- 125
- }
-
- fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option {
- Some(self.backend.to_string())
- }
- }
-
- struct RecordingTimeoutProvider {
- name: &'static str,
- backend: &'static str,
- configured_timeout_ms: u32,
- predicted: Arc>>,
- requested: Arc>>,
- }
-
- #[async_trait::async_trait(?Send)]
- impl AuctionProvider for RecordingTimeoutProvider {
- fn provider_name(&self) -> &'static str {
- self.name
- }
-
- async fn request_bids(
- &self,
- _request: &AuctionRequest,
- context: &AuctionContext<'_>,
- ) -> Result> {
- self.requested
- .lock()
- .expect("should lock requested timeouts")
- .push(context.timeout_ms);
- let request = PlatformHttpRequest::new(
- http::Request::builder()
- .method("POST")
- .uri("https://example.com/bid")
- .body(edgezero_core::body::Body::empty())
- .expect("should build recording request"),
- self.backend,
- );
- context
- .services
- .http_client()
- .send_async(request)
- .await
- .change_context(TrustedServerError::Auction {
- message: "recording launch failed".to_string(),
- })
- .map(ProviderRequestOutcome::pending)
- }
-
- async fn parse_response(
- &self,
- _response: PlatformResponse,
- response_time_ms: u64,
- ) -> Result> {
- Ok(AuctionResponse::success(
- self.name,
- vec![],
- response_time_ms,
- ))
- }
-
fn timeout_ms(&self) -> u32 {
self.configured_timeout_ms
}
fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option {
- self.predicted
- .lock()
- .expect("should lock predicted timeouts")
- .push(timeout_ms);
+ Self::record(&self.predicted_timeouts, timeout_ms);
Some(self.backend.to_string())
}
}
+ /// Provider whose `backend_name` prediction deliberately differs from the
+ /// backend name its `request_bids` puts on the wire.
struct DivergentBackendProvider {
name: &'static str,
predicted: &'static str,
@@ -1687,7 +2048,7 @@ mod tests {
_request: &AuctionRequest,
context: &AuctionContext<'_>,
) -> Result> {
- let request = PlatformHttpRequest::new(
+ let req = PlatformHttpRequest::new(
http::Request::builder()
.method("POST")
.uri("https://example.com/bid")
@@ -1698,7 +2059,7 @@ mod tests {
context
.services
.http_client()
- .send_async(request)
+ .send_async(req)
.await
.change_context(TrustedServerError::Auction {
message: "divergent launch failed".to_string(),
@@ -1759,14 +2120,14 @@ mod tests {
configured_timeout_ms: u32,
predicted: &Arc>>,
requested: &Arc>>,
- ) -> RecordingTimeoutProvider {
- RecordingTimeoutProvider {
+ ) -> StubAuctionProvider {
+ StubAuctionProvider::recording(
name,
backend,
configured_timeout_ms,
- predicted: Arc::clone(predicted),
- requested: Arc::clone(requested),
- }
+ Arc::clone(predicted),
+ Arc::clone(requested),
+ )
}
/// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring
@@ -1776,7 +2137,7 @@ mod tests {
fn auction_bid(bidder: &str, price: f64) -> Bid {
let renderer = (bidder == "aps").then(|| {
- BidRenderer::Aps(ApsRendererV1 {
+ BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "aps-selected-bid".to_string(),
@@ -1790,6 +2151,9 @@ mod tests {
});
Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(price),
currency: "USD".to_string(),
creative: renderer
@@ -1810,28 +2174,131 @@ mod tests {
cache_path: None,
metadata: HashMap::new(),
}
- }
+ }
+
+ struct CounterIdentityGenerator {
+ draws: AtomicUsize,
+ }
+
+ impl CounterIdentityGenerator {
+ fn new() -> Self {
+ Self {
+ draws: AtomicUsize::new(0),
+ }
+ }
+ }
+
+ impl AuctionIdentityGenerator for CounterIdentityGenerator {
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()> {
+ destination.fill(0);
+ let draw = self.draws.fetch_add(1, Ordering::SeqCst) + 1;
+ let last = destination.last_mut().ok_or(())?;
+ *last = u8::try_from(draw).map_err(|_| ())?;
+ Ok(())
+ }
+ }
+
+ struct FixedIdentityGenerator {
+ draws: AtomicUsize,
+ }
+
+ impl FixedIdentityGenerator {
+ fn new() -> Self {
+ Self {
+ draws: AtomicUsize::new(0),
+ }
+ }
+ }
+
+ impl AuctionIdentityGenerator for FixedIdentityGenerator {
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()> {
+ self.draws.fetch_add(1, Ordering::SeqCst);
+ destination.fill(0);
+ Ok(())
+ }
+ }
+
+ fn mediated_bid(nurl: Option) -> Bid {
+ Bid {
+ slot_id: "header-banner".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
+ price: Some(2.5),
+ currency: "USD".to_string(),
+ creative: Some("ad
".to_string()),
+ adomain: None,
+ bidder: "mediator".to_string(),
+ width: 728,
+ height: 90,
+ nurl: nurl.clone(),
+ burl: nurl,
+ bid_id: None,
+ ad_id: Some("creative-123".to_string()),
+ creative_id: None,
+ renderer: None,
+ cache_id: Some("cache-abc".to_string()),
+ cache_host: None,
+ cache_path: None,
+ metadata: HashMap::new(),
+ }
+ }
+
+ struct SourceBidProvider {
+ nurl: &'static str,
+ }
+
+ #[async_trait::async_trait(?Send)]
+ impl AuctionProvider for SourceBidProvider {
+ fn provider_name(&self) -> &'static str {
+ "bidder"
+ }
+
+ async fn request_bids(
+ &self,
+ _request: &AuctionRequest,
+ context: &AuctionContext<'_>,
+ ) -> Result> {
+ let request = PlatformHttpRequest::new(
+ http::Request::builder()
+ .method("POST")
+ .uri("https://example.com/bid")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build source bid request"),
+ "bidder-backend",
+ );
+ context
+ .services
+ .http_client()
+ .send_async(request)
+ .await
+ .change_context(TrustedServerError::Auction {
+ message: "source bidder launch failed".to_string(),
+ })
+ .map(ProviderRequestOutcome::pending)
+ }
+
+ async fn parse_response(
+ &self,
+ _response: PlatformResponse,
+ response_time_ms: u64,
+ ) -> Result> {
+ let mut bid = mediated_bid(Some(self.nurl.to_string()));
+ bid.price = Some(1.0);
+ bid.bid_id = Some("source-bid-id".to_string());
+ Ok(AuctionResponse::success(
+ self.provider_name(),
+ vec![bid],
+ response_time_ms,
+ ))
+ }
+
+ fn timeout_ms(&self) -> u32 {
+ 2000
+ }
- fn mediated_bid(nurl: Option) -> Bid {
- Bid {
- slot_id: "header-banner".to_string(),
- price: Some(2.5),
- currency: "USD".to_string(),
- creative: Some("ad
".to_string()),
- adomain: None,
- bidder: "mediator".to_string(),
- width: 728,
- height: 90,
- nurl: nurl.clone(),
- burl: nurl,
- bid_id: None,
- ad_id: Some("creative-123".to_string()),
- creative_id: None,
- renderer: None,
- cache_id: Some("cache-abc".to_string()),
- cache_host: None,
- cache_path: None,
- metadata: HashMap::new(),
+ fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option {
+ Some("bidder-backend".to_string())
}
}
@@ -1883,12 +2350,18 @@ mod tests {
_response: PlatformResponse,
response_time_ms: u64,
_request: &AuctionRequest,
- _context: &AuctionContext<'_>,
+ context: &AuctionContext<'_>,
) -> Result> {
- // Context-aware path: restores nurl/ad_id from the collected SSP bids.
+ let mut selection = context
+ .provider_responses
+ .and_then(|responses| responses.first())
+ .and_then(|response| response.bids.first())
+ .cloned()
+ .expect("should provide one source candidate to mediator");
+ selection.price = Some(2.5);
Ok(AuctionResponse::success(
"mediator",
- vec![mediated_bid(Some("https://nurl.example/win".to_string()))],
+ vec![selection],
response_time_ms,
))
}
@@ -1913,13 +2386,18 @@ mod tests {
async fn request_bids(
&self,
_request: &AuctionRequest,
- _context: &AuctionContext<'_>,
+ context: &AuctionContext<'_>,
) -> Result> {
+ let mut selection = context
+ .provider_responses
+ .and_then(|responses| responses.first())
+ .and_then(|response| response.bids.first())
+ .cloned()
+ .expect("should provide one source candidate to immediate mediator");
+ selection.price = Some(2.5);
Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success(
self.provider_name(),
- vec![mediated_bid(Some(
- "https://nurl.example/immediate".to_string(),
- ))],
+ vec![selection],
0,
)))
}
@@ -1958,9 +2436,8 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "bidder",
- backend: "bidder-backend",
+ orchestrator.register_provider(Arc::new(SourceBidProvider {
+ nurl: "https://nurl.example/win",
}));
orchestrator.register_provider(Arc::new(CacheRestoringMediator));
@@ -2014,9 +2491,8 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "bidder",
- backend: "bidder-backend",
+ orchestrator.register_provider(Arc::new(SourceBidProvider {
+ nurl: "https://nurl.example/immediate",
}));
orchestrator.register_provider(Arc::new(ImmediateMediator));
let request = create_test_auction_request();
@@ -2031,11 +2507,7 @@ mod tests {
};
let result = if split {
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("bidder request should dispatch");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await
@@ -2105,6 +2577,374 @@ mod tests {
}
}
+ fn one_slot_request() -> AuctionRequest {
+ let mut request = create_test_auction_request();
+ request.slots = vec![AdSlot {
+ id: "slot-1".to_string(),
+ formats: vec![AdFormat {
+ media_type: MediaType::Banner,
+ width: 300,
+ height: 250,
+ }],
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ }];
+ request
+ }
+
+ fn enabled_config(providers: &[&str]) -> AuctionConfig {
+ AuctionConfig {
+ enabled: true,
+ providers: providers
+ .iter()
+ .map(|provider| (*provider).to_string())
+ .collect(),
+ ..AuctionConfig::default()
+ }
+ }
+
+ #[test]
+ fn normalized_provider_outcomes_cover_every_dispatched_slot() {
+ let generator = Arc::new(CounterIdentityGenerator::new());
+ let mut orchestrator =
+ AuctionOrchestrator::with_identity_generator(enabled_config(&["alpha"]), generator);
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let request = one_slot_request();
+ let mut candidate = auction_bid("aps", 2.0);
+ candidate.slot_id = "slot-1".to_string();
+ let mut responses = vec![AuctionResponse::success("alpha", vec![candidate], 10)];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert_eq!(normalized.outcomes.len(), 1);
+ assert_eq!(normalized.outcomes[0].provider, "alpha");
+ assert_eq!(normalized.outcomes[0].slot, "slot-1");
+ assert!(matches!(
+ &normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Candidates(candidates)
+ if candidates.len() == 1
+ && candidates[0].candidate_id.as_deref().is_some_and(|id| id.len() == 12)
+ ));
+
+ let mut no_bid = vec![AuctionResponse::no_bid("alpha", 10)];
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut no_bid);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::NoBid
+ ));
+
+ let mut timeout = vec![super::provider_timeout_response("alpha", 10)];
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut timeout);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout)
+ ));
+
+ let mut attributable_invalid = vec![AuctionResponse::no_bid("alpha", 10)];
+ attributable_invalid[0].metadata.insert(
+ "invalid_slots".to_string(),
+ serde_json::json!({"slot-1": "invalid_provider_response"}),
+ );
+ let normalized =
+ orchestrator.normalize_provider_responses(&request, &mut attributable_invalid);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InvalidProviderResponse)
+ ));
+ }
+
+ #[test]
+ fn provider_failure_classes_map_to_closed_slot_reasons() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let request = one_slot_request();
+
+ for (error_type, expected) in [
+ (
+ super::ERROR_TYPE_LAUNCH_FAILED,
+ AuctionSlotFailureReason::ProviderError,
+ ),
+ (
+ super::ERROR_TYPE_TRANSPORT,
+ AuctionSlotFailureReason::ProviderError,
+ ),
+ (
+ super::ERROR_TYPE_HTTP_STATUS,
+ AuctionSlotFailureReason::ProviderError,
+ ),
+ (
+ super::ERROR_TYPE_PARSE_RESPONSE,
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ ),
+ ] {
+ let error = Report::new(TrustedServerError::Auction {
+ message: "provider failed".to_string(),
+ });
+ let mut responses = vec![super::provider_error_response(
+ "alpha", 1, error_type, &error,
+ )];
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(reason) if reason == expected
+ ));
+ }
+ }
+
+ #[test]
+ fn candidate_collision_exhaustion_fails_only_the_affected_slot() {
+ let generator = Arc::new(FixedIdentityGenerator::new());
+ let mut orchestrator = AuctionOrchestrator::with_identity_generator(
+ enabled_config(&["alpha"]),
+ generator.clone(),
+ );
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let mut request = one_slot_request();
+ request.slots.push(AdSlot {
+ id: "slot-2".to_string(),
+ formats: request.slots[0].formats.clone(),
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ });
+ let mut first = auction_bid("aps", 2.0);
+ first.slot_id = "slot-1".to_string();
+ first.bid_id = Some("upstream-1".to_string());
+ let mut second = auction_bid("aps", 1.0);
+ second.slot_id = "slot-2".to_string();
+ second.bid_id = Some("upstream-2".to_string());
+ let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert_eq!(generator.draws.load(Ordering::SeqCst), 10);
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Candidates(_)
+ ));
+ assert!(matches!(
+ normalized.outcomes[1].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError)
+ ));
+ }
+
+ #[test]
+ fn candidate_collision_exhaustion_discards_earlier_sibling_for_same_slot() {
+ let generator = Arc::new(FixedIdentityGenerator::new());
+ let mut orchestrator = AuctionOrchestrator::with_identity_generator(
+ enabled_config(&["alpha"]),
+ generator.clone(),
+ );
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let request = one_slot_request();
+ let mut first = auction_bid("aps", 2.0);
+ first.bid_id = Some("upstream-1".to_string());
+ let mut second = auction_bid("aps", 1.0);
+ second.bid_id = Some("upstream-2".to_string());
+ let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert_eq!(generator.draws.load(Ordering::SeqCst), 10);
+ assert!(responses[0].bids.is_empty());
+ assert!(normalized.candidates.is_empty());
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError)
+ ));
+ }
+
+ #[test]
+ fn per_bid_drop_does_not_poison_an_unrelated_missing_slot() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ let mut request = one_slot_request();
+ request.slots.push(AdSlot {
+ id: "slot-2".to_string(),
+ formats: request.slots[0].formats.clone(),
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ });
+ let mut valid = auction_bid("aps", 2.0);
+ valid.bid_id = Some("upstream-1".to_string());
+ let mut response = AuctionResponse::success("alpha", vec![valid], 10);
+ response = response.with_drop_reason(AuctionDropReason::InvalidDimensions);
+ let mut responses = vec![response];
+
+ let normalized = orchestrator.normalize_provider_responses(&request, &mut responses);
+
+ assert!(matches!(
+ normalized.outcomes[0].disposition,
+ ProviderSlotDisposition::Candidates(_)
+ ));
+ assert!(matches!(
+ normalized.outcomes[1].disposition,
+ ProviderSlotDisposition::NoBid
+ ));
+ }
+
+ #[test]
+ fn final_decisions_are_request_ordered_and_use_closed_failure_priority() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta")));
+ let request = one_slot_request();
+ let outcomes = vec![
+ crate::auction::provider::ProviderSlotOutcome {
+ provider: "alpha".to_string(),
+ slot: "slot-1".to_string(),
+ disposition: ProviderSlotDisposition::Failed(
+ AuctionSlotFailureReason::ProviderTimeout,
+ ),
+ },
+ crate::auction::provider::ProviderSlotOutcome {
+ provider: "zeta".to_string(),
+ slot: "slot-1".to_string(),
+ disposition: ProviderSlotDisposition::Failed(
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ ),
+ },
+ ];
+
+ let decisions = orchestrator.build_decision_set(&request, &outcomes, &HashMap::new(), true);
+
+ assert_eq!(decisions.results.len(), 1);
+ assert!(matches!(
+ &decisions.results[0],
+ SlotAuctionDecisionV1::Failed { slot, reason }
+ if slot == "slot-1" && *reason == AuctionSlotFailureReason::MediationFailed
+ ));
+ assert_eq!(
+ serde_json::to_string(&decisions).expect("decision set should serialize"),
+ r#"{"version":1,"auctionId":"test-auction-123","results":[{"slot":"slot-1","outcome":"failed","reason":"mediation_failed"}]}"#
+ );
+ assert_eq!(
+ serde_json::to_string(&SlotAuctionDecisionV1::Failed {
+ slot: "slot-1".to_string(),
+ reason: AuctionSlotFailureReason::IdentityGenerationFailed,
+ })
+ .expect("direct identity-generation failure should serialize"),
+ r#"{"slot":"slot-1","outcome":"failed","reason":"identity_generation_failed"}"#
+ );
+ }
+
+ #[test]
+ fn deliverable_winner_beats_a_sibling_provider_failure() {
+ let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"]));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha")));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta")));
+ let request = one_slot_request();
+ let mut winner = auction_bid("alpha-seat", 2.0);
+ winner.candidate_id = Some("AAAAAAAAAAAA".to_string());
+ winner.candidate_provider = Some("alpha".to_string());
+ winner.bid_id = Some("upstream-alpha".to_string());
+ let outcomes = vec![crate::auction::provider::ProviderSlotOutcome {
+ provider: "zeta".to_string(),
+ slot: "slot-1".to_string(),
+ disposition: ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout),
+ }];
+
+ let decisions = orchestrator.build_decision_set(
+ &request,
+ &outcomes,
+ &HashMap::from([("slot-1".to_string(), winner)]),
+ true,
+ );
+
+ assert_eq!(
+ decisions.results,
+ vec![SlotAuctionDecisionV1::Winner {
+ slot: "slot-1".to_string(),
+ candidate_id: "AAAAAAAAAAAA".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn direct_ties_ignore_arrival_and_candidate_ids() {
+ let orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"]));
+ let mut alpha = auction_bid("seat-a", 2.0);
+ alpha.candidate_provider = Some("alpha".to_string());
+ alpha.candidate_id = Some("zzzzzzzzzzzz".to_string());
+ alpha.bid_id = Some("upstream-z".to_string());
+ let mut zeta = auction_bid("seat-z", 2.0);
+ zeta.candidate_provider = Some("zeta".to_string());
+ zeta.candidate_id = Some("AAAAAAAAAAAA".to_string());
+ zeta.bid_id = Some("upstream-a".to_string());
+ let left = AuctionResponse::success("alpha", vec![alpha], 1);
+ let right = AuctionResponse::success("zeta", vec![zeta], 1);
+
+ for responses in [vec![left.clone(), right.clone()], vec![right, left]] {
+ let winners = orchestrator.select_winning_bids(&responses, &HashMap::new());
+ assert_eq!(
+ winners["slot-1"].candidate_provider.as_deref(),
+ Some("alpha")
+ );
+ }
+ }
+
+ #[test]
+ fn mediator_can_select_only_known_candidate_provenance() {
+ let mut source = auction_bid("aps", 1.0);
+ source.candidate_id = Some("AAAAAAAAAAAA".to_string());
+ source.candidate_provider = Some("aps".to_string());
+ source.nurl = Some("https://source.example/win".to_string());
+ let candidates = HashMap::from([("AAAAAAAAAAAA".to_string(), source.clone())]);
+ let mut selection = source.clone();
+ selection.price = Some(9.0);
+
+ let resolved = AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![selection], 2),
+ &candidates,
+ )
+ .expect("known candidate should resolve");
+ assert_eq!(resolved.bids[0].price, Some(9.0));
+ assert_eq!(resolved.bids[0].width, source.width);
+ assert_eq!(resolved.bids[0].height, source.height);
+ assert_eq!(resolved.bids[0].renderer, source.renderer);
+ assert_eq!(resolved.bids[0].nurl, source.nurl);
+
+ let mut substituted = source.clone();
+ substituted.price = Some(9.0);
+ substituted.width = 1;
+ assert!(
+ AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![substituted], 2),
+ &candidates,
+ )
+ .is_err(),
+ "mediator source-field substitutions should fail provenance validation"
+ );
+
+ let mut second_source = source.clone();
+ second_source.candidate_id = Some("BBBBBBBBBBBB".to_string());
+ second_source.bid_id = Some("upstream-2".to_string());
+ let same_slot_candidates = HashMap::from([
+ ("AAAAAAAAAAAA".to_string(), source.clone()),
+ ("BBBBBBBBBBBB".to_string(), second_source.clone()),
+ ]);
+ assert!(
+ AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![source.clone(), second_source], 2),
+ &same_slot_candidates,
+ )
+ .is_err(),
+ "a mediator may select at most one candidate for a slot"
+ );
+
+ let mut unknown = source;
+ unknown.candidate_id = Some("BBBBBBBBBBBB".to_string());
+ assert!(
+ AuctionOrchestrator::resolve_mediator_candidates(
+ AuctionResponse::success("mediator", vec![unknown], 2),
+ &candidates,
+ )
+ .is_err()
+ );
+ }
+
fn create_test_settings() -> crate::settings::Settings {
let settings_str = crate_test_settings_str();
crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings")
@@ -2216,6 +3056,17 @@ mod tests {
assert_eq!(result.provider_responses.len(), 1);
assert_eq!(result.provider_responses[0].status, BidStatus::NoBid);
assert!(result.winning_bids.is_empty());
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::NoBid {
+ slot: "header-banner".to_string(),
+ },
+ SlotAuctionDecisionV1::NoBid {
+ slot: "sidebar".to_string(),
+ },
+ ]
+ );
}
#[tokio::test]
@@ -2233,12 +3084,9 @@ mod tests {
let downstream = http::Request::new(edgezero_core::body::Body::empty());
let context = immediate_test_context(&settings, &downstream, &services);
- let DispatchAuctionOutcome::Dispatched(dispatched) = orchestrator
+ let dispatched = orchestrator
.dispatch_auction(&create_test_auction_request(), &context)
- .await
- else {
- panic!("enabled immediate provider should dispatch");
- };
+ .await;
let result = orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
@@ -2246,6 +3094,17 @@ mod tests {
assert_eq!(result.provider_responses.len(), 1);
assert_eq!(result.provider_responses[0].status, BidStatus::NoBid);
assert!(result.winning_bids.is_empty());
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::NoBid {
+ slot: "header-banner".to_string(),
+ },
+ SlotAuctionDecisionV1::NoBid {
+ slot: "sidebar".to_string(),
+ },
+ ]
+ );
}
#[tokio::test]
@@ -2259,10 +3118,10 @@ mod tests {
};
let mut orchestrator = AuctionOrchestrator::new(config);
orchestrator.register_provider(Arc::new(ImmediateNoBidProvider));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "pending",
- backend: "pending-backend",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "pending",
+ "pending-backend",
+ )));
let stub = Arc::new(StubHttpClient::new());
stub.push_response(200, b"{}".to_vec());
let services = build_services_with_http_client(stub);
@@ -2272,11 +3131,7 @@ mod tests {
let request = create_test_auction_request();
let result = if split {
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("mixed immediate/pending auction should dispatch");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await
@@ -2406,6 +3261,9 @@ mod tests {
"slot-1".to_string(),
Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(0.50),
currency: "USD".to_string(),
creative: Some("Ad
".to_string()),
@@ -2429,6 +3287,9 @@ mod tests {
"slot-2".to_string(),
Bid {
slot_id: "slot-2".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(2.00),
currency: "USD".to_string(),
creative: Some("Ad
".to_string()),
@@ -2499,14 +3360,25 @@ mod tests {
let result = orchestrator.run_auction(&request, &context).await;
- assert!(result.is_err());
- let err = result.unwrap_err();
- assert!(format!("{}", err).contains("No providers configured"));
+ let result = result.expect("should return one decision per requested slot");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::SlotNotEligible,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::SlotNotEligible,
+ },
+ ]
+ );
});
}
#[test]
- fn provider_launch_failures_error_when_no_requests_launch() {
+ fn provider_launch_failures_are_explicit_when_no_requests_launch() {
futures::executor::block_on(async {
let config = AuctionConfig {
enabled: true,
@@ -2526,16 +3398,20 @@ mod tests {
.expect("should build request");
let context = create_test_auction_context(&settings, &req, 2000);
- let error = orchestrator
- .run_auction(&request, &context)
- .await
- .expect_err("should fail when every provider launch fails");
-
- assert!(
- error
- .to_string()
- .contains("All 1 configured provider(s) skipped or failed to launch"),
- "should explain that no configured provider request launched"
+ let result = orchestrator.run_auction(&request, &context).await;
+ let result = result.expect("should preserve launch failures as slot decisions");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ ]
);
});
}
@@ -2579,14 +3455,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "shared-backend",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "shared-backend",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "shared-backend",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "shared-backend",
+ )));
let stub = Arc::new(StubHttpClient::new());
stub.push_response(200, b"{}".to_vec());
let services = build_services_with_http_client(stub);
@@ -2596,11 +3472,7 @@ mod tests {
let request = create_test_auction_request();
let result = if split {
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch the first provider");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await
@@ -2721,13 +3593,17 @@ mod tests {
#[test]
fn zero_canonical_timeout_skips_parallel_launch() {
futures::executor::block_on(async {
+ // A platform that canonicalizes to zero signals "budget exhausted";
+ // the orchestrator must skip the launch and retain an attributable
+ // timeout decision for every eligible requested slot.
+ let stub = Arc::new(StubHttpClient::new());
let calls = Arc::new(Mutex::new(Vec::new()));
let services = build_services_with_backend_and_http_client(
Arc::new(CanonicalTimeoutBackend {
canonical_ms: 0,
calls,
}),
- Arc::new(StubHttpClient::new()),
+ stub,
);
let predicted = Arc::new(Mutex::new(Vec::new()));
let requested = Arc::new(Mutex::new(Vec::new()));
@@ -2747,14 +3623,80 @@ mod tests {
let settings = create_test_settings();
let downstream = http::Request::new(edgezero_core::body::Body::empty());
let context = immediate_test_context(&settings, &downstream, &services);
+ let request = create_test_auction_request();
let result = orchestrator
- .run_auction(&create_test_auction_request(), &context)
+ .run_auction(&request, &context)
+ .await
+ .expect("should preserve an exhausted budget as slot decisions");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ ]
+ );
+ });
+ }
+
+ #[test]
+ fn zero_canonical_timeout_is_attributable_in_split_dispatch() {
+ futures::executor::block_on(async {
+ let stub = Arc::new(StubHttpClient::new());
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ let services = build_services_with_backend_and_http_client(
+ Arc::new(CanonicalTimeoutBackend {
+ canonical_ms: 0,
+ calls,
+ }),
+ stub,
+ );
+ let predicted = Arc::new(Mutex::new(Vec::new()));
+ let requested = Arc::new(Mutex::new(Vec::new()));
+ let mut orchestrator = AuctionOrchestrator::new(AuctionConfig {
+ enabled: true,
+ providers: vec!["bidder".to_string()],
+ timeout_ms: 2000,
+ ..Default::default()
+ });
+ orchestrator.register_provider(Arc::new(recording_provider(
+ "bidder",
+ "bidder-backend",
+ 1000,
+ &predicted,
+ &requested,
+ )));
+ let settings = create_test_settings();
+ let downstream = http::Request::new(edgezero_core::body::Body::empty());
+ let context = immediate_test_context(&settings, &downstream, &services);
+ let request = create_test_auction_request();
+
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
+ let result = orchestrator
+ .collect_dispatched_auction(dispatched, &services, &context)
.await;
- assert!(result.is_err(), "zero budget should skip every provider");
assert!(predicted.lock().expect("should lock predicted").is_empty());
assert!(requested.lock().expect("should lock requested").is_empty());
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderTimeout,
+ },
+ ]
+ );
});
}
@@ -2781,10 +3723,10 @@ mod tests {
timeout_ms: 2000,
..Default::default()
});
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "bidder",
- backend: "bidder-backend",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "bidder",
+ "bidder-backend",
+ )));
orchestrator.register_provider(Arc::new(recording_provider(
"mediator",
"mediator-backend",
@@ -2850,11 +3792,7 @@ mod tests {
let context = immediate_test_context(&settings, &downstream, &services);
let request = create_test_auction_request();
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch bidder request");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
@@ -2902,18 +3840,21 @@ mod tests {
let context = immediate_test_context(&settings, &downstream, &services);
let request = create_test_auction_request();
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch provider");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
let result = orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
- assert!(result.provider_responses.iter().any(|response| {
- response.provider == "provider-a" && response.status == BidStatus::Success
- }));
+ let provider_a = result
+ .provider_responses
+ .iter()
+ .find(|response| response.provider == "provider-a")
+ .expect("should have provider-a response");
+ assert_eq!(
+ provider_a.status,
+ BidStatus::Success,
+ "response should correlate by the resolved backend name, not the prediction"
+ );
});
}
@@ -2945,21 +3886,23 @@ mod tests {
let context = immediate_test_context(&settings, &downstream, &services);
let request = create_test_auction_request();
- let DispatchAuctionOutcome::Dispatched(dispatched) =
- orchestrator.dispatch_auction(&request, &context).await
- else {
- panic!("should dispatch first provider");
- };
+ let dispatched = orchestrator.dispatch_auction(&request, &context).await;
let result = orchestrator
.collect_dispatched_auction(dispatched, &services, &context)
.await;
- assert!(result.provider_responses.iter().any(|response| {
- response.provider == "provider-a" && response.status == BidStatus::Success
- }));
- assert!(result.provider_responses.iter().any(|response| {
- response.provider == "provider-b" && response.status == BidStatus::Error
- }));
+ let provider_a = result
+ .provider_responses
+ .iter()
+ .find(|response| response.provider == "provider-a")
+ .expect("should have provider-a response");
+ let provider_b = result
+ .provider_responses
+ .iter()
+ .find(|response| response.provider == "provider-b")
+ .expect("should have provider-b response");
+ assert_eq!(provider_a.status, BidStatus::Success);
+ assert_eq!(provider_b.status, BidStatus::Error);
});
}
@@ -2987,14 +3930,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "backend-b",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "backend-a",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "backend-b",
+ )));
let request = create_test_auction_request();
let settings = create_test_settings();
@@ -3062,10 +4005,9 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
+ let mut provider = StubAuctionProvider::new("provider-a", "backend-a");
+ provider.configured_timeout_ms = 125;
+ orchestrator.register_provider(Arc::new(provider));
let request = create_test_auction_request();
let settings = create_test_settings();
let downstream = http::Request::builder()
@@ -3080,13 +4022,9 @@ mod tests {
provider_responses: None,
services: &services,
};
- let dispatched = match orchestrator
+ let dispatched = orchestrator
.dispatch_auction(&request, &dispatch_context)
- .await
- {
- DispatchAuctionOutcome::Dispatched(dispatched) => dispatched,
- _ => panic!("should dispatch provider request"),
- };
+ .await;
let placeholder = http::Request::builder()
.uri("https://placeholder.invalid/")
.body(edgezero_core::body::Body::empty())
@@ -3140,14 +4078,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "backend-b",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "backend-a",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "backend-b",
+ )));
let request = create_test_auction_request();
let settings = create_test_settings();
@@ -3167,11 +4105,21 @@ mod tests {
// Act
let result = orchestrator.run_auction(&request, &context).await;
- // Assert: rejected before any provider request launches.
- let err = result.expect_err("should reject multi-provider fan-out");
- assert!(
- format!("{err}").contains("sequentially"),
- "should explain the sequential-execution limitation"
+ // Assert: every affected slot gets an explicit provider failure
+ // without launching either provider request.
+ let result = result.expect("should preserve sequential-platform failures");
+ assert_eq!(
+ result.decision_set.results,
+ vec![
+ SlotAuctionDecisionV1::Failed {
+ slot: "header-banner".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ SlotAuctionDecisionV1::Failed {
+ slot: "sidebar".to_string(),
+ reason: AuctionSlotFailureReason::ProviderError,
+ },
+ ]
);
assert!(
stub_for_assertion.recorded_backend_names().is_empty(),
@@ -3205,14 +4153,14 @@ mod tests {
..Default::default()
};
let mut orchestrator = AuctionOrchestrator::new(config);
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-a",
- backend: "backend-a",
- }));
- orchestrator.register_provider(Arc::new(StubAuctionProvider {
- name: "provider-b",
- backend: "backend-b",
- }));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-a",
+ "backend-a",
+ )));
+ orchestrator.register_provider(Arc::new(StubAuctionProvider::new(
+ "provider-b",
+ "backend-b",
+ )));
let request = create_test_auction_request();
let settings = create_test_settings();
@@ -3232,15 +4180,27 @@ mod tests {
// Act
let dispatched = orchestrator.dispatch_auction(&request, &context).await;
- // Assert: no dispatch and no provider request launched.
- assert!(
- matches!(dispatched, DispatchAuctionOutcome::NotStarted),
- "should skip initial-page dispatch on sequential platforms"
- );
+ // Assert: no network request launches, but every configured provider
+ // remains attributable through the normal terminal decision path.
assert!(
stub_for_assertion.recorded_backend_names().is_empty(),
"should not launch any provider request on a sequential platform"
);
+ let result = orchestrator
+ .collect_dispatched_auction(dispatched, services, &context)
+ .await;
+ assert!(result.winning_bids.is_empty());
+ assert!(result.provider_responses.iter().all(|response| {
+ response.status == BidStatus::Error
+ && response.metadata["error_type"] == "launch_failed"
+ }));
+ assert!(result.decision_set.results.iter().all(|decision| matches!(
+ decision,
+ SlotAuctionDecisionV1::Failed {
+ reason: AuctionSlotFailureReason::ProviderError,
+ ..
+ }
+ )));
});
}
@@ -3290,6 +4250,9 @@ mod tests {
"slot-1".to_string(),
Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: None,
currency: "USD".to_string(),
creative: Some("Ad
".to_string()),
@@ -3334,6 +4297,9 @@ mod tests {
"atf".to_string(),
Bid {
slot_id: "atf".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(0.30), // decoded APS price — below $0.50 floor
currency: "USD".to_string(),
creative: Some("APS Ad
".to_string()),
@@ -3373,6 +4339,9 @@ mod tests {
"atf".to_string(),
Bid {
slot_id: "atf".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(0.75), // decoded APS price — above floor
currency: "USD".to_string(),
creative: Some("APS Ad
".to_string()),
diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs
index 766bd7a08..a3c6f012f 100644
--- a/crates/trusted-server-core/src/auction/provider.rs
+++ b/crates/trusted-server-core/src/auction/provider.rs
@@ -8,7 +8,31 @@ use error_stack::Report;
use crate::error::TrustedServerError;
use crate::platform::{PlatformPendingRequest, PlatformResponse, RuntimeServices};
-use super::types::{AuctionContext, AuctionRequest, AuctionResponse};
+use super::types::{
+ AuctionContext, AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid,
+};
+
+/// Exactly one normalized outcome for a slot dispatched to one provider.
+#[derive(Debug, Clone)]
+pub struct ProviderSlotOutcome {
+ /// Provider integration that received the slot.
+ pub provider: String,
+ /// Exact dispatched slot identifier.
+ pub slot: String,
+ /// Candidate, successful no-bid, or typed failure.
+ pub disposition: ProviderSlotDisposition,
+}
+
+/// Closed normalized provider result for one dispatched slot.
+#[derive(Debug, Clone)]
+pub enum ProviderSlotDisposition {
+ /// One or more independently validated candidates returned for the slot.
+ Candidates(Vec),
+ /// Provider completed successfully without a candidate for this slot.
+ NoBid,
+ /// Provider failed for this slot.
+ Failed(AuctionSlotFailureReason),
+}
/// Provider-local state carried from request dispatch to response parsing.
pub type ProviderParseState = Box;
diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs
index b3e049eaf..1ba73a655 100644
--- a/crates/trusted-server-core/src/auction/telemetry.rs
+++ b/crates/trusted-server-core/src/auction/telemetry.rs
@@ -933,7 +933,7 @@ mod tests {
use serde_json::json;
- use crate::auction::types::{AdFormat, AdSlot, PublisherInfo, UserInfo};
+ use crate::auction::types::{AdFormat, AdSlot, AuctionDecisionSetV1, PublisherInfo, UserInfo};
use super::*;
@@ -969,6 +969,9 @@ mod tests {
fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid {
Bid {
slot_id: slot_id.to_owned(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price,
currency: "USD".to_owned(),
creative: None,
@@ -1049,6 +1052,11 @@ mod tests {
provider_responses: vec![provider_success, provider_no_bid, provider_error],
mediator_response: None,
winning_bids: HashMap::from([("slot-1".to_owned(), winning)]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 99,
metadata: HashMap::new(),
};
@@ -1112,6 +1120,11 @@ mod tests {
provider_responses: vec![provider_success.clone()],
mediator_response: None,
winning_bids: HashMap::from([("slot-1".to_owned(), provider_success.bids[0].clone())]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 42,
metadata: HashMap::new(),
};
@@ -1153,6 +1166,11 @@ mod tests {
provider_responses: vec![provider_http_error],
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 12,
metadata: HashMap::new(),
};
@@ -1191,6 +1209,11 @@ mod tests {
provider_responses: vec![provider_success],
mediator_response: Some(mediator_response),
winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 80,
metadata: HashMap::new(),
};
@@ -1231,6 +1254,11 @@ mod tests {
provider_responses: Vec::new(),
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: Vec::new(),
+ },
total_time_ms: 1,
metadata: HashMap::new(),
};
diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs
index f61334787..271cac8ca 100644
--- a/crates/trusted-server-core/src/auction/types.rs
+++ b/crates/trusted-server-core/src/auction/types.rs
@@ -1,9 +1,15 @@
//! Core types for auction requests and responses.
+use base64::{
+ Engine as _,
+ engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD},
+};
use edgezero_core::body::Body as EdgeBody;
use http::Request;
+use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize};
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
+use url::Url;
use crate::auction::context::ContextValue;
use crate::geo::GeoInfo;
@@ -14,6 +20,42 @@ fn is_zero(value: &usize) -> bool {
*value == 0
}
+/// Injectable CSPRNG boundary for server-minted response-local identities.
+pub(crate) trait AuctionIdentityGenerator: Send + Sync {
+ /// Fill the complete destination or report that secure randomness is unavailable.
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()>;
+}
+
+/// Production CSPRNG for server-minted auction identities.
+pub(crate) struct SystemAuctionIdentityGenerator;
+
+impl AuctionIdentityGenerator for SystemAuctionIdentityGenerator {
+ fn fill(&self, destination: &mut [u8]) -> Result<(), ()> {
+ OsRng.try_fill_bytes(destination).map_err(|_| ())
+ }
+}
+
+/// Mint one response-unique unpadded base64url identity.
+pub(crate) fn mint_response_unique_base64url_identity(
+ generator: &dyn AuctionIdentityGenerator,
+ issued: &mut HashSet,
+ prefix: &str,
+ random_byte_count: usize,
+ collision_retries: usize,
+) -> Option {
+ for _ in 0..=collision_retries {
+ let mut bytes = vec![0_u8; random_byte_count];
+ if generator.fill(&mut bytes).is_err() {
+ return None;
+ }
+ let identity = format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes));
+ if issued.insert(identity.clone()) {
+ return Some(identity);
+ }
+ }
+ None
+}
+
/// Represents a unified auction request across all providers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuctionRequest {
@@ -154,6 +196,353 @@ pub struct AuctionContext<'a> {
pub services: &'a RuntimeServices,
}
+/// Closed, local reason set for rejecting provider bids or undeliverable winners.
+///
+/// These values are serialized only into existing auction debug/diagnostic
+/// surfaces. They are not a persistence or external-event taxonomy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AuctionDropReason {
+ /// Configured processing rejected an ordinary creative's only render source.
+ CreativeProcessingRejected,
+ /// Optional creative ID is present with an invalid type or value.
+ InvalidCreativeId,
+ /// Optional creative ID exceeds its UTF-8 byte bound.
+ CreativeIdTooLarge,
+ /// A positive integral dimension exceeds the supported range.
+ DimensionsOutOfRange,
+ /// An otherwise valid upstream bid ID is repeated in one provider response.
+ DuplicateUpstreamBidId,
+ /// A response contains no seat bids.
+ #[serde(rename = "empty_seatbid")]
+ EmptySeatBid,
+ /// A seat bid contains no usable bid array.
+ #[serde(rename = "empty_seatbid_bids")]
+ EmptySeatBidBids,
+ /// A creative URL is malformed, unsafe, or self-origin.
+ InvalidCreativeUrl,
+ /// A dimension is missing, malformed, nonpositive, or not requested.
+ InvalidDimensions,
+ /// A price is missing, malformed, nonfinite, or negative.
+ InvalidPrice,
+ /// The provider response violates the response-level contract.
+ InvalidProviderResponse,
+ /// The APS tag type is missing or unsupported.
+ InvalidTagType,
+ /// An upstream bid ID contains a forbidden control value or has the wrong type.
+ InvalidUpstreamBidId,
+ /// A valid sibling was preferred by deterministic per-slot reduction.
+ LostToHigherBid,
+ /// A provider bid is not an object.
+ MalformedBid,
+ /// APS creative metadata does not contain `creativeurl`.
+ MissingCreativeUrl,
+ /// Provider parsing was invoked without its request-local context.
+ MissingRequestContext,
+ /// A required upstream bid ID is absent or empty.
+ MissingUpstreamBidId,
+ /// A winner carries more than one render source.
+ MultipleRenderSources,
+ /// A winner has no render source.
+ NoRenderSource,
+ /// A typed renderer extension could not be serialized.
+ RendererExtensionSerializationFailed,
+ /// A validated renderer projection exceeds its bound.
+ RenderPayloadTooLarge,
+ /// APS script rendering is disabled by configuration.
+ ScriptRenderingDisabled,
+ /// A provider bid references an impression that was not dispatched.
+ UnknownImpression,
+ /// A provider bid declares a non-banner media type.
+ UnsupportedMediaType,
+ /// An upstream bid ID exceeds 64 UTF-8 bytes.
+ UpstreamBidIdTooLarge,
+}
+
+impl AuctionDropReason {
+ /// Return the exact existing debug/projection literal.
+ ///
+ /// This hand-written mapping also drives [`Ord`] so serialized-map output stays
+ /// alphabetically stable even when declaration order changes.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::CreativeProcessingRejected => "creative_processing_rejected",
+ Self::InvalidCreativeId => "invalid_creative_id",
+ Self::CreativeIdTooLarge => "creative_id_too_large",
+ Self::DimensionsOutOfRange => "dimensions_out_of_range",
+ Self::DuplicateUpstreamBidId => "duplicate_upstream_bid_id",
+ Self::EmptySeatBid => "empty_seatbid",
+ Self::EmptySeatBidBids => "empty_seatbid_bids",
+ Self::InvalidCreativeUrl => "invalid_creative_url",
+ Self::InvalidDimensions => "invalid_dimensions",
+ Self::InvalidPrice => "invalid_price",
+ Self::InvalidProviderResponse => "invalid_provider_response",
+ Self::InvalidTagType => "invalid_tag_type",
+ Self::InvalidUpstreamBidId => "invalid_upstream_bid_id",
+ Self::LostToHigherBid => "lost_to_higher_bid",
+ Self::MalformedBid => "malformed_bid",
+ Self::MissingCreativeUrl => "missing_creative_url",
+ Self::MissingRequestContext => "missing_request_context",
+ Self::MissingUpstreamBidId => "missing_upstream_bid_id",
+ Self::MultipleRenderSources => "multiple_render_sources",
+ Self::NoRenderSource => "no_render_source",
+ Self::RendererExtensionSerializationFailed => "renderer_extension_serialization_failed",
+ Self::RenderPayloadTooLarge => "render_payload_too_large",
+ Self::ScriptRenderingDisabled => "script_rendering_disabled",
+ Self::UnknownImpression => "unknown_impression",
+ Self::UnsupportedMediaType => "unsupported_media_type",
+ Self::UpstreamBidIdTooLarge => "upstream_bid_id_too_large",
+ }
+ }
+}
+
+impl Ord for AuctionDropReason {
+ fn cmp(&self, other: &Self) -> core::cmp::Ordering {
+ self.as_str().cmp(other.as_str())
+ }
+}
+
+impl PartialOrd for AuctionDropReason {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+/// Typed counts projected into the existing `drop_reasons` debug object.
+pub type AuctionDropReasons = BTreeMap;
+
+/// Increment one typed local drop reason.
+pub(crate) fn record_auction_drop(reasons: &mut AuctionDropReasons, reason: AuctionDropReason) {
+ *reasons.entry(reason).or_default() += 1;
+}
+
+/// Closed failure set for one requested slot's server-auction decision.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AuctionSlotFailureReason {
+ /// The auction orchestrator is disabled.
+ AuctionDisabled,
+ /// Request consent does not permit a server-side auction.
+ ConsentDenied,
+ /// No enabled configured provider can bid on the slot.
+ SlotNotEligible,
+ /// A dispatched provider exceeded its deadline.
+ ProviderTimeout,
+ /// A provider could not launch or complete its transport/HTTP exchange.
+ ProviderError,
+ /// A provider response failed structural, currency, identity, or bid validation.
+ InvalidProviderResponse,
+ /// The configured mediator failed or returned invalid provenance.
+ MediationFailed,
+ /// A selected candidate cannot be represented by the exact browser contract.
+ WinnerNotRenderable,
+ /// A unique renderer reservation could not be minted.
+ IdentityGenerationFailed,
+ /// An internal invariant or candidate-identity operation failed.
+ InternalError,
+}
+
+impl AuctionSlotFailureReason {
+ /// Return the exact wire literal.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::AuctionDisabled => "auction_disabled",
+ Self::ConsentDenied => "consent_denied",
+ Self::SlotNotEligible => "slot_not_eligible",
+ Self::ProviderTimeout => "provider_timeout",
+ Self::ProviderError => "provider_error",
+ Self::InvalidProviderResponse => "invalid_provider_response",
+ Self::MediationFailed => "mediation_failed",
+ Self::WinnerNotRenderable => "winner_not_renderable",
+ Self::IdentityGenerationFailed => "identity_generation_failed",
+ Self::InternalError => "internal_error",
+ }
+ }
+
+ /// Closed multi-provider aggregation priority; lower values win.
+ #[must_use]
+ pub const fn priority(self) -> u8 {
+ match self {
+ Self::InternalError => 0,
+ Self::MediationFailed => 1,
+ Self::InvalidProviderResponse => 2,
+ Self::ProviderError => 3,
+ Self::ProviderTimeout => 4,
+ Self::ConsentDenied => 5,
+ Self::AuctionDisabled => 6,
+ Self::SlotNotEligible => 7,
+ Self::WinnerNotRenderable | Self::IdentityGenerationFailed => u8::MAX,
+ }
+ }
+}
+
+/// Exactly one final server-auction decision for a requested slot.
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+#[serde(
+ tag = "outcome",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase",
+ deny_unknown_fields
+)]
+pub enum SlotAuctionDecisionV1 {
+ /// A candidate won and joins exactly one projected bid.
+ Winner {
+ /// Exact request slot identifier.
+ slot: String,
+ /// Opaque response-local candidate identifier.
+ candidate_id: String,
+ },
+ /// Every dispatched provider completed successfully without a candidate.
+ NoBid {
+ /// Exact request slot identifier.
+ slot: String,
+ },
+ /// The slot failed with one closed reason.
+ Failed {
+ /// Exact request slot identifier.
+ slot: String,
+ /// Exact failure reason.
+ reason: AuctionSlotFailureReason,
+ },
+}
+
+impl SlotAuctionDecisionV1 {
+ /// Return the exact slot identifier shared by every variant.
+ #[must_use]
+ pub fn slot(&self) -> &str {
+ match self {
+ Self::Winner { slot, .. } | Self::NoBid { slot } | Self::Failed { slot, .. } => slot,
+ }
+ }
+}
+
+impl Serialize for SlotAuctionDecisionV1 {
+ fn serialize(&self, serializer: S) -> Result
+ where
+ S: serde::Serializer,
+ {
+ use serde::ser::SerializeStruct;
+
+ match self {
+ Self::Winner { slot, candidate_id } => {
+ let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?;
+ state.serialize_field("slot", slot)?;
+ state.serialize_field("outcome", "winner")?;
+ state.serialize_field("candidateId", candidate_id)?;
+ state.end()
+ }
+ Self::NoBid { slot } => {
+ let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 2)?;
+ state.serialize_field("slot", slot)?;
+ state.serialize_field("outcome", "no_bid")?;
+ state.end()
+ }
+ Self::Failed { slot, reason } => {
+ let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?;
+ state.serialize_field("slot", slot)?;
+ state.serialize_field("outcome", "failed")?;
+ state.serialize_field("reason", reason)?;
+ state.end()
+ }
+ }
+ }
+}
+
+/// Ordered version-1 decision set for one server auction.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct AuctionDecisionSetV1 {
+ /// Contract version.
+ pub version: u8,
+ /// Exact auction identifier.
+ pub auction_id: String,
+ /// Exactly one decision per requested slot, in request order.
+ pub results: Vec,
+}
+
+impl AuctionDecisionSetV1 {
+ /// Construct an ordered decision set for a request-wide gate.
+ #[must_use]
+ pub fn failed(request: &AuctionRequest, reason: AuctionSlotFailureReason) -> Self {
+ Self {
+ version: 1,
+ auction_id: request.id.clone(),
+ results: request
+ .slots
+ .iter()
+ .map(|slot| SlotAuctionDecisionV1::Failed {
+ slot: slot.id.clone(),
+ reason,
+ })
+ .collect(),
+ }
+ }
+}
+
+/// Maximum canonical UTF-8 size of the browser auction projection.
+pub const MAX_BROWSER_AUCTION_PROJECTION_BYTES: usize = 8 * 1024 * 1024;
+/// Maximum number of requested results or projected winner bids.
+pub const MAX_BROWSER_AUCTION_RESULTS: usize = 256;
+/// Maximum number of publisher targeting entries on one projected bid.
+pub const MAX_BROWSER_AUCTION_TARGETING_ENTRIES: usize = 32;
+
+/// One exact browser-facing winner projection.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct BrowserAuctionBidV1 {
+ /// Response-local mediator candidate identity.
+ pub candidate_id: String,
+ /// Exact requested server slot identity.
+ pub slot: String,
+ /// Canonical provider integration name.
+ pub provider: String,
+ /// Exact provider-native upstream bid identity.
+ pub upstream_bid_id: String,
+ /// Selected finite, nonnegative CPM.
+ pub cpm: f64,
+ /// Exact auction currency; version 1 admits only `USD`.
+ pub currency: String,
+ /// Lexically ordered publisher targeting, excluding runtime-owned `hb_adid`.
+ pub targeting: BTreeMap,
+ /// Server-minted renderer capability identity for APS/ADM only.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub renderer_reservation_id: Option,
+ /// Sole tagged render authority for the winner.
+ pub render_source: BidRenderSourceV1,
+}
+
+/// Exact GAM placement metadata required to publish one server-projected slot.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct BrowserAuctionSlotV1 {
+ /// Exact server slot identity joined to one auction decision.
+ pub slot: String,
+ /// Fully rendered GAM ad-unit path for this navigation.
+ pub gam_unit_path: String,
+ /// Stable configured DOM id/prefix for responsive resolution.
+ pub div_id: String,
+ /// Accepted banner dimensions in configured order.
+ pub formats: Vec<[u32; 2]>,
+ /// Static publisher targeting applied before winner targeting.
+ pub targeting: BTreeMap,
+}
+
+/// Complete browser-facing version-1 auction projection.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct BrowserAuctionProjectionV1 {
+ /// Contract version.
+ pub version: u8,
+ /// Ordered decision set for every requested slot.
+ pub auction: AuctionDecisionSetV1,
+ /// Ordered GAM placement definitions; empty only for direct `/auction` serialization.
+ pub slots: Vec,
+ /// Winner bids in matching decision order.
+ pub bids: Vec,
+}
+
/// URL used by the orchestrator when invoking a mediator from the collect
/// path. Providers can `debug_assert` against this value to catch a mediator
/// that has accidentally started depending on `context.request` carrying real
@@ -187,7 +576,7 @@ pub enum ApsTagType {
/// Version 1 APS renderer descriptor shared with browser clients.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ApsRendererV1 {
/// Renderer contract version.
pub version: u8,
@@ -210,22 +599,308 @@ pub struct ApsRendererV1 {
pub height: u32,
}
-/// Typed browser renderer capability carried by a bid.
+/// Version 1 inline ADM render source shared with browser clients.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct AdmRenderSourceV1 {
+ /// Render-source contract version.
+ pub version: u8,
+ /// Exact creative markup.
+ pub adm: String,
+ /// Creative width.
+ pub width: u32,
+ /// Creative height.
+ pub height: u32,
+}
+
+/// Thin version 1 carrier for the current GPT-owned PBS Cache behavior.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct BaselinePbsCacheSourceV1 {
+ /// Render-source contract version.
+ pub version: u8,
+ /// Exact native PBS Cache identity.
+ pub cache_id: String,
+ /// Exact current-main `hb_cache_host` value.
+ pub cache_host: String,
+ /// Exact current-main `hb_cache_path` value.
+ pub cache_path: String,
+ /// Winning width transported without cache-specific validation.
+ pub width: u32,
+ /// Winning height transported without cache-specific validation.
+ pub height: u32,
+}
+
+/// Typed browser render source carried by a bid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "lowercase")]
-pub enum BidRenderer {
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum BidRenderSourceV1 {
/// APS renderer version 1.
Aps(ApsRendererV1),
+ /// Inline ADM version 1.
+ Adm(AdmRenderSourceV1),
+ /// Current-main GPT-owned PBS Cache carrier.
+ PbsCache(BaselinePbsCacheSourceV1),
}
-impl BidRenderer {
+impl BidRenderSourceV1 {
/// Return the APS renderer descriptor when this is an APS renderer.
#[must_use]
pub fn as_aps(&self) -> Option<&ApsRendererV1> {
match self {
Self::Aps(renderer) => Some(renderer),
+ Self::Adm(_) | Self::PbsCache(_) => None,
+ }
+ }
+}
+
+/// Smallest accepted renderer dimension in CSS pixels.
+pub const RENDER_DIMENSION_MIN: u64 = 1;
+/// Largest accepted renderer dimension in CSS pixels.
+pub const RENDER_DIMENSION_MAX: u64 = 4096;
+
+const MAX_APS_ACCOUNT_ID_BYTES: usize = 1024;
+const MAX_APS_BID_ID_BYTES: usize = 64;
+const MAX_APS_CREATIVE_ID_BYTES: usize = 1024;
+const MAX_APS_CREATIVE_URL_BYTES: usize = 4096;
+const MAX_APS_RENDER_ENVELOPE_BYTES: usize = 256 * 1024;
+const MAX_APS_RENDER_ENVELOPE_BASE64_BYTES: usize = 4 * MAX_APS_RENDER_ENVELOPE_BYTES.div_ceil(3);
+
+/// Cross-language APS descriptor validation result.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ApsRendererValidationResult {
+ /// Descriptor and decoded envelope are valid and agree.
+ Accepted,
+ /// Descriptor or decoded envelope is malformed.
+ DescriptorInvalid,
+ /// A dimension has the wrong type or is nonfinite, fractional, zero, or negative.
+ InvalidDimensions,
+ /// An otherwise integral positive dimension is outside the supported range.
+ DimensionsOutOfRange,
+}
+
+impl ApsRendererValidationResult {
+ /// Return the exact browser failure/result literal.
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Accepted => "accepted",
+ Self::DescriptorInvalid => "descriptor_invalid",
+ Self::InvalidDimensions => "invalid_dimensions",
+ Self::DimensionsOutOfRange => "dimensions_out_of_range",
+ }
+ }
+}
+
+fn has_exact_json_keys(value: &serde_json::Value, expected: &[&str]) -> bool {
+ value.as_object().is_some_and(|object| {
+ object.len() == expected.len() && expected.iter().all(|key| object.contains_key(*key))
+ })
+}
+
+fn classify_render_dimension(value: &serde_json::Value) -> ApsRendererValidationResult {
+ let Some(number) = value.as_f64() else {
+ return ApsRendererValidationResult::InvalidDimensions;
+ };
+ if !number.is_finite() || number.fract() != 0.0 || number <= 0.0 {
+ return ApsRendererValidationResult::InvalidDimensions;
+ }
+ if number < RENDER_DIMENSION_MIN as f64 || number > RENDER_DIMENSION_MAX as f64 {
+ return ApsRendererValidationResult::DimensionsOutOfRange;
+ }
+ ApsRendererValidationResult::Accepted
+}
+
+fn valid_aps_creative_url(value: &str, publisher_origin: &str) -> bool {
+ if value.len() > MAX_APS_CREATIVE_URL_BYTES {
+ return false;
+ }
+ let Ok(url) = Url::parse(value) else {
+ return false;
+ };
+ url.scheme() == "https"
+ && url.host_str().is_some()
+ && url.username().is_empty()
+ && url.password().is_none()
+ && url.origin().ascii_serialization() != publisher_origin
+}
+
+/// Classify a raw APS renderer descriptor using the cross-language version-1 contract.
+#[must_use]
+pub fn classify_aps_renderer_v1(
+ value: &serde_json::Value,
+ publisher_origin: &str,
+) -> ApsRendererValidationResult {
+ const REQUIRED_KEYS: &[&str] = &[
+ "aaxResponse",
+ "accountId",
+ "bidId",
+ "creativeUrl",
+ "height",
+ "tagType",
+ "type",
+ "version",
+ "width",
+ ];
+ const KEYS_WITH_CREATIVE_ID: &[&str] = &[
+ "aaxResponse",
+ "accountId",
+ "bidId",
+ "creativeId",
+ "creativeUrl",
+ "height",
+ "tagType",
+ "type",
+ "version",
+ "width",
+ ];
+
+ if !has_exact_json_keys(value, REQUIRED_KEYS)
+ && !has_exact_json_keys(value, KEYS_WITH_CREATIVE_ID)
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let Some(descriptor) = value.as_object() else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if descriptor.get("type").and_then(serde_json::Value::as_str) != Some("aps")
+ || descriptor
+ .get("version")
+ .and_then(serde_json::Value::as_f64)
+ .is_none_or(|version| version != 1.0)
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let Some(account_id) = descriptor
+ .get("accountId")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ let Some(bid_id) = descriptor.get("bidId").and_then(serde_json::Value::as_str) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if account_id.is_empty()
+ || account_id.len() > MAX_APS_ACCOUNT_ID_BYTES
+ || bid_id.is_empty()
+ || bid_id.len() > MAX_APS_BID_ID_BYTES
+ || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f)
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ if let Some(creative_id) = descriptor.get("creativeId") {
+ let Some(creative_id) = creative_id.as_str() else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if creative_id.is_empty() || creative_id.len() > MAX_APS_CREATIVE_ID_BYTES {
+ return ApsRendererValidationResult::DescriptorInvalid;
}
}
+ let Some(tag_type) = descriptor
+ .get("tagType")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if tag_type != "iframe" && tag_type != "script" {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let width_result =
+ classify_render_dimension(descriptor.get("width").unwrap_or(&serde_json::Value::Null));
+ if width_result != ApsRendererValidationResult::Accepted {
+ return width_result;
+ }
+ let height_result =
+ classify_render_dimension(descriptor.get("height").unwrap_or(&serde_json::Value::Null));
+ if height_result != ApsRendererValidationResult::Accepted {
+ return height_result;
+ }
+
+ let Some(creative_url) = descriptor
+ .get("creativeUrl")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ let Some(aax_response) = descriptor
+ .get("aaxResponse")
+ .and_then(serde_json::Value::as_str)
+ else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if !valid_aps_creative_url(creative_url, publisher_origin)
+ || aax_response.is_empty()
+ || aax_response.len() > MAX_APS_RENDER_ENVELOPE_BASE64_BYTES
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Ok(decoded_bytes) = BASE64_STANDARD.decode(aax_response) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if decoded_bytes.len() > MAX_APS_RENDER_ENVELOPE_BYTES
+ || BASE64_STANDARD.encode(&decoded_bytes) != aax_response
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Ok(decoded_utf8) = core::str::from_utf8(&decoded_bytes) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ let Ok(decoded) = serde_json::from_str::(decoded_utf8) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if !has_exact_json_keys(&decoded, &["seatbid"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Some(seats) = decoded.get("seatbid").and_then(serde_json::Value::as_array) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if seats.len() != 1 || !has_exact_json_keys(&seats[0], &["bid"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let Some(bids) = seats[0].get("bid").and_then(serde_json::Value::as_array) else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if bids.len() != 1 || !has_exact_json_keys(&bids[0], &["ext", "h", "id", "price", "w"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+ let bid = &bids[0];
+ let Some(ext) = bid.get("ext") else {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ };
+ if !has_exact_json_keys(ext, &["creativeurl", "tagtype"]) {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ let bid_width_result =
+ classify_render_dimension(bid.get("w").unwrap_or(&serde_json::Value::Null));
+ if bid_width_result != ApsRendererValidationResult::Accepted {
+ return bid_width_result;
+ }
+ let bid_height_result =
+ classify_render_dimension(bid.get("h").unwrap_or(&serde_json::Value::Null));
+ if bid_height_result != ApsRendererValidationResult::Accepted {
+ return bid_height_result;
+ }
+ let price_is_valid = bid
+ .get("price")
+ .and_then(serde_json::Value::as_f64)
+ .is_some_and(|price| price.is_finite() && price >= 0.0);
+ if bid.get("id").and_then(serde_json::Value::as_str) != Some(bid_id)
+ || bid.get("w").and_then(serde_json::Value::as_f64)
+ != descriptor.get("width").and_then(serde_json::Value::as_f64)
+ || bid.get("h").and_then(serde_json::Value::as_f64)
+ != descriptor.get("height").and_then(serde_json::Value::as_f64)
+ || ext.get("creativeurl").and_then(serde_json::Value::as_str) != Some(creative_url)
+ || ext.get("tagtype").and_then(serde_json::Value::as_str) != Some(tag_type)
+ || !price_is_valid
+ {
+ return ApsRendererValidationResult::DescriptorInvalid;
+ }
+
+ ApsRendererValidationResult::Accepted
}
/// Individual bid from a provider.
@@ -233,13 +908,22 @@ impl BidRenderer {
pub struct Bid {
/// Slot this bid is for
pub slot_id: String,
+ /// Server-minted opaque identifier used only for this auction response.
+ #[serde(skip)]
+ pub candidate_id: Option,
+ /// Provider integration name paired with the upstream bid ID for provenance.
+ #[serde(skip)]
+ pub candidate_provider: Option,
+ /// Server-minted renderer capability identifier (populated during projection).
+ #[serde(skip)]
+ pub renderer_reservation_id: Option,
/// Bid price in CPM.
pub price: Option,
/// Currency code (e.g., "USD")
pub currency: String,
/// Creative markup (HTML/VAST).
///
- /// `None` when the bid uses a typed [`BidRenderer`] instead.
+ /// `None` when the bid uses a typed [`BidRenderSourceV1`] instead.
pub creative: Option,
/// Advertiser domain
pub adomain: Option>,
@@ -267,7 +951,7 @@ pub struct Bid {
pub creative_id: Option,
/// Typed browser renderer capability.
#[serde(skip_serializing_if = "Option::is_none")]
- pub renderer: Option,
+ pub renderer: Option,
/// Prebid Cache UUID for this bid.
///
/// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response.
@@ -288,6 +972,28 @@ pub struct Bid {
pub metadata: HashMap,
}
+/// Length of the hex-encoded creative trace hash.
+const ADM_TRACE_HASH_LEN: usize = 16;
+
+/// Compute the trace hash for delivered creative markup.
+#[must_use]
+pub fn adm_trace_hash(adm: &str) -> String {
+ use sha2::{Digest as _, Sha256};
+
+ let digest = Sha256::digest(adm.as_bytes());
+ let mut hex = hex::encode(digest);
+ hex.truncate(ADM_TRACE_HASH_LEN);
+ hex
+}
+
+impl Bid {
+ /// Trace hash of this bid's creative markup, when present.
+ #[must_use]
+ pub fn creative_trace_hash(&self) -> Option {
+ self.creative.as_deref().map(adm_trace_hash)
+ }
+}
+
/// Per-provider summary included in the auction response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderSummary {
@@ -338,7 +1044,7 @@ pub struct OrchestratorExt {
pub dropped_winner_count: usize,
/// Machine-readable reasons for omitted winners.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub dropped_winner_reasons: BTreeMap,
+ pub dropped_winner_reasons: AuctionDropReasons,
}
/// Status of bid response.
@@ -394,6 +1100,30 @@ impl AuctionResponse {
self.metadata.insert(key.into(), value);
self
}
+
+ /// Project typed local drop reasons into the existing provider metadata surface.
+ #[must_use]
+ pub fn with_drop_reasons(mut self, reasons: &AuctionDropReasons) -> Self {
+ if !reasons.is_empty() {
+ let values = reasons
+ .iter()
+ .map(|(reason, count)| {
+ (reason.as_str().to_string(), serde_json::Value::from(*count))
+ })
+ .collect();
+ self.metadata.insert(
+ "drop_reasons".to_string(),
+ serde_json::Value::Object(values),
+ );
+ }
+ self
+ }
+
+ /// Project one typed local drop reason into provider metadata.
+ #[must_use]
+ pub fn with_drop_reason(self, reason: AuctionDropReason) -> Self {
+ self.with_drop_reasons(&BTreeMap::from([(reason, 1)]))
+ }
}
#[cfg(test)]
@@ -401,9 +1131,59 @@ mod tests {
use super::*;
use serde_json::json;
+ #[test]
+ fn typed_drop_reasons_use_exact_literals_in_provider_summary_metadata() {
+ let reasons = [
+ AuctionDropReason::CreativeProcessingRejected,
+ AuctionDropReason::InvalidCreativeId,
+ AuctionDropReason::CreativeIdTooLarge,
+ AuctionDropReason::DimensionsOutOfRange,
+ AuctionDropReason::DuplicateUpstreamBidId,
+ AuctionDropReason::EmptySeatBid,
+ AuctionDropReason::EmptySeatBidBids,
+ AuctionDropReason::InvalidCreativeUrl,
+ AuctionDropReason::InvalidDimensions,
+ AuctionDropReason::InvalidPrice,
+ AuctionDropReason::InvalidProviderResponse,
+ AuctionDropReason::InvalidTagType,
+ AuctionDropReason::InvalidUpstreamBidId,
+ AuctionDropReason::LostToHigherBid,
+ AuctionDropReason::MalformedBid,
+ AuctionDropReason::MissingCreativeUrl,
+ AuctionDropReason::MissingRequestContext,
+ AuctionDropReason::MissingUpstreamBidId,
+ AuctionDropReason::MultipleRenderSources,
+ AuctionDropReason::NoRenderSource,
+ AuctionDropReason::RendererExtensionSerializationFailed,
+ AuctionDropReason::RenderPayloadTooLarge,
+ AuctionDropReason::ScriptRenderingDisabled,
+ AuctionDropReason::UnknownImpression,
+ AuctionDropReason::UnsupportedMediaType,
+ AuctionDropReason::UpstreamBidIdTooLarge,
+ ];
+ for reason in reasons {
+ assert_eq!(
+ serde_json::to_value(reason).expect("drop reason should serialize"),
+ json!(reason.as_str()),
+ "serde and diagnostic literal should agree for {reason:?}"
+ );
+ }
+
+ let response = AuctionResponse::no_bid("aps", 12)
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
+ let summary = ProviderSummary::from(&response);
+ assert_eq!(
+ summary.metadata["drop_reasons"]["invalid_provider_response"], 1,
+ "publisher provider-summary projection should retain the typed reason"
+ );
+ }
+
fn make_bid(bidder: &str) -> Bid {
Bid {
slot_id: "slot-1".to_owned(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(1.0),
currency: "USD".to_owned(),
creative: None,
@@ -536,6 +1316,9 @@ mod tests {
fn bid_with_cache_fields_round_trips_through_json() {
let bid = Bid {
slot_id: "atf".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(1.50),
currency: "USD".to_string(),
creative: None,
@@ -575,7 +1358,7 @@ mod tests {
#[test]
fn aps_renderer_serializes_to_versioned_camel_case_contract() {
- let renderer = BidRenderer::Aps(ApsRendererV1 {
+ let renderer = BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account-id".to_string(),
bid_id: "fictional-bid-id".to_string(),
@@ -609,7 +1392,7 @@ mod tests {
#[test]
fn aps_renderer_omits_absent_creative_id() {
- let renderer = BidRenderer::Aps(ApsRendererV1 {
+ let renderer = BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account-id".to_string(),
bid_id: "fictional-bid-id".to_string(),
@@ -638,10 +1421,40 @@ mod tests {
);
}
+ #[test]
+ fn slot_failure_priority_matches_the_closed_contract() {
+ let ordered = [
+ AuctionSlotFailureReason::InternalError,
+ AuctionSlotFailureReason::MediationFailed,
+ AuctionSlotFailureReason::InvalidProviderResponse,
+ AuctionSlotFailureReason::ProviderError,
+ AuctionSlotFailureReason::ProviderTimeout,
+ AuctionSlotFailureReason::ConsentDenied,
+ AuctionSlotFailureReason::AuctionDisabled,
+ AuctionSlotFailureReason::SlotNotEligible,
+ ];
+
+ assert_eq!(
+ ordered.map(AuctionSlotFailureReason::priority),
+ [0, 1, 2, 3, 4, 5, 6, 7]
+ );
+ assert_eq!(
+ AuctionSlotFailureReason::WinnerNotRenderable.priority(),
+ u8::MAX
+ );
+ assert_eq!(
+ AuctionSlotFailureReason::IdentityGenerationFailed.priority(),
+ u8::MAX
+ );
+ }
+
#[test]
fn bid_has_ad_id_field() {
let bid = Bid {
slot_id: "s".to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(1.0),
currency: "USD".to_string(),
creative: None,
diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs
index 8e70aa020..a58cf5561 100644
--- a/crates/trusted-server-core/src/auth.rs
+++ b/crates/trusted-server-core/src/auth.rs
@@ -269,9 +269,8 @@ mod tests {
/// handler covers is the operator's decision, and silently carving holes in
/// it would be worse than a documented constraint. Operators must scope
/// handler patterns to the paths they mean (`^/_ts/admin`) — see the
- /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps
- /// affected deployments serving SPA ads until they do, but it disappears
- /// with the alias in IABTechLab/trusted-server#970.
+ /// configuration guide. A broad pattern will block the canonical page-bids
+ /// endpoint; the hard-cutover client does not retry a compatibility alias.
#[test]
fn broad_handler_regex_also_covers_browser_facing_endpoints() {
let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#);
diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs
index e1152b1e7..828311f12 100644
--- a/crates/trusted-server-core/src/constants.rs
+++ b/crates/trusted-server-core/src/constants.rs
@@ -5,6 +5,7 @@ pub const COOKIE_TS_EC: &str = "ts-ec";
/// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers.
pub const COOKIE_TS_EIDS: &str = "ts-eids";
pub const COOKIE_TS_TESTER: &str = "ts-tester";
+pub const COOKIE_TS_TRACE: &str = "ts-trace";
pub const COOKIE_SHAREDID: &str = "sharedId";
pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id");
diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs
index a4d641bdf..232cb2781 100644
--- a/crates/trusted-server-core/src/creative.rs
+++ b/crates/trusted-server-core/src/creative.rs
@@ -574,8 +574,8 @@ fn process_auction_creative_with_rewriter(
/// - 1x1 `
` pixels → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>`
/// - Non-pixel absolute images → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>`
/// - `