diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index ffa58122f..0e23226a3 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -506,6 +506,12 @@ fn fastly_response_to_platform( // FastlyPlatformHttpClient // --------------------------------------------------------------------------- +fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) { + if bypass_cache { + request.set_pass(true); + } +} + /// Fastly implementation of [`PlatformHttpClient`]. /// /// - [`send`](PlatformHttpClient::send) converts the platform request to a @@ -529,10 +535,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { let backend_name = request.backend_name.clone(); let image_optimizer = request.image_optimizer; let stream_response = request.stream_response; + let bypass_cache = request.bypass_cache; let mut fastly_req = edge_request_to_fastly(request.request)?; if let Some(options) = image_optimizer { apply_fastly_image_optimizer(&mut fastly_req, options)?; } + apply_fastly_cache_bypass(&mut fastly_req, bypass_cache); let fastly_resp = fastly_req .send(&backend_name) .change_context(PlatformError::HttpClient)?; @@ -552,7 +560,9 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { return Err(Report::new(PlatformError::HttpClient) .attach("streaming responses are not supported with Fastly send_async")); } - let fastly_req = edge_request_to_fastly(request.request)?; + let bypass_cache = request.bypass_cache; + let mut fastly_req = edge_request_to_fastly(request.request)?; + apply_fastly_cache_bypass(&mut fastly_req, bypass_cache); let pending = fastly_req .send_async(&backend_name) .change_context(PlatformError::HttpClient)?; @@ -873,6 +883,26 @@ mod tests { // --- FastlyPlatformHttpClient ------------------------------------------- + #[test] + fn apply_fastly_cache_bypass_sets_pass_when_enabled() { + let mut request = fastly::Request::get("https://example.com/"); + apply_fastly_cache_bypass(&mut request, true); + assert!( + format!("{request:?}").contains("cache_override: Pass"), + "enabled bypass should select Fastly pass mode" + ); + } + + #[test] + fn apply_fastly_cache_bypass_preserves_default_when_disabled() { + let mut request = fastly::Request::get("https://example.com/"); + apply_fastly_cache_bypass(&mut request, false); + assert!( + format!("{request:?}").contains("cache_override: None"), + "disabled bypass should preserve Fastly read-through caching" + ); + } + #[test] fn fastly_platform_http_client_send_returns_error_for_unregistered_backend() { let client = FastlyPlatformHttpClient; diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index da99487a3..2e1ec51a4 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -23,6 +23,12 @@ pub struct PlatformHttpRequest { /// Adapters that cannot attach this metadata to their send path should /// return an error rather than silently dropping transformations. pub image_optimizer: Option, + /// Whether the platform's intermediary response cache must be bypassed. + /// + /// Adapters without an intermediary outbound cache may treat this as already + /// satisfied. The option defaults to `false` so existing call sites preserve + /// their current cache behavior. + pub bypass_cache: bool, /// Whether the response body should stay streaming in the platform response. /// /// Adapters that cannot preserve streaming response bodies should return an @@ -38,6 +44,7 @@ impl PlatformHttpRequest { request, backend_name: backend_name.into(), image_optimizer: None, + bypass_cache: false, stream_response: false, } } @@ -53,6 +60,13 @@ impl PlatformHttpRequest { self } + /// Bypass the platform's intermediary response cache for this request. + #[must_use] + pub fn with_cache_bypass(mut self) -> Self { + self.bypass_cache = true; + self + } + /// Preserve the upstream response body as a stream when the adapter supports it. /// /// Asset routes use this to avoid materializing large image/static responses @@ -306,8 +320,42 @@ pub trait PlatformHttpClient: Send + Sync { #[cfg(test)] mod tests { + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use super::*; + #[test] + fn platform_http_request_cache_bypass_defaults_to_false() { + let request = PlatformHttpRequest::new( + request_builder() + .body(Body::empty()) + .expect("should build request"), + "stub-backend", + ); + + assert!( + !request.bypass_cache, + "should preserve existing cache behavior by default" + ); + } + + #[test] + fn platform_http_request_cache_bypass_builder_enables_bypass() { + let request = PlatformHttpRequest::new( + request_builder() + .body(Body::empty()) + .expect("should build request"), + "stub-backend", + ) + .with_cache_bypass(); + + assert!( + request.bypass_cache, + "should enable intermediary cache bypass" + ); + } + // --------------------------------------------------------------------------- // Error-correlation interim scope (before EdgeZero #213) // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ef1e2ad7b..6857b4699 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -225,6 +225,7 @@ pub(crate) struct StubHttpClient { // platforms whose send_async executes eagerly (e.g. Cloudflare Workers). concurrent_fanout: std::sync::atomic::AtomicBool, image_optimizer_options: Mutex>>, + cache_bypass_flags: Mutex>, stream_response_flags: Mutex>, request_methods: Mutex>, request_uris: Mutex>, @@ -247,6 +248,7 @@ impl StubHttpClient { select_errors: Mutex::new(VecDeque::new()), concurrent_fanout: std::sync::atomic::AtomicBool::new(true), image_optimizer_options: Mutex::new(Vec::new()), + cache_bypass_flags: Mutex::new(Vec::new()), stream_response_flags: Mutex::new(Vec::new()), request_methods: Mutex::new(Vec::new()), request_uris: Mutex::new(Vec::new()), @@ -319,6 +321,14 @@ impl StubHttpClient { .clone() } + /// Return cache-bypass flags captured per `send` or `send_async` call, in order. + pub(crate) fn recorded_cache_bypass_flags(&self) -> Vec { + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .clone() + } + /// Return streaming-response flags captured per `send` call, in order. pub fn recorded_stream_response_flags(&self) -> Vec { self.stream_response_flags @@ -376,6 +386,10 @@ impl PlatformHttpClient for StubHttpClient { .lock() .expect("should lock image optimizer options") .push(request.image_optimizer.clone()); + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .push(request.bypass_cache); self.stream_response_flags .lock() .expect("should lock stream response flags") @@ -456,6 +470,10 @@ impl PlatformHttpClient for StubHttpClient { .lock() .expect("should lock calls") .push(backend_name.clone()); + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .push(request.bypass_cache); let headers: Vec<(String, String)> = request .request @@ -742,6 +760,11 @@ mod tests { vec!["stub-backend"], "should record the backend name" ); + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "should record the default cache-bypass flag" + ); } #[test] @@ -787,8 +810,9 @@ mod tests { let pending_a = futures::executor::block_on(stub.send_async(make_req("backend-a"))) .expect("should start request a"); - let pending_b = futures::executor::block_on(stub.send_async(make_req("backend-b"))) - .expect("should start request b"); + let pending_b = + futures::executor::block_on(stub.send_async(make_req("backend-b").with_cache_bypass())) + .expect("should start request b"); assert_eq!( pending_a.backend_name(), @@ -827,6 +851,11 @@ mod tests { vec!["backend-a", "backend-b"], "should record both send_async calls in order" ); + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false, true], + "should record both send_async cache-bypass flags in order" + ); } #[test] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d6e9d2ee..8936c2e05 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1739,6 +1739,11 @@ pub async fn handle_publisher_request( } ); + if should_run_ad_stack { + req.headers_mut().remove(header::IF_NONE_MATCH); + req.headers_mut().remove(header::IF_MODIFIED_SINCE); + } + // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); // Strip the internal `fastly-ssl` scheme signal before forwarding to the @@ -1757,11 +1762,11 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. - let mut response = match services - .http_client() - .send(PlatformHttpRequest::new(req, backend_name)) - .await - { + let mut publisher_request = PlatformHttpRequest::new(req, backend_name); + if should_run_ad_stack { + publisher_request = publisher_request.with_cache_bypass(); + } + let mut response = match services.http_client().send(publisher_request).await { Ok(platform_response) => platform_response.response, Err(err) => { if let Some(dispatched) = dispatched_auction.take() { @@ -1785,6 +1790,30 @@ pub async fn handle_publisher_request( response.headers().len() ); + if should_run_ad_stack && response.status() == StatusCode::NOT_MODIFIED { + if let Some(dispatched) = dispatched_auction.take() { + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "unexpected_origin_304", + ) + .await; + } + + let response = Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header(header::CACHE_CONTROL, "private, no-store") + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(EdgeBody::from( + "Publisher origin returned an invalid conditional response", + )) + .change_context(TrustedServerError::Proxy { + message: "failed to build unexpected origin 304 response".to_string(), + })?; + return Ok(PublisherResponse::Buffered(response)); + } + let ad_slots_script = if should_run_ad_stack { settings .creative_opportunities @@ -1794,10 +1823,9 @@ pub async fn handle_publisher_request( None }; - // §4.7: HTML carrying inline per-user bid data must never be shared-cached. - // `private, max-age=0` is deliberate (not `no-store`): it keeps the page - // BFCache-eligible while restricting reuse to the same user's browser with - // revalidation; `Surrogate-Control` removal handles the Fastly shared cache. + // §4.7: HTML with synthesized per-navigation auction state must not be + // stored or validated as an origin representation. Strip both browser and + // surrogate validators/cache directives before returning it. // // Gate on `should_run_ad_stack` rather than content-type alone: when no slot // matched, the feature is disabled, or this is not an ad-eligible navigation, @@ -1814,8 +1842,10 @@ pub async fn handle_publisher_request( if should_run_ad_stack && is_html_content_type(origin_content_type) { response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_static("private, max-age=0"), + HeaderValue::from_static("private, no-store"), ); + response.headers_mut().remove(header::ETAG); + response.headers_mut().remove(header::LAST_MODIFIED); response.headers_mut().remove("surrogate-control"); response.headers_mut().remove("fastly-surrogate-control"); } @@ -3033,6 +3063,526 @@ mod tests { .expect("should proxy publisher request") } + mod ssat_cache_policy_tests { + use super::*; + use crate::auction::provider::AuctionProvider; + use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; + use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; + use crate::platform::test_support::{ + NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, + }; + use crate::platform::{ClientInfo, PlatformPendingRequest, PlatformResponse}; + use crate::test_support::tests::crate_test_settings_str; + + const ORIGIN_ETAG: &str = "\"origin-tag\""; + const ORIGIN_LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT"; + const UNEXPECTED_304_PROVIDER: &str = "example_navigation_bidder"; + const UNEXPECTED_304_BACKEND: &str = "example-navigation-bidder-backend"; + + struct DispatchingTestProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for DispatchingTestProvider { + fn provider_name(&self) -> &'static str { + UNEXPECTED_304_PROVIDER + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let request = PlatformHttpRequest::new( + HttpRequest::builder() + .method(Method::POST) + .uri("https://bidder.example.com/navigation-bids") + .body(EdgeBody::empty()) + .expect("should build test provider request"), + UNEXPECTED_304_BACKEND, + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "test provider launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run for an unexpected origin 304"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name( + &self, + _services: &RuntimeServices, + _timeout_ms: u32, + ) -> Option { + Some(UNEXPECTED_304_BACKEND.to_string()) + } + } + + #[derive(Default)] + struct RecordingTelemetrySink { + batches: Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionTelemetrySink for RecordingTelemetrySink { + async fn emit_auction_events( + &self, + _services: &RuntimeServices, + batch: AuctionEventBatch, + ) -> Result<(), Report> { + self.batches + .lock() + .expect("should lock telemetry batches") + .push(batch); + Ok(()) + } + } + + fn settings_with_enabled_auction_and_creative_opportunities() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\n\n\ + [creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml) + .expect("should parse settings with auction and creative opportunities enabled") + } + + fn settings_with_dispatching_provider() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\nproviders = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n\ + [creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml) + .expect("should parse settings with the dispatching test provider") + } + + fn services_with_telemetry( + http_client: Arc, + telemetry_sink: Arc, + ) -> RuntimeServices { + let telemetry_sink: Arc = telemetry_sink; + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(StubBackend)) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .auction_telemetry_sink(telemetry_sink) + .client_info(ClientInfo::default()) + .build() + } + + fn article_slot() -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: "article-slot".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/article".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + }], + floor_price: None, + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + } + } + + fn conditional_navigation_request() -> Request { + HttpRequest::builder() + .method(Method::GET) + .uri("https://ts.example.com/article") + .header(header::HOST, "ts.example.com") + .header("sec-fetch-dest", "document") + .header(header::IF_NONE_MATCH, ORIGIN_ETAG) + .header(header::IF_MODIFIED_SINCE, ORIGIN_LAST_MODIFIED) + .body(EdgeBody::empty()) + .expect("should build conditional navigation request") + } + + fn queue_cacheable_html_response(stub: &StubHttpClient) { + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ("last-modified", ORIGIN_LAST_MODIFIED), + ("surrogate-control", "max-age=300"), + ("fastly-surrogate-control", "max-age=300"), + ], + ); + } + + async fn run_with_slots( + settings: &Settings, + services: &RuntimeServices, + slots: &[CreativeOpportunitySlot], + req: Request, + ) -> PublisherResponse { + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + run_with_orchestrator(settings, services, &orchestrator, slots, req).await + } + + async fn run_with_orchestrator( + settings: &Settings, + services: &RuntimeServices, + orchestrator: &AuctionOrchestrator, + slots: &[CreativeOpportunitySlot], + req: Request, + ) -> PublisherResponse { + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(None, consent); + + handle_publisher_request( + settings, + services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator, + slots, + registry: None, + }, + req, + ) + .await + .expect("should proxy publisher request") + } + + fn response_head(response: PublisherResponse) -> http::response::Parts { + match response { + PublisherResponse::Buffered(response) + | PublisherResponse::Stream { response, .. } + | PublisherResponse::PassThrough { response, .. } => response.into_parts().0, + } + } + + fn recorded_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + + #[tokio::test] + async fn eligible_navigation_bypasses_cache_and_returns_non_storable_html() { + // Arrange + let settings = settings_with_enabled_auction_and_creative_opportunities(); + let stub = Arc::new(StubHttpClient::new()); + queue_cacheable_html_response(&stub); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let slots = [article_slot()]; + let req = conditional_navigation_request(); + + // Act + let response = run_with_slots(&settings, &services, &slots, req).await; + let response_head = response_head(response); + + // Assert + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![true], + "eligible publisher navigation should bypass the platform cache" + ); + let recorded_requests = stub.recorded_request_headers(); + let outbound_headers = recorded_requests + .first() + .expect("should record the outbound publisher request"); + assert_eq!( + recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), + None, + "eligible publisher request should not forward If-None-Match" + ); + assert_eq!( + recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), + None, + "eligible publisher request should not forward If-Modified-Since" + ); + assert_eq!( + response_head + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "eligible HTML response should be private and non-storable" + ); + for header_name in [ + header::ETAG, + header::LAST_MODIFIED, + header::HeaderName::from_static("surrogate-control"), + header::HeaderName::from_static("fastly-surrogate-control"), + ] { + assert!( + !response_head.headers.contains_key(&header_name), + "eligible HTML response should remove {header_name}" + ); + } + } + + #[tokio::test] + async fn navigation_without_matched_slots_preserves_origin_cache_policy() { + // Arrange + let settings = settings_with_enabled_auction_and_creative_opportunities(); + let stub = Arc::new(StubHttpClient::new()); + queue_cacheable_html_response(&stub); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = conditional_navigation_request(); + + // Act + let response = run_with_slots(&settings, &services, &[], req).await; + let response_head = response_head(response); + + // Assert + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "publisher navigation without matched slots should use the default cache mode" + ); + let recorded_requests = stub.recorded_request_headers(); + let outbound_headers = recorded_requests + .first() + .expect("should record the outbound publisher request"); + assert_eq!( + recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), + Some(ORIGIN_ETAG), + "publisher request without matched slots should preserve If-None-Match" + ); + assert_eq!( + recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), + Some(ORIGIN_LAST_MODIFIED), + "publisher request without matched slots should preserve If-Modified-Since" + ); + + for (header_name, expected) in [ + (header::CACHE_CONTROL, "public, max-age=300"), + (header::ETAG, ORIGIN_ETAG), + (header::LAST_MODIFIED, ORIGIN_LAST_MODIFIED), + ( + header::HeaderName::from_static("surrogate-control"), + "max-age=300", + ), + ( + header::HeaderName::from_static("fastly-surrogate-control"), + "max-age=300", + ), + ] { + assert_eq!( + response_head + .headers + .get(&header_name) + .and_then(|value| value.to_str().ok()), + Some(expected), + "publisher response without matched slots should preserve {header_name}" + ); + } + } + + #[tokio::test] + async fn eligible_navigation_rejects_unexpected_origin_304() { + for content_type in [None, Some("text/html; charset=utf-8")] { + // Arrange + let settings = settings_with_dispatching_provider(); + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(DispatchingTestProvider)); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let stub = Arc::new(StubHttpClient::new()); + + // `send_async` consumes the first response before the publisher + // origin request consumes the second response. + stub.push_response(200, b"unused provider response".to_vec()); + let mut origin_headers = vec![ + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ("last-modified", ORIGIN_LAST_MODIFIED), + ("surrogate-control", "max-age=300"), + ("fastly-surrogate-control", "max-age=300"), + ]; + if let Some(content_type) = content_type { + origin_headers.push(("content-type", content_type)); + } + stub.push_response_with_headers(304, Vec::new(), origin_headers); + let services = services_with_telemetry( + Arc::clone(&stub) as Arc, + Arc::clone(&telemetry_sink), + ); + let slots = [article_slot()]; + + // Act + let response = run_with_orchestrator( + &settings, + &services, + &orchestrator, + &slots, + conditional_navigation_request(), + ) + .await; + + // Assert + let response = match response { + PublisherResponse::Buffered(response) => response, + PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { + panic!("unexpected origin 304 should return a buffered response") + } + }; + assert_eq!( + response.status(), + StatusCode::BAD_GATEWAY, + "eligible origin 304 should fail closed with or without Content-Type" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "eligible origin 304 should return an explicitly non-storable response" + ); + for header_name in [ + header::ETAG, + header::LAST_MODIFIED, + header::HeaderName::from_static("surrogate-control"), + header::HeaderName::from_static("fastly-surrogate-control"), + ] { + assert!( + !response.headers().contains_key(&header_name), + "eligible origin 304 should not forward {header_name}" + ); + } + + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + let summary_rows: Vec<_> = batches + .iter() + .flat_map(AuctionEventBatch::rows) + .filter(|row| row.event_kind == "summary") + .collect(); + assert_eq!( + summary_rows.len(), + 1, + "unexpected origin 304 should emit exactly one summary row" + ); + assert_eq!( + summary_rows[0].terminal_status.as_deref(), + Some("abandoned"), + "unexpected origin 304 should abandon the dispatched auction" + ); + assert_eq!( + summary_rows[0].terminal_reason.as_deref(), + Some("unexpected_origin_304"), + "unexpected origin 304 should use the bounded telemetry reason" + ); + } + } + + #[tokio::test] + async fn noneligible_origin_304_preserves_conditional_response_metadata() { + // Arrange + let settings = settings_with_enabled_auction_and_creative_opportunities(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 304, + Vec::new(), + vec![ + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ("last-modified", ORIGIN_LAST_MODIFIED), + ("surrogate-control", "max-age=300"), + ("fastly-surrogate-control", "max-age=300"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + // Act + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; + + // Assert + let response = match response { + PublisherResponse::Buffered(response) => response, + PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { + panic!("noneligible origin 304 should remain buffered") + } + }; + assert_eq!( + response.status(), + StatusCode::NOT_MODIFIED, + "noneligible origin 304 should preserve its status" + ); + for (header_name, expected) in [ + (header::CACHE_CONTROL, "public, max-age=300"), + (header::ETAG, ORIGIN_ETAG), + (header::LAST_MODIFIED, ORIGIN_LAST_MODIFIED), + ( + header::HeaderName::from_static("surrogate-control"), + "max-age=300", + ), + ( + header::HeaderName::from_static("fastly-surrogate-control"), + "max-age=300", + ), + ] { + assert_eq!( + response + .headers() + .get(&header_name) + .and_then(|value| value.to_str().ok()), + Some(expected), + "noneligible origin 304 should preserve {header_name}" + ); + } + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "noneligible publisher navigation should use the default cache mode" + ); + let recorded_requests = stub.recorded_request_headers(); + let outbound_headers = recorded_requests + .first() + .expect("should record the outbound publisher request"); + assert_eq!( + recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), + Some(ORIGIN_ETAG), + "noneligible publisher request should preserve If-None-Match" + ); + assert_eq!( + recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), + Some(ORIGIN_LAST_MODIFIED), + "noneligible publisher request should preserve If-Modified-Since" + ); + } + } + #[tokio::test] async fn publisher_request_uses_platform_http_client_with_http_types() { let settings = create_test_settings(); diff --git a/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md b/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md new file mode 100644 index 000000000..cd89dea13 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md @@ -0,0 +1,612 @@ +# SSAT Root Document 304 Prevention Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Guarantee that every auction-eligible SSAT navigation receives a complete, non-stored publisher document instead of a browser- or Fastly-generated 304. + +**Architecture:** Add a default-off cache-bypass capability to the platform HTTP request and map it to Fastly pass mode. The publisher path enables it only for the existing `should_run_ad_stack` gate, strips browser validators before the origin fetch, removes response validators while setting `private, no-store`, and converts an unexpected eligible-origin 304 into a non-cacheable 502 with abandoned-auction telemetry. + +**Tech Stack:** Rust 2024, `edgezero_core` HTTP types, Fastly Rust SDK 0.12.1, async traits, Viceroy tests, `error-stack`. + +--- + +## File Map + +| File | Responsibility | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/platform/http.rs` | Define the platform-neutral, default-off cache-bypass request option. | +| `crates/trusted-server-core/src/platform/test_support.rs` | Record cache-bypass options in the shared stub HTTP client for publisher tests. | +| `crates/trusted-server-adapter-fastly/src/platform.rs` | Translate the platform option to Fastly `Request::set_pass(true)` in both send paths. | +| `crates/trusted-server-core/src/publisher.rs` | Apply the eligibility gate, strip validators, set the synthesized response policy, fail closed on unexpected 304, and test the complete behavior. | + +No configuration schema, JavaScript, `/page-bids`, auction-ID, asset, or integration files change. + +### Task 1: Add Platform Cache-Bypass Metadata and Test Recording + +**Files:** + +- Modify: `crates/trusted-server-core/src/platform/http.rs` +- Modify: `crates/trusted-server-core/src/platform/test_support.rs` + +- [ ] **Step 1: Write failing constructor and builder tests** + +Add these tests to `platform::http::tests`: + +```rust +#[test] +fn platform_http_request_cache_bypass_defaults_to_false() { + let request = edgezero_core::http::request_builder() + .uri("https://example.com/") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let request = PlatformHttpRequest::new(request, "origin"); + + assert!( + !request.bypass_cache, + "ordinary platform requests should retain normal cache behavior" + ); +} + +#[test] +fn platform_http_request_cache_bypass_builder_enables_bypass() { + let request = edgezero_core::http::request_builder() + .uri("https://example.com/") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let request = PlatformHttpRequest::new(request, "origin").with_cache_bypass(); + + assert!( + request.bypass_cache, + "cache-bypass builder should enable platform cache bypass" + ); +} +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: + +```bash +cargo test-fastly platform_http_request_cache_bypass -- --nocapture +``` + +Expected: compilation fails because `bypass_cache` and `with_cache_bypass` do not exist. + +- [ ] **Step 3: Add the minimal platform request option** + +Add a documented public field and builder to `PlatformHttpRequest`: + +```rust +/// Whether the platform's intermediary response cache must be bypassed. +/// +/// Adapters without an intermediary outbound cache may treat this as already +/// satisfied. The option defaults to `false` so existing call sites preserve +/// their current cache behavior. +pub bypass_cache: bool, +``` + +Initialize it to `false` in `new`, then add: + +```rust +/// Bypass the platform's intermediary response cache for this request. +#[must_use] +pub fn with_cache_bypass(mut self) -> Self { + self.bypass_cache = true; + self +} +``` + +- [ ] **Step 4: Extend the shared HTTP stub** + +Add `cache_bypass_flags: Mutex>` to `StubHttpClient`, initialize it, +record `request.bypass_cache` in both `send` and `send_async`, and expose: + +```rust +pub fn recorded_cache_bypass_flags(&self) -> Vec { + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .clone() +} +``` + +Record the flag before consuming `request.request`. + +- [ ] **Step 5: Run targeted platform tests** + +Run: + +```bash +cargo test-fastly platform_http_request_cache_bypass -- --nocapture +cargo test-fastly platform::test_support -- --nocapture +``` + +Expected: both commands pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/platform/http.rs crates/trusted-server-core/src/platform/test_support.rs +git commit -m "Add platform HTTP cache bypass option" +``` + +### Task 2: Map Cache Bypass to Fastly Pass Mode + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` + +- [ ] **Step 1: Write failing Fastly cache-override tests** + +Introduce a private helper named `apply_fastly_cache_bypass` and add tests that +construct a `fastly::Request`, invoke the helper, and inspect the SDK's derived +debug representation: + +```rust +#[test] +fn apply_fastly_cache_bypass_sets_pass_when_enabled() { + let mut request = fastly::Request::get("https://example.com/"); + + apply_fastly_cache_bypass(&mut request, true); + + assert!( + format!("{request:?}").contains("cache_override: Pass"), + "enabled bypass should select Fastly pass mode" + ); +} + +#[test] +fn apply_fastly_cache_bypass_preserves_default_when_disabled() { + let mut request = fastly::Request::get("https://example.com/"); + + apply_fastly_cache_bypass(&mut request, false); + + assert!( + format!("{request:?}").contains("cache_override: None"), + "disabled bypass should preserve Fastly read-through caching" + ); +} +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: + +```bash +cargo test-fastly apply_fastly_cache_bypass -- --nocapture +``` + +Expected: compilation fails because the helper does not exist. + +- [ ] **Step 3: Implement and use the Fastly mapping** + +Add: + +```rust +fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) { + if bypass_cache { + request.set_pass(true); + } +} +``` + +In `FastlyPlatformHttpClient::send`, copy `request.bypass_cache` before moving +the inner request, make the converted Fastly request mutable, and invoke the +helper before `.send()`. + +Do the same in `send_async` before `.send_async()`. Preserve the existing Image +Optimizer and streaming-response rejection behavior. + +- [ ] **Step 4: Run targeted Fastly adapter tests** + +Run: + +```bash +cargo test-fastly apply_fastly_cache_bypass -- --nocapture +cargo test-fastly fastly_platform_http_client -- --nocapture +``` + +Expected: all matching tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Bypass Fastly cache for marked HTTP requests" +``` + +### Task 3: Protect Eligible Publisher Requests and Successful HTML Responses + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add focused eligible- and ineligible-request test helpers** + +Add a `ssat_cache_policy_tests` module beside the existing handler-level test +modules. Reuse `StubHttpClient`, `StubBackend`, no-op services, a +non-regulated `EcContext`, a slot matching `/article`, and an enabled +orchestrator. A launch-failing provider is sufficient for these policy tests: +eligibility depends on the configured gate, not the dispatch outcome. + +The eligible request must be: + +```rust +HttpRequest::builder() + .method(Method::GET) + .uri("https://ts.example.com/article") + .header(header::HOST, "ts.example.com") + .header("sec-fetch-dest", "document") + .header(header::IF_NONE_MATCH, "\"origin-tag\"") + .header(header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT") + .body(EdgeBody::empty()) + .expect("should build eligible navigation") +``` + +Queue an origin 200 with `Content-Type: text/html`, `Cache-Control: public, +max-age=300`, `ETag`, `Last-Modified`, `Surrogate-Control`, and +`Fastly-Surrogate-Control`. + +- [ ] **Step 2: Write the failing eligible-request test** + +Drive `handle_publisher_request` and assert: + +```rust +assert_eq!(stub.recorded_cache_bypass_flags(), vec![true]); +let origin_headers = stub + .recorded_request_headers() + .into_iter() + .last() + .expect("should record publisher request headers"); +assert!(!origin_headers.iter().any(|(name, _)| name == "if-none-match")); +assert!(!origin_headers.iter().any(|(name, _)| name == "if-modified-since")); +``` + +Extract the response headers from the returned `PublisherResponse` and assert: + +```rust +assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("private, no-store")) +); +for name in [ + header::ETAG.as_str(), + header::LAST_MODIFIED.as_str(), + "surrogate-control", + "fastly-surrogate-control", +] { + assert!(response.headers().get(name).is_none(), "{name} should be removed"); +} +``` + +- [ ] **Step 3: Write the failing noneligible-request test** + +Use the existing `run_publisher_proxy` helper with no slots and the same +conditional headers. Queue a normal response and assert: + +```rust +assert_eq!(stub.recorded_cache_bypass_flags(), vec![false]); +assert!(origin_headers.iter().any(|(name, _)| name == "if-none-match")); +assert!(origin_headers.iter().any(|(name, _)| name == "if-modified-since")); +``` + +Also assert the origin's cache policy and validators remain unchanged. This is +the regression guard for HEAD, bots, prefetches, no-slot pages, and every other +request that fails the existing gate. + +- [ ] **Step 4: Run the publisher policy tests and verify they fail** + +Run: + +```bash +cargo test-fastly ssat_cache_policy_tests -- --nocapture +``` + +Expected: the eligible assertions fail because validators are forwarded, +bypass is false, the response uses `private, max-age=0`, and validators remain. + +- [ ] **Step 5: Implement request protection** + +Immediately after auction dispatch and before URI/Host rewriting, add: + +```rust +if should_run_ad_stack { + req.headers_mut().remove(header::IF_NONE_MATCH); + req.headers_mut().remove(header::IF_MODIFIED_SINCE); +} +``` + +Build the publisher request once, conditionally apply the builder, and send it: + +```rust +let platform_request = PlatformHttpRequest::new(req, backend_name); +let platform_request = if should_run_ad_stack { + platform_request.with_cache_bypass() +} else { + platform_request +}; +``` + +- [ ] **Step 6: Implement successful HTML response protection** + +Within the existing `should_run_ad_stack && is_html_content_type(...)` branch: + +```rust +response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), +); +response.headers_mut().remove(header::ETAG); +response.headers_mut().remove(header::LAST_MODIFIED); +response.headers_mut().remove("surrogate-control"); +response.headers_mut().remove("fastly-surrogate-control"); +``` + +Update the adjacent rationale: synthesized, per-navigation auction state must +not be stored or validated as though it were the origin representation. + +- [ ] **Step 7: Run targeted publisher tests** + +Run: + +```bash +cargo test-fastly ssat_cache_policy_tests -- --nocapture +cargo test-fastly publisher_request_uses_platform_http_client_with_http_types -- --nocapture +cargo test-fastly response_carries_body_preserves_bodiless_metadata -- --nocapture +``` + +Expected: all commands pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Prevent SSAT publisher document revalidation" +``` + +### Task 4: Fail Closed on an Unexpected Eligible-Origin 304 + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add a dispatching test provider** + +Within `ssat_cache_policy_tests`, add a provider whose `request_bids` sends one +request through `context.services.http_client().send_async(...)`. Give it a +stable backend name and make `parse_response` panic because an unexpected 304 +must abandon, not collect, the pending request. + +Queue responses in this order because `StubHttpClient` consumes the provider +response during `send_async` before the publisher response during `send`: + +```rust +stub.push_response(200, b"unused provider response".to_vec()); +stub.push_response_with_headers( + 304, + Vec::new(), + vec![("etag", "\"origin-tag\"")], +); +``` + +- [ ] **Step 2: Write the failing unexpected-304 test** + +Drive an eligible navigation with the dispatching provider and recording +telemetry sink. Assert the returned variant is `PublisherResponse::Buffered` +with: + +```rust +assert_eq!(response.status(), StatusCode::BAD_GATEWAY); +assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("private, no-store")) +); +assert!(response.headers().get(header::ETAG).is_none()); +assert!(response.headers().get(header::LAST_MODIFIED).is_none()); +assert!(response.headers().get("surrogate-control").is_none()); +assert!(response.headers().get("fastly-surrogate-control").is_none()); +``` + +Flatten telemetry rows and assert exactly one summary row has +`terminal_status == Some("abandoned")` and +`terminal_reason == Some("unexpected_origin_304")`. Assert no provider parse or +auction collection occurred. + +Cover both a typical 304 without `Content-Type` and a 304 carrying +`Content-Type: text/html` using a small table/helper so response classification +cannot affect the guard. + +- [ ] **Step 3: Verify the test fails** + +Run: + +```bash +cargo test-fastly unexpected_origin_304 -- --nocapture +``` + +Expected: the handler returns 304 and no `unexpected_origin_304` telemetry. + +- [ ] **Step 4: Add a noneligible-304 regression test** + +Use `run_publisher_proxy` with no slots, queue a 304 carrying `ETag`, +`Last-Modified`, and origin cache headers, and assert: + +```rust +let response = match run_publisher_proxy(&settings, &services, request).await { + PublisherResponse::Buffered(response) => response, + _ => panic!("noneligible 304 should remain a buffered response"), +}; +assert_eq!(response.status(), StatusCode::NOT_MODIFIED); +assert_eq!(response.headers().get(header::ETAG), Some(&origin_etag)); +assert_eq!( + response.headers().get(header::LAST_MODIFIED), + Some(&origin_last_modified) +); +``` + +Also assert the request used `bypass_cache == false` and preserved its incoming +conditional headers. This proves the 304-to-502 guard is eligibility-scoped +rather than global. + +- [ ] **Step 5: Implement the fail-closed guard before content classification** + +Immediately after receiving/logging the publisher response and before reading +its content type: + +```rust +if should_run_ad_stack && response.status() == StatusCode::NOT_MODIFIED { + if let Some(dispatched) = dispatched_auction.take() { + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "unexpected_origin_304", + ) + .await; + } + + let response = Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header(header::CACHE_CONTROL, "private, no-store") + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(EdgeBody::from("Publisher origin returned an invalid conditional response")) + .change_context(TrustedServerError::Proxy { + message: "failed to build unexpected origin 304 response".to_string(), + })?; + return Ok(PublisherResponse::Buffered(response)); +} +``` + +Because the response is built from a fresh builder, it contains no origin +validators or surrogate cache headers and still goes through the adapter's +normal finalization after the publisher handler returns. + +- [ ] **Step 6: Run the unexpected-304 and generic bodiless tests** + +Run: + +```bash +cargo test-fastly ssat_cache_policy_tests -- --nocapture +cargo test-fastly response_carries_body_preserves_bodiless_metadata -- --nocapture +cargo test-fastly serve_static -- --nocapture +``` + +Expected: eligible publisher 304 tests return 502; generic publisher/static +conditional semantics remain passing. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Reject unexpected SSAT origin 304 responses" +``` + +### Task 5: Full Verification and Scope Audit + +**Files:** + +- Verify only; modify production files only if a verification failure exposes a defect in the approved scope. + +- [ ] **Step 1: Format and inspect the diff** + +Run: + +```bash +cargo fmt --all +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git status --short +``` + +Expected: formatting succeeds; no whitespace errors; only the spec, plan, two +core platform files, publisher, and Fastly platform adapter are changed. Local +`fastly.toml` remains untouched. + +- [ ] **Step 2: Run all target-specific test suites** + +Run: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: all tests pass. + +- [ ] **Step 3: Run JavaScript and documentation gates** + +Run from `crates/trusted-server-js/lib`: + +```bash +npx vitest run +npm run format +``` + +Then run from `docs`: + +```bash +npm run format +``` + +Expected: JavaScript tests pass and both format commands complete without +errors. Inspect `git status --short` afterward; formatting must not introduce +unrelated content changes. + +- [ ] **Step 4: Run formatting and target-specific lints/checks** + +Run: + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo check-fastly +cargo check-axum +cargo check-cloudflare +cargo check-spin +``` + +Expected: all checks pass with no warnings promoted to errors. + +- [ ] **Step 5: Audit constructors and behavior boundaries** + +Run: + +```bash +rg -n "with_cache_bypass|bypass_cache|set_pass" crates +rg -n "If-None-Match|If-Modified-Since|private, no-store|unexpected_origin_304" crates/trusted-server-core/src/publisher.rs +git diff origin/main...HEAD -- fastly.toml crates/trusted-server-js +``` + +Expected: + +- `with_cache_bypass` is used only by the eligible publisher fetch. +- Fastly honors the option in both send paths. +- all other request constructors default to false; +- `fastly.toml` and JavaScript have no branch diff. + +- [ ] **Step 6: Commit any formatting-only changes if needed** + +```bash +git add crates/trusted-server-core/src/platform/http.rs \ + crates/trusted-server-core/src/platform/test_support.rs \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Format SSAT 304 prevention changes" +``` + +Skip this commit when `cargo fmt --all` produces no new diff. + +- [ ] **Step 7: Request final code review** + +Run the repository's code-review workflow against `origin/main...HEAD`. Resolve +only correctness, security, test, or approved-scope findings, then repeat the +affected verification commands before reporting completion. diff --git a/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md b/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md new file mode 100644 index 000000000..38cb1a8a6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md @@ -0,0 +1,146 @@ +# SSAT Root Document 304 Prevention Design + +## Problem + +An auction-eligible publisher navigation can currently return `304 Not Modified` +on reload. Trusted Server starts the server-side auction before fetching the +publisher document, but a 304 has no HTML body. The HTML processor therefore +cannot inject the current request's slot state or auction result, and the browser +reuses a previously synthesized document. + +The behavior has two independent causes: + +- Trusted Server forwards browser validators (`If-None-Match` and + `If-Modified-Since`) to the publisher origin. +- The Fastly adapter sends publisher requests through its read-through cache. + +Successful synthesized HTML is also returned with `private, max-age=0` while +retaining the publisher's `ETag` and `Last-Modified`. That explicitly permits +browser storage and revalidation even though those validators describe the +unmodified origin representation, not the personalized document returned by +Trusted Server. + +## Scope + +This change applies only when the existing `should_run_ad_stack` decision is +true. That decision already limits the path to GET document navigations that are +not prefetches or bots and that have matched ad slots, permitted consent, and an +enabled auction. + +The change does not alter: + +- HEAD requests; +- bots or prefetches; +- requests without matching slots or auction consent; +- publisher requests when the auction is disabled; +- static Trusted Server assets and their intentional conditional responses; +- the `/page-bids` client-side auction endpoint; or +- auction identifiers. + +## Design + +### Publisher request + +Immediately before the publisher-origin fetch, an auction-eligible request will +remove `If-None-Match` and `If-Modified-Since`. This forces the publisher origin +to return a complete representation instead of validating a browser-cached +copy. + +The corresponding `PlatformHttpRequest` will carry an explicit, default-false +cache-bypass option. The Fastly adapter will translate that option to +`fastly::Request::set_pass(true)` before both synchronous and asynchronous sends. +All other `PlatformHttpRequest` call sites retain their current behavior because +the option defaults to false. Adapters without an intermediary read-through +cache require no runtime change. + +This bypass is deliberately scoped to the publisher fetch for an eligible SSAT +navigation. It must not be set on assets, image optimization, SSP fan-out, or +integration calls. + +### Publisher response + +When the eligible publisher response is HTML, Trusted Server will: + +- set `Cache-Control: private, no-store`; +- remove `ETag` and `Last-Modified`; +- continue removing `Surrogate-Control` and + `Fastly-Surrogate-Control`. + +`no-store` is an intentional correctness choice. It prevents the browser from +retaining a synthesized document that could later be resurrected through +revalidation. This trades away some browser back/forward-cache eligibility and +increases publisher-origin traffic, but it guarantees that an eligible +navigation receives a fresh body for SSAT injection. + +### Unexpected origin 304 + +An eligible publisher request will already be unconditional and will bypass the +Fastly cache. If the publisher nevertheless returns 304, Trusted Server will not +forward it to the browser. It will abandon the in-flight auction using a distinct +reason and return a synthetic `502 Bad Gateway` response with +`Cache-Control: private, no-store` and no validators or surrogate cache headers. + +The implementation will not retry. The first request is already unconditional +and cache-bypassed, so repeating it is unlikely to produce a body and would add +origin traffic and latency. Returning an explicit non-cacheable error is safer +than allowing the browser to reuse stale personalized HTML. + +## Data Flow + +1. Trusted Server evaluates the existing SSAT eligibility gates. +2. If eligible, it dispatches the server-side auction as it does today. +3. Before the publisher fetch, it removes browser conditional headers and marks + the platform request as cache-bypassed. +4. Fastly sends the request directly to the configured publisher backend. +5. A complete HTML response enters the existing buffering/HTML injection path. +6. Trusted Server injects current slot and auction data and removes all storage + and validation metadata before responding. +7. If the origin unexpectedly returns 304, Trusted Server abandons the auction + and returns the non-cacheable 502 instead. + +## Error Handling and Observability + +Existing publisher transport-error handling remains unchanged. The unexpected +304 case will reuse the existing abandoned-auction event mechanism with a +specific reason `unexpected_origin_304`, allowing it to be distinguished +from transport failures and ordinary bodiless responses. + +Noneligible publisher requests retain their existing 304 behavior. This avoids a +global semantic change to the proxy and keeps normal conditional caching intact +outside personalized SSAT documents. + +## Testing + +Tests will prove the behavior at the relevant boundaries: + +- `PlatformHttpRequest` defaults to ordinary cache behavior and its builder + enables bypass explicitly. +- The Fastly adapter applies pass mode only when requested. +- An eligible publisher request removes both conditional headers and requests a + platform cache bypass. +- A noneligible request preserves its conditional headers and ordinary cache + behavior. +- Eligible HTML receives `private, no-store` and has origin and surrogate + validators removed. +- An eligible origin 304, with or without `Content-Type`, is never returned as a + client 304 and produces one abandoned-auction observation. +- Existing HEAD, prefetch, bot, noneligible 304, asset, and `/page-bids` tests + remain unchanged. + +Targeted tests will be written before implementation. Final verification will +use the repository's target-specific test, formatting, and lint commands rather +than a bare workspace build or test. + +## Risks + +- Every eligible SSAT navigation reaches the publisher origin and transfers a + full document, increasing origin load and potentially TTFB. +- `no-store` can reduce back/forward-cache effectiveness, depending on browser + behavior. +- A publisher that incorrectly emits 304 for an unconditional request will now + expose a visible 502 instead of stale content. The distinct telemetry reason + makes this condition diagnosable. + +These costs are accepted because the requested invariant is that every eligible +SSAT navigation receives a complete document into which the current auction can +be injected.