diff --git a/Cargo.lock b/Cargo.lock
index 70316712ac..74afc2b7dd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1530,7 +1530,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"base16ct 1.0.0",
"serde",
@@ -1543,7 +1543,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-axum"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"anyhow",
"async-trait",
@@ -1571,7 +1571,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-cloudflare"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"anyhow",
"async-trait",
@@ -1594,7 +1594,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-fastly"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"anyhow",
"async-stream",
@@ -1624,7 +1624,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-spin"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"anyhow",
"async-trait",
@@ -1651,7 +1651,7 @@ dependencies = [
[[package]]
name = "edgezero-cli"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"chrono",
"clap",
@@ -1676,7 +1676,7 @@ dependencies = [
[[package]]
name = "edgezero-core"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"anyhow",
"async-compression",
@@ -1708,7 +1708,7 @@ dependencies = [
[[package]]
name = "edgezero-macros"
version = "0.1.0"
-source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5"
+source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec"
dependencies = [
"log",
"proc-macro2",
diff --git a/Cargo.toml b/Cargo.toml
index 139cf4f610..bc866b335a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,12 +56,12 @@ cssparser = "0.36"
derive_more = { version = "2.0", features = ["display", "error"] }
directories = "5"
ed25519-dalek = { version = "2.2", features = ["rand_core"] }
-edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false }
-edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false }
-edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false }
-edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false }
-edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11" }
-edgezero-core = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false }
+edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false }
+edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false }
+edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false }
+edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false }
+edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" }
+edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false }
env_logger = "0.11"
error-stack = "0.6"
esi = "0.7.2"
diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs
index 3cc13fdfa8..159c203203 100644
--- a/crates/trusted-server-adapter-fastly/src/main.rs
+++ b/crates/trusted-server-adapter-fastly/src/main.rs
@@ -772,7 +772,7 @@ fn send_response_to_client(mut response: HttpResponse, counters: Option<&Sandbox
}
}
-/// Apply every late response mutation, then restore privacy invariants before headers commit.
+/// Apply late mutations, then restore privacy and encoding before headers commit.
fn apply_terminal_response_effects(
response: &mut HttpResponse,
request_filter_effects: Option<&RequestFilterEffects>,
@@ -800,6 +800,7 @@ fn apply_terminal_response_effects(
// `apply_finalize_headers` and normalized asset policy reapplication ran.
crate::middleware::enforce_set_cookie_cache_privacy(response);
crate::middleware::enforce_uncacheable_cache_privacy(response);
+ edgezero_adapter_fastly::delivery_compression::finalize_response(response);
}
const FALLBACK_UNAVAILABLE: &str = "unavailable";
@@ -903,6 +904,9 @@ mod tests {
use super::*;
use base64::Engine as _;
use edgezero_core::body::Body as EdgeBody;
+ use edgezero_core::delivery_compression::{
+ DeliveryCompressionIntent, DeliveryCompressionSupport,
+ };
use edgezero_core::http::response_builder;
use edgezero_core::http::{HeaderValue, Method, StatusCode};
use edgezero_core::ingress::{
@@ -1357,6 +1361,135 @@ mod tests {
assert!(response.headers().get("etag").is_none());
}
+ #[test]
+ fn late_filter_effects_cannot_change_offloaded_delivery_encoding() {
+ futures::executor::block_on(async {
+ let identity = b"
Example article";
+ for streaming in [false, true] {
+ let body = if streaming {
+ EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static(
+ identity,
+ )]))
+ } else {
+ EdgeBody::from(identity.to_vec())
+ };
+ let mut response = response_builder()
+ .header("content-type", "text/html")
+ .header("content-length", identity.len())
+ .header("cache-control", "private, no-store")
+ .body(body)
+ .expect("should install final identity output");
+ response.extensions_mut().insert(TerminalPrivateResponse);
+ DeliveryCompressionSupport::GzipBrotli
+ .mark_identity_response(&mut response)
+ .expect("should mark installed identity output");
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_some()
+ );
+ assert!(response.headers().get("x-compress-hint").is_none());
+ let effects = RequestFilterEffects {
+ request_headers: Vec::new(),
+ response_headers: vec![
+ HeaderMutation::set("content-encoding", "gzip"),
+ HeaderMutation::set("content-length", "9999"),
+ HeaderMutation::set("x-compress-hint", "off"),
+ HeaderMutation::set("vary", "Origin"),
+ HeaderMutation::set("cache-control", "public, s-maxage=3600"),
+ HeaderMutation::set("surrogate-control", "max-age=3600"),
+ HeaderMutation::set("cdn-cache-control", "max-age=3600"),
+ ],
+ };
+
+ apply_terminal_response_effects(&mut response, Some(&effects));
+
+ assert!(response.headers().get("content-encoding").is_none());
+ assert!(response.headers().get("content-length").is_none());
+ assert_eq!(response.headers()["x-compress-hint"], "on");
+ assert!(
+ response.headers().get_all("vary").iter().any(|value| {
+ value
+ .to_str()
+ .is_ok_and(|value| value.eq_ignore_ascii_case("accept-encoding"))
+ }),
+ "should restore delivery variation after late effects"
+ );
+ assert_eq!(response.headers()["cache-control"], "no-store, private");
+ assert!(response.headers().get("surrogate-control").is_none());
+ assert!(response.headers().get("cdn-cache-control").is_none());
+ assert_eq!(
+ response.body().is_stream(),
+ streaming,
+ "should preserve send form"
+ );
+ let body = response
+ .into_body()
+ .into_bytes_bounded(1024)
+ .await
+ .expect("should collect guest identity output");
+ assert_eq!(
+ body.as_ref(),
+ identity,
+ "should preserve exact identity bytes"
+ );
+ }
+ });
+ }
+
+ #[test]
+ fn terminal_delivery_finalization_preserves_unmarked_gzip() {
+ futures::executor::block_on(async {
+ let gzip = base64::engine::general_purpose::STANDARD
+ .decode(
+ "H4sIAAAAAAAAA7PJKMnNsbNJyk+ptHPNS85PSU1RKM5MysnMS7fRB4va6IOVAAB6wM8tKQAAAA==",
+ )
+ .expect("should decode actual gzip fixture");
+ for streaming in [false, true] {
+ let body = if streaming {
+ EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from(
+ gzip.clone(),
+ )]))
+ } else {
+ EdgeBody::from(gzip.clone())
+ };
+ let mut response = response_builder()
+ .header("content-type", "text/html")
+ .header("content-encoding", "gzip")
+ .header("content-length", gzip.len())
+ .header("vary", "Accept-Encoding")
+ .body(body)
+ .expect("should build encoded sibling");
+ let headers = response.headers().clone();
+
+ apply_terminal_response_effects(&mut response, None);
+
+ assert_eq!(
+ response.headers(),
+ &headers,
+ "should leave encoded framing unchanged"
+ );
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_none()
+ );
+ assert_eq!(
+ response
+ .into_body()
+ .into_bytes_bounded(1024)
+ .await
+ .expect("should read encoded sibling")
+ .as_ref(),
+ gzip.as_slice(),
+ "should never relabel or re-encode unmarked gzip"
+ );
+ }
+ });
+ }
+
#[test]
fn late_filter_effects_cannot_make_a_page_bids_response_public() {
let mut response = trusted_server_core::publisher::page_bids_preflight_denied();
diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs
index a670d31416..9a1119dab2 100644
--- a/crates/trusted-server-core/src/publisher.rs
+++ b/crates/trusted-server-core/src/publisher.rs
@@ -32,6 +32,9 @@ use brotli::enc::writer::CompressorWriter;
use chrono::{DateTime, Utc};
use cookie::CookieJar;
use edgezero_core::body::Body as EdgeBody;
+#[cfg(test)]
+use edgezero_core::delivery_compression::DeliveryCompressionIntent;
+use edgezero_core::delivery_compression::DeliveryCompressionSupport;
use error_stack::{Report, ResultExt};
use flate2::read::ZlibDecoder;
use flate2::write::{GzEncoder, ZlibEncoder};
@@ -501,9 +504,9 @@ fn set_response_compression(response: &mut Response, compression: Comp
.iter()
.any(|value| {
value.to_str().is_ok_and(|value| {
- value
- .split(',')
- .any(|name| name.trim().eq_ignore_ascii_case("accept-encoding"))
+ value.split(',').any(|name| {
+ name.trim() == "*" || name.trim().eq_ignore_ascii_case("accept-encoding")
+ })
})
});
if !varies_on_encoding {
@@ -514,6 +517,39 @@ fn set_response_compression(response: &mut Response, compression: Comp
response.headers_mut().remove(header::CONTENT_LENGTH);
}
+/// Pending final delivery, independent of origin decoding and template storage.
+#[derive(Clone, Copy, Debug)]
+pub(crate) enum OutputCompression {
+ Guest(Compression),
+ AdapterIdentity(DeliveryCompressionSupport),
+}
+
+impl OutputCompression {
+ fn compression(self) -> Compression {
+ match self {
+ Self::Guest(compression) => compression,
+ Self::AdapterIdentity(_) => Compression::None,
+ }
+ }
+}
+
+fn finalize_delivery_output(
+ response: &mut Response,
+ output: Option,
+) -> Result<(), Report> {
+ if let Some(OutputCompression::AdapterIdentity(support)) = output {
+ // The final identity bytes or identity-producing stream are installed.
+ set_response_compression(response, Compression::None);
+ support
+ .mark_identity_response(response)
+ .change_context_lazy(|| TrustedServerError::Proxy {
+ message: "failed to mark identity delivery output".to_string(),
+ })?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
fn response_compression(response: &Response) -> Compression {
response
.headers()
@@ -831,11 +867,7 @@ async fn process_response_streaming_async(
let input_compression = Compression::from_content_encoding(¶ms.content_encoding);
// A template-cache response is always identity bytes. Decode during the transform instead of
// recompressing and immediately decoding the entire buffered result afterwards.
- let output_compression = if params.template_cache_key.is_some() {
- Compression::None
- } else {
- input_compression
- };
+ let output_compression = params.transform_compression();
// This path has no seam controller, so it must never be reached with a
// pending auction; `deferred_inline_seam_token` returns `None` for it.
let (mut processor, inline_seam_token) =
@@ -1762,6 +1794,8 @@ pub struct OwnedProcessResponseParams {
/// Origin policy headers to store with the template and replay on a hit.
pub(crate) policy_headers: Vec<(String, String)>,
pub(crate) content_encoding: String,
+ /// Delivery override independent of the origin decoder.
+ pub(crate) output_compression: Option,
pub(crate) origin_host: String,
pub(crate) origin_url: String,
pub(crate) request_host: String,
@@ -1799,6 +1833,21 @@ pub struct OwnedProcessResponseParams {
}
impl OwnedProcessResponseParams {
+ fn transform_compression(&self) -> Compression {
+ if self.template_cache_key.is_some() {
+ Compression::None
+ } else {
+ self.delivery_compression()
+ }
+ }
+
+ fn delivery_compression(&self) -> Compression {
+ self.output_compression.map_or_else(
+ || Compression::from_content_encoding(&self.content_encoding),
+ OutputCompression::compression,
+ )
+ }
+
fn is_range_associated(&self) -> bool {
self.range_associated || self.response_status == StatusCode::PARTIAL_CONTENT
}
@@ -1929,9 +1978,8 @@ pub async fn buffer_publisher_response_async(
services,
)
.await?;
- // Authorized template cache transforms are emitted as identity by
- // `process_response_streaming_async`; inline transforms retain the origin
- // coding. This avoids recompressing and immediately decoding a full document.
+ // Authorized template transforms stay identity before storage. Other
+ // transforms use the independently selected delivery encoding.
let bytes = output.into_inner();
// The origin readthrough cache holds raw origin bytes; the template cache
// stores processed reader-neutral HTML. Caching the final per-user
@@ -2033,7 +2081,7 @@ pub async fn buffer_publisher_response_async(
set_assembly_response_state(&mut response, state);
}
let bytes = if was_authorized {
- encode_complete_body(bytes, response_compression(&response))?
+ encode_complete_body(bytes, params.delivery_compression())?
} else {
bytes
};
@@ -2042,6 +2090,7 @@ pub async fn buffer_publisher_response_async(
http::HeaderValue::from(bytes.len() as u64),
);
*response.body_mut() = EdgeBody::from(bytes);
+ finalize_delivery_output(&mut response, params.output_compression)?;
Ok(response)
}
PublisherResponse::AssembleTemplate {
@@ -2082,12 +2131,13 @@ pub async fn buffer_publisher_response_async(
assembled.extend_from_slice(head);
assembled.extend_from_slice(seam.as_bytes());
assembled.extend_from_slice(tail);
- let assembled = encode_complete_body(assembled, response_compression(&response))?;
+ let assembled = encode_complete_body(assembled, params.delivery_compression())?;
response.headers_mut().insert(
http::header::CONTENT_LENGTH,
http::HeaderValue::from(assembled.len() as u64),
);
*response.body_mut() = EdgeBody::from(assembled);
+ finalize_delivery_output(&mut response, params.output_compression)?;
Ok(response)
}
PublisherResponse::PassThrough { mut response, body } => {
@@ -2236,6 +2286,7 @@ fn build_template_assembly_params(
ad_bids_state: AdBidsState,
) -> OwnedProcessResponseParams {
OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -2568,7 +2619,8 @@ pub async fn publisher_response_into_streaming_response(
let services = services.clone();
let settings = Arc::clone(&settings);
let orchestrator = Arc::clone(&orchestrator);
- let compression = response_compression(&response);
+ let output_compression = params.output_compression;
+ let compression = params.delivery_compression();
// Arm the drop warning before constructing the lazy body. A reader can
// disconnect after receiving the cached article prefix but before the seam
// is polled, just like on the ordinary streaming path.
@@ -2638,6 +2690,7 @@ pub async fn publisher_response_into_streaming_response(
}
};
*response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream);
+ finalize_delivery_output(&mut response, output_compression)?;
Ok(response)
}
PublisherResponse::PassThrough { mut response, body } => {
@@ -2687,6 +2740,7 @@ pub async fn publisher_response_into_streaming_response(
}
response.headers_mut().remove(header::CONTENT_LENGTH);
let mut params = *params;
+ let output_compression = params.output_compression;
if is_html_content_type(¶ms.content_type)
&& integration_registry.has_async_html_finish()
{
@@ -2731,6 +2785,7 @@ pub async fn publisher_response_into_streaming_response(
}
};
*response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream);
+ finalize_delivery_output(&mut response, output_compression)?;
return Ok(response);
}
@@ -2767,7 +2822,7 @@ pub async fn publisher_response_into_streaming_response(
let compression = Compression::from_content_encoding(¶ms.content_encoding);
let max_body_bytes = settings.publisher.max_buffered_body_bytes;
let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes);
- let mut encoder = BodyStreamEncoder::new(compression);
+ let mut encoder = BodyStreamEncoder::new(params.transform_compression());
let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE)
.with_max_bytes(max_body_bytes);
@@ -2905,6 +2960,7 @@ pub async fn publisher_response_into_streaming_response(
}
};
*response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream);
+ finalize_delivery_output(&mut response, output_compression)?;
Ok(response)
}
}
@@ -3120,12 +3176,7 @@ pub fn stream_publisher_body(
shared_template_authorized: params.template_cache_key.is_some(),
csp_nonce_observed: params.csp_nonce_observed.as_ref(),
};
- let input_compression = Compression::from_content_encoding(¶ms.content_encoding);
- let output_compression = if params.template_cache_key.is_some() {
- Compression::None
- } else {
- input_compression
- };
+ let output_compression = params.transform_compression();
process_response_streaming(body, output, &borrowed, output_compression)
}
@@ -3374,11 +3425,7 @@ pub async fn stream_publisher_body_async(
};
let input_compression = Compression::from_content_encoding(¶ms.content_encoding);
- let output_compression = if params.template_cache_key.is_some() {
- Compression::None
- } else {
- input_compression
- };
+ let output_compression = params.transform_compression();
stream_html_with_auction_hold(
body,
output,
@@ -4900,6 +4947,7 @@ pub async fn handle_publisher_request(
mut req: Request,
edge_header: EdgeCacheHeader,
) -> Result> {
+ let delivery_support = DeliveryCompressionSupport::from_request(&req);
auction
.integration_registry
.ensure_async_html_finish_allowed(settings)?;
@@ -5150,6 +5198,12 @@ pub async fn handle_publisher_request(
|| datadome_suppression_requires_origin;
let reader_compression = negotiate_reader_compression(req.headers());
let reader_supports_assembly = reader_compression.is_ok();
+ let delivery_offload_eligible = delivery_support == DeliveryCompressionSupport::GzipBrotli
+ && should_run_ad_stack
+ && matches!(
+ reader_compression,
+ Ok(Compression::None | Compression::Gzip | Compression::Brotli)
+ );
// A failed negotiation bypasses template cache below, so this value is used only on an
// admitted path. Keeping an identity fallback avoids making that relationship
// a panic-prone invariant in the public request handler.
@@ -5636,6 +5690,11 @@ pub async fn handle_publisher_request(
price_granularity,
ad_bids_state.clone(),
);
+ params.output_compression = Some(if delivery_offload_eligible {
+ OutputCompression::AdapterIdentity(delivery_support)
+ } else {
+ OutputCompression::Guest(reader_compression)
+ });
params.seam_ad_slots = seam_ad_slots.clone();
params.dispatched_auction = dispatched_auction.take();
params.auction_observation = auction_observation.take();
@@ -5972,6 +6031,12 @@ pub async fn handle_publisher_request(
if template_cache_key.is_some() {
set_response_compression(&mut response, reader_compression);
}
+ let offload_delivery = delivery_offload_eligible
+ && route == ResponseRoute::Stream
+ && status.is_success()
+ && !response_is_range_associated(&response)
+ && response_carries_body(&request_method, status)
+ && is_html_content_type(&content_type);
match route {
ResponseRoute::PassThrough => {
@@ -6079,9 +6144,16 @@ pub async fn handle_publisher_request(
// present on the live path so no future caller has to remember to
// supply it — the handlers themselves stay gated on authorization.
csp_nonce_observed: Some(Arc::new(AtomicBool::new(false))),
- template_cache_key,
seam_ad_slots,
policy_headers,
+ output_compression: if offload_delivery {
+ Some(OutputCompression::AdapterIdentity(delivery_support))
+ } else if template_cache_key.is_some() {
+ Some(OutputCompression::Guest(reader_compression))
+ } else {
+ None
+ },
+ template_cache_key,
content_encoding,
origin_host,
origin_url: settings.publisher.origin_url.clone(),
@@ -9929,6 +10001,7 @@ mod tests {
content_encoding: &str,
) -> OwnedProcessResponseParams {
OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -11531,6 +11604,11 @@ mod tests {
//! the cache without touching the origin. Everything else in this file tests a
//! link; this tests that they connect.
+ mod delivery_compression_tests {
+ use super::*;
+ include!("publisher/delivery_compression_tests.rs");
+ }
+
use super::*;
use crate::platform::ClientInfo;
use crate::platform::test_support::{
@@ -23048,6 +23126,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let body = EdgeBody::from(compressed);
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23110,6 +23189,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
.expect("should create integration registry");
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23161,6 +23241,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
)
.expect("should create integration registry");
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23290,6 +23371,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let orchestrator = AuctionOrchestrator::new(settings.auction.clone());
let services = noop_services();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23357,6 +23439,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let orchestrator = AuctionOrchestrator::new(settings.auction.clone());
let services = noop_services();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23427,6 +23510,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let orchestrator = AuctionOrchestrator::new(settings.auction.clone());
let services = noop_services();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23497,6 +23581,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let orchestrator = AuctionOrchestrator::new(settings.auction.clone());
let services = noop_services();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23567,6 +23652,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let orchestrator = AuctionOrchestrator::new(settings.auction.clone());
let services = noop_services();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23619,6 +23705,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
fn non_html_stream_params(content_encoding: &str) -> OwnedProcessResponseParams {
OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23839,6 +23926,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let services = noop_services();
let state = AdBidsState::default();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23917,6 +24005,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let services = noop_services();
let state = AdBidsState::default();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -23997,6 +24086,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let orchestrator = AuctionOrchestrator::new(settings.auction.clone());
let services = noop_services();
let mut params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -24071,6 +24161,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
.body(EdgeBody::empty())
.expect("should build response");
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -26035,6 +26126,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
dispatched_auction: Option,
) -> OwnedProcessResponseParams {
OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -26751,6 +26843,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let ec_context =
EcContext::new_for_test(None, crate::consent::types::ConsentContext::default());
OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -26949,6 +27042,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
.map(bytes::Bytes::copy_from_slice)
.collect();
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -27033,6 +27127,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
r#""#;
let state = AdBidsState::with_script(bids_script);
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -27100,6 +27195,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
// Claim gzip encoding but feed non-gzip bytes. The GzDecoder will
// error as soon as it tries to read the gzip header.
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -27221,6 +27317,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
let body = EdgeBody::from(html.to_vec());
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
@@ -27291,6 +27388,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) {
// Small, single-fragment RSC script — placeholder path (not fallback).
let html = br#""#;
let params = OwnedProcessResponseParams {
+ output_compression: None,
response_status: StatusCode::OK,
range_associated: false,
csp_nonce_observed: None,
diff --git a/crates/trusted-server-core/src/publisher/delivery_compression_tests.rs b/crates/trusted-server-core/src/publisher/delivery_compression_tests.rs
new file mode 100644
index 0000000000..fe97e2bf24
--- /dev/null
+++ b/crates/trusted-server-core/src/publisher/delivery_compression_tests.rs
@@ -0,0 +1,616 @@
+#[derive(Clone, Copy)]
+enum Finish {
+ Inline,
+ Replace,
+ Unchanged,
+}
+
+async fn run_delivery(
+ settings: &Arc,
+ services: &RuntimeServices,
+ mut request: Request,
+ finalizer: Finalizer,
+ capability: DeliveryCompressionSupport,
+ finish: Finish,
+ stream_origin: bool,
+) -> Response {
+ let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone());
+ orchestrator.register_provider(Arc::new(WinningBidProvider {
+ price: STUB_BID_CPM,
+ }));
+ let orchestrator = Arc::new(orchestrator);
+ let registry = match finish {
+ Finish::Inline => {
+ IntegrationRegistry::new(settings).expect("should create integration registry")
+ }
+ Finish::Replace => IntegrationRegistry::from_html_stream_processors(vec![Arc::new(
+ super::super::TerminalTestFactory,
+ )]),
+ Finish::Unchanged => IntegrationRegistry::from_html_stream_processors(vec![Arc::new(
+ super::super::CountingTerminalFactory(Arc::new(AtomicUsize::new(0))),
+ )]),
+ };
+ let consent = crate::consent::ConsentContext {
+ jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated,
+ ..Default::default()
+ };
+ let mut ec_context = EcContext::new_for_test(None, consent);
+ if capability != DeliveryCompressionSupport::Unavailable {
+ request.extensions_mut().insert(capability);
+ }
+ let method = request.method().clone();
+ let mut publisher = handle_publisher_request(
+ settings,
+ services,
+ None,
+ &mut ec_context,
+ AuctionDispatch {
+ integration_registry: ®istry,
+ orchestrator: &orchestrator,
+ slots: &[article_slot()],
+ registry: None,
+ },
+ request,
+ EdgeCacheHeader::SurrogateControl,
+ )
+ .await
+ .expect("should proxy delivery request");
+ let response = match &publisher {
+ PublisherResponse::Buffered(response)
+ | PublisherResponse::Stream { response, .. }
+ | PublisherResponse::AssembleTemplate { response, .. }
+ | PublisherResponse::PassThrough { response, .. } => response,
+ };
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_none(),
+ "should not mark unfinished publisher output"
+ );
+ // Reuse the HTTP fixture's response headers, then exercise both live input
+ // forms. The fixture's streaming-response helper cannot queue headers.
+ if stream_origin && let PublisherResponse::Stream { body, .. } = &mut publisher {
+ let bytes = std::mem::replace(body, EdgeBody::empty())
+ .into_bytes()
+ .expect("should materialize fixture body");
+ let chunks = bytes
+ .chunks(11)
+ .map(bytes::Bytes::copy_from_slice)
+ .collect::>();
+ *body = EdgeBody::stream(futures::stream::iter(chunks));
+ }
+ match finalizer {
+ Finalizer::Streaming => publisher_response_into_streaming_response(
+ publisher,
+ &method,
+ Arc::clone(settings),
+ ®istry,
+ orchestrator,
+ services.clone(),
+ )
+ .await
+ .expect("should finalize lazy delivery"),
+ Finalizer::Buffered => buffer_publisher_response_async(
+ publisher,
+ &method,
+ settings,
+ ®istry,
+ &orchestrator,
+ services,
+ )
+ .await
+ .expect("should finalize buffered delivery"),
+ }
+}
+
+fn reader(encoding: &str) -> Request {
+ let mut request = navigation_request();
+ request.headers_mut().insert(
+ header::ACCEPT_ENCODING,
+ HeaderValue::from_str(encoding).expect("should build reader encoding"),
+ );
+ request
+}
+
+fn assert_offloaded(response: &Response) {
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_some(),
+ "should attach identity delivery intent"
+ );
+ assert!(
+ !response.headers().contains_key("x-compress-hint"),
+ "should leave Fastly hints to the adapter"
+ );
+ assert!(
+ !response.headers().contains_key(header::CONTENT_ENCODING),
+ "should emit identity"
+ );
+ assert!(
+ response
+ .headers()
+ .get_all(header::VARY)
+ .iter()
+ .any(|v| v.to_str().is_ok_and(|v| v
+ .split(',')
+ .any(|v| v.trim() == "*" || v.trim().eq_ignore_ascii_case("accept-encoding")))),
+ "should retain encoding variation"
+ );
+ assert!(
+ response
+ .headers()
+ .get(header::CACHE_CONTROL)
+ .and_then(|v| v.to_str().ok())
+ .is_some_and(|v| v.contains("private")),
+ "should remain private"
+ );
+}
+
+const ARTICLE: &[u8] = b"Example article";
+
+fn queue_encoded(stub: &StubHttpClient, compression: Compression) {
+ let encoding = match compression {
+ Compression::None => "identity",
+ Compression::Gzip => "gzip",
+ Compression::Deflate => "deflate",
+ Compression::Brotli => "br",
+ };
+ let origin = encode_complete_body(ARTICLE.to_vec(), compression).expect("should encode origin");
+ let length = origin.len().to_string();
+ stub.push_response_with_headers(
+ 200,
+ origin,
+ vec![
+ ("content-type", "text/html"),
+ ("content-encoding", encoding),
+ ("content-length", &length),
+ ("cache-control", "public, max-age=300"),
+ ],
+ );
+}
+
+#[tokio::test]
+async fn delivery_offload_decodes_origin_without_guest_recompression() {
+ for finalizer in [Finalizer::Streaming, Finalizer::Buffered] {
+ for stream_origin in [false, true] {
+ for finish in [Finish::Inline, Finish::Replace, Finish::Unchanged] {
+ for bidding in [false, true] {
+ let settings =
+ Arc::new(crate::publisher::allow_eof_test_settings(if bidding {
+ settings_with_bidder("inline")
+ } else {
+ settings_with_mode("inline")
+ }));
+ let mut expected = None;
+ for compression in [
+ Compression::None,
+ Compression::Gzip,
+ Compression::Deflate,
+ Compression::Brotli,
+ ] {
+ let stub = Arc::new(StubHttpClient::new());
+ let services =
+ services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default()));
+ if bidding {
+ queue_bid_response(&stub);
+ }
+ queue_encoded(&stub, compression);
+ let response = run_delivery(
+ &settings,
+ &services,
+ reader("br, gzip, deflate"),
+ finalizer,
+ DeliveryCompressionSupport::GzipBrotli,
+ finish,
+ stream_origin,
+ )
+ .await;
+ assert_offloaded(&response);
+ assert_eq!(
+ response.body().is_stream(),
+ matches!(finalizer, Finalizer::Streaming),
+ "should preserve finalizer laziness"
+ );
+ let length = response.headers().get(header::CONTENT_LENGTH).map(|v| {
+ v.to_str()
+ .expect("should read length")
+ .parse::()
+ .expect("should parse length")
+ });
+ let body = body_of(response).await;
+ if let Some(length) = length {
+ assert_eq!(length, body.len(), "should recompute identity length");
+ }
+ let html = std::str::from_utf8(&body).expect("should emit identity HTML");
+ assert!(html.contains("Example article"), "should preserve article");
+ assert_eq!(
+ html.ends_with(""),
+ matches!(finish, Finish::Replace),
+ "should retain EOF replacement or fallback"
+ );
+ if bidding {
+ assert!(html.contains("3.50"), "should retain auction tail");
+ }
+ if let Some(expected) = &expected {
+ assert_eq!(
+ &body, expected,
+ "should produce identical HTML for every origin coding"
+ );
+ } else {
+ expected = Some(body);
+ }
+ let headers = stub.recorded_request_headers();
+ assert_eq!(
+ headers
+ .last()
+ .expect("should record origin")
+ .iter()
+ .find(|(name, _)| name.eq_ignore_ascii_case("accept-encoding"))
+ .map(|(_, value)| value.as_str()),
+ Some("gzip, deflate, br"),
+ "should preserve existing origin negotiation"
+ );
+ }
+ }
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn delivery_offload_template_miss_hit_fallback_and_ineligible_reader() {
+ for finalizer in [Finalizer::Streaming, Finalizer::Buffered] {
+ for fail_assembly in [false, true] {
+ let settings = Arc::new(settings_with_mode("esi"));
+ let stub = Arc::new(StubHttpClient::new());
+ let cache = Arc::new(MemoryTemplateCache::default());
+ let assembler = Arc::new(RecordingTemplateAssembler::default());
+ assembler.fail.store(fail_assembly, Ordering::Relaxed);
+ assembler.require_store_before_call(Arc::clone(&cache.insert_completed));
+ let services =
+ services_with_assembler(Arc::clone(&stub), Arc::clone(&cache), assembler);
+ queue_encoded(&stub, Compression::Gzip);
+ for state in ["miss-stored", "hit"] {
+ let response = run_delivery(
+ &settings,
+ &services,
+ reader("br, gzip"),
+ finalizer,
+ DeliveryCompressionSupport::GzipBrotli,
+ Finish::Inline,
+ false,
+ )
+ .await;
+ assert_offloaded(&response);
+ assert_eq!(
+ response
+ .headers()
+ .get(HEADER_X_TS_TEMPLATE_CACHE)
+ .and_then(|v| v.to_str().ok()),
+ Some(state),
+ "should retain template state"
+ );
+ let assembly = if state == "hit" {
+ "byte-seam"
+ } else if fail_assembly {
+ "byte-seam-fallback"
+ } else {
+ "esi-parser"
+ };
+ assert_eq!(
+ response
+ .headers()
+ .get(HEADER_X_TS_ASSEMBLY)
+ .and_then(|v| v.to_str().ok()),
+ Some(assembly),
+ "should retain assembly and fallback path"
+ );
+ let html = String::from_utf8(body_of(response).await)
+ .expect("should deliver assembled identity");
+ assert!(
+ html.contains("Example article"),
+ "should retain template content"
+ );
+ assert!(
+ !html.contains(AD_ASSEMBLY_SEAM),
+ "should assemble before delivery"
+ );
+ let entries = cache
+ .entries
+ .lock()
+ .expect("should inspect stored template");
+ let entry = entries.values().next().expect("should store template");
+ assert_eq!(
+ entry.metadata.content_encoding, "identity",
+ "should store neutral identity"
+ );
+ assert!(
+ std::str::from_utf8(&entry.body)
+ .expect("should store HTML")
+ .contains(AD_ASSEMBLY_SEAM),
+ "should store before assembly"
+ );
+ }
+ let mut request = reader("gzip");
+ request.headers_mut().insert(
+ "sec-purpose",
+ HeaderValue::from_static("prefetch;prerender"),
+ );
+ let response = run_delivery(
+ &settings,
+ &services,
+ request,
+ finalizer,
+ DeliveryCompressionSupport::GzipBrotli,
+ Finish::Inline,
+ false,
+ )
+ .await;
+ assert_eq!(
+ response
+ .headers()
+ .get(HEADER_X_TS_TEMPLATE_CACHE)
+ .and_then(|v| v.to_str().ok()),
+ Some("hit"),
+ "should reuse template for ineligible reader"
+ );
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_none(),
+ "should use this reader's eligibility"
+ );
+ assert_eq!(
+ response_compression(&response),
+ Compression::Gzip,
+ "should preserve reader encoding"
+ );
+ let html = gzip_decode(&body_of(response).await);
+ assert!(
+ std::str::from_utf8(&html)
+ .expect("should decode template")
+ .contains("Example article"),
+ "should assemble ineligible reader"
+ );
+ assert_eq!(
+ stub.recorded_request_uris().len(),
+ 1,
+ "should fetch only on miss"
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn delivery_offload_negotiation_and_capability_are_independent() {
+ for capability in [
+ DeliveryCompressionSupport::Unavailable,
+ DeliveryCompressionSupport::GzipBrotli,
+ ] {
+ for (encoding, supported) in [
+ (None, true),
+ (Some(""), true),
+ (Some("identity"), true),
+ (Some("gzip, identity;q=0"), true),
+ (Some("br, identity;q=0"), true),
+ (Some("deflate, identity;q=0"), false),
+ (Some("*;q=0"), false),
+ (Some("zstd, identity;q=0"), false),
+ (Some("gzip;q=broken"), false),
+ ] {
+ let settings = Arc::new(settings_with_mode("inline"));
+ let stub = Arc::new(StubHttpClient::new());
+ let services = services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default()));
+ queue_encoded(&stub, Compression::Deflate);
+ let request = encoding.map_or_else(navigation_request, reader);
+ let response = run_delivery(
+ &settings,
+ &services,
+ request,
+ Finalizer::Buffered,
+ capability,
+ Finish::Inline,
+ false,
+ )
+ .await;
+ let offloaded = capability == DeliveryCompressionSupport::GzipBrotli && supported;
+ assert_eq!(
+ response
+ .extensions()
+ .get::()
+ .is_some(),
+ offloaded,
+ "should respect capability and negotiation {encoding:?}"
+ );
+ assert_eq!(
+ response_compression(&response),
+ if offloaded {
+ Compression::None
+ } else {
+ Compression::Deflate
+ },
+ "should preserve existing fallback encoding"
+ );
+ let body = body_of(response).await;
+ let body = if offloaded {
+ body
+ } else {
+ let mut decoded = Vec::new();
+ flate2::read::ZlibDecoder::new(&body[..])
+ .read_to_end(&mut decoded)
+ .expect("should decode deflate fallback");
+ decoded
+ };
+ assert!(
+ std::str::from_utf8(&body)
+ .expect("should emit HTML")
+ .contains("Example article"),
+ "should retain body"
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn delivery_offload_excludes_unmodified_and_non_ssat_responses() {
+ for (status, content_type, encoding, eligible, method, range) in [
+ (200, "text/html", "gzip", false, Method::GET, false),
+ (200, "text/css", "gzip", true, Method::GET, false),
+ (200, "text/html", "zstd", true, Method::GET, false),
+ (302, "text/html", "gzip", true, Method::GET, false),
+ (404, "text/html", "gzip", true, Method::GET, false),
+ (500, "text/html", "gzip", true, Method::GET, false),
+ (200, "text/html", "gzip", true, Method::HEAD, false),
+ (204, "text/html", "gzip", true, Method::GET, false),
+ (205, "text/html", "gzip", true, Method::GET, false),
+ (304, "text/html", "gzip", true, Method::GET, false),
+ (206, "text/html", "gzip", true, Method::GET, true),
+ (200, "text/html", "gzip", true, Method::GET, true),
+ ] {
+ let mut settings = settings_with_mode("inline");
+ settings.auction.enabled = eligible;
+ let settings = Arc::new(settings);
+ let stub = Arc::new(StubHttpClient::new());
+ let services = services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default()));
+ let bytes = if encoding == "zstd" {
+ ARTICLE.to_vec()
+ } else {
+ gzip_encode(ARTICLE)
+ };
+ let mut headers = vec![
+ ("content-type", content_type),
+ ("content-encoding", encoding),
+ ];
+ if range {
+ headers.push(("content-range", "bytes 0-9/100"));
+ }
+ stub.push_response_with_headers(status, bytes, headers);
+ let mut request = reader("br, gzip");
+ *request.method_mut() = method.clone();
+ let response = run_delivery(
+ &settings,
+ &services,
+ request,
+ Finalizer::Streaming,
+ DeliveryCompressionSupport::GzipBrotli,
+ Finish::Inline,
+ false,
+ )
+ .await;
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_none(),
+ "should not offload {method} {status} {content_type} {encoding}"
+ );
+ let carries_body = response_carries_body(&method, response.status());
+ let compression = response_compression(&response);
+ let body = body_of(response).await;
+ assert_eq!(
+ !body.is_empty(),
+ carries_body,
+ "should preserve bodyless semantics"
+ );
+ if carries_body && compression == Compression::Gzip {
+ assert!(
+ !gzip_decode(&body).is_empty(),
+ "should retain in-guest gzip"
+ );
+ }
+ }
+}
+
+#[test]
+fn delivery_offload_emits_identity_prefix_before_origin_eof_and_auction() {
+ let mut params = super::super::html_stream_params(
+ "gzip",
+ Some(DispatchedAuction::empty_for_test(
+ test_auction_request(),
+ 500,
+ )),
+ );
+ params.output_compression = Some(OutputCompression::AdapterIdentity(
+ DeliveryCompressionSupport::GzipBrotli,
+ ));
+ let body = super::super::streaming_finalize_response(
+ params,
+ super::super::origin_chunk_then_pending(bytes::Bytes::from(gzip_encode(ARTICLE))),
+ );
+ let first = super::super::first_lazy_body_chunk(body);
+ let html = std::str::from_utf8(&first).expect("should emit identity prefix");
+ assert!(
+ html.contains("Example article"),
+ "should emit article before origin EOF"
+ );
+ assert!(
+ !html.contains("var b=JSON.parse("),
+ "should not await auction before prefix"
+ );
+}
+
+#[tokio::test]
+async fn delivery_offload_preserves_vary_and_stronger_privacy() {
+ for vary in [
+ vec!["Origin", "Accept-Language"],
+ vec!["Origin, aCcEpT-EnCoDiNg", "Accept-Language"],
+ vec!["*", "Origin"],
+ ] {
+ let settings = Arc::new(settings_with_mode("inline"));
+ let stub = Arc::new(StubHttpClient::new());
+ let services = services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default()));
+ let mut headers = vec![
+ ("content-type", "text/html"),
+ ("cache-control", "private, no-store"),
+ ("content-length", "9999"),
+ ];
+ for value in &vary {
+ headers.push(("vary", *value));
+ }
+ stub.push_response_with_headers(200, ARTICLE.to_vec(), headers);
+ let response = run_delivery(
+ &settings,
+ &services,
+ reader("gzip"),
+ Finalizer::Streaming,
+ DeliveryCompressionSupport::GzipBrotli,
+ Finish::Inline,
+ false,
+ )
+ .await;
+ assert_offloaded(&response);
+ let actual = response
+ .headers()
+ .get_all(header::VARY)
+ .iter()
+ .map(|v| v.to_str().expect("should read vary"))
+ .collect::>();
+ assert_eq!(
+ &actual[..vary.len()],
+ &vary,
+ "should preserve all Vary fields"
+ );
+ assert_eq!(
+ actual.len(),
+ vary.len() + usize::from(vary.len() == 2 && vary[0] == "Origin"),
+ "should not duplicate wildcard or existing variation"
+ );
+ assert!(
+ response.headers()[header::CACHE_CONTROL]
+ .to_str()
+ .expect("should read privacy")
+ .contains("no-store"),
+ "should preserve stronger privacy"
+ );
+ assert!(
+ std::str::from_utf8(&body_of(response).await)
+ .expect("should keep identity")
+ .contains("Example article"),
+ "should keep final body and headers aligned"
+ );
+ }
+}
diff --git a/crates/trusted-server-core/src/publisher/html_finalization.rs b/crates/trusted-server-core/src/publisher/html_finalization.rs
index 49ad33a5f8..8a831f1c86 100644
--- a/crates/trusted-server-core/src/publisher/html_finalization.rs
+++ b/crates/trusted-server-core/src/publisher/html_finalization.rs
@@ -137,7 +137,7 @@ impl HtmlEofDriver {
Ok(Self {
response_status: params.response_status,
input,
- encoder: BodyStreamEncoder::new(compression),
+ encoder: BodyStreamEncoder::new(params.transform_compression()),
processor,
replay: VecDeque::new(),
hold,
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
index 8413e77d09..26c4082725 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -538,6 +538,28 @@ a zero-byte cap fails every non-empty publisher response.
TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216
```
+### Fastly SSAT delivery compression
+
+Fastly automatically offloads final compression for eligible, successful SSAT
+HTML responses. Trusted Server keeps its existing origin encoding negotiation,
+decodes supported origin encodings, and emits processed identity HTML with
+`X-Compress-Hint: on`. Fastly negotiates gzip or Brotli delivery using the reader's
+original `Accept-Encoding`. No publisher setting is required. Adapter capability
+and final delivery headers come from EdgeZero. Trusted Server attaches the typed
+delivery intent only after installing identity bytes or the final lazy identity
+stream; its custom sender uses EdgeZero's non-collecting header finalizer.
+
+This applies to eligible inline responses and assembled template misses and
+hits. Requests for which reader negotiation selects deflate or fails retain the
+existing in-guest encoding path. Non-SSAT, non-HTML, unsupported-encoding, error,
+redirect, and bodyless responses keep their existing behavior. Axum, Cloudflare,
+and Spin also retain their current compression behavior.
+
+Offload does not change template storage, cache admission, consent gates, or
+terminal private/no-store policies. It does not request identity from origin or
+change body-size limits. Local tests verify guest output; actual Fastly delivery
+encoding and performance require staging validation.
+
## Trusted Client IP Configuration
Use this optional section when a trusted CDN service forwards requests to the
diff --git a/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md
new file mode 100644
index 0000000000..be11659a4b
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md
@@ -0,0 +1,132 @@
+# Consume EdgeZero delivery compression
+
+## Goal
+
+Keep Trusted Server's SSAT output-only compression behavior while replacing its
+local platform mechanism with [EdgeZero PR #452](https://github.com/IABTechLab/edgezero/pull/452).
+Both PRs remain draft for joint review. The
+[SSAT design](../specs/2026-10-29-858-ssat-delivery-compression-offload-design.md)
+owns application policy; EdgeZero owns capability, checked identity intent, and
+Fastly's final delivery headers.
+
+## Preparation
+
+- [x] Publish EdgeZero's reviewed spec, implementation plan, implementation, and
+ passing native/Viceroy/workspace checks in draft PR #452 before consumer edits.
+- [x] Preserve old branch as `backup/issue-858-before-edgezero-20261102`.
+- [x] Rebase one feature-only tree delta onto main
+ `51730bdaf841e4d7bb96c2258e5e98169bdff9a7`. Do not replay inherited parser-stack
+ or merge commits. The clean rebase preserves current trace, ingress, and
+ absolute-deadline code.
+
+## Implementation
+
+1. Pin all six root workspace EdgeZero dependencies to
+ `157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec`. Regenerate only the necessary lock
+ entries and verify all eight EdgeZero packages have one exact git source.
+ Do not update Fastly SDK pins or unrelated registry versions.
+2. Read support from the incoming EdgeZero request before encoder selection.
+ Remove `DeliveryCompression`, `PublisherAdapterOptions`, their per-adapter
+ literals, and the local generic response marker/header helper. Restore the
+ existing cache-header argument where it is the only remaining publisher option.
+3. Carry one explicit local pending output plan through every finalizer. It must
+ distinguish preserved input coding, explicit guest output, and adapter identity
+ output without a second synchronized offload boolean. Keep origin decoding and
+ template-store identity transforms separate from final delivery encoding.
+4. Make buffered, lazy, template-miss/hit/fallback, and ordered-EOF outputs use that
+ plan. Install identity bytes or the final identity-producing stream first,
+ normalize its representation metadata, then call EdgeZero's checked marking
+ method. Never put the final intent on raw origin or pending intermediate output.
+5. In the real custom Fastly sender, call the shared EdgeZero finalizer after late
+ header effects and existing privacy guards, before response parts are consumed.
+ Preserve its streaming pump, post-send work, error attribution, and all current
+ trace/deadline safeguards. Do not switch to the collecting standard converter.
+6. Adapt regressions to inject typed request support and assert no premature
+ response intent. Core tests assert identity output/intent; Fastly terminal tests
+ assert the hint and framing after the framework finalizer. Retain unavailable,
+ deflate/failed-reader, non-SSAT/non-HTML, unsupported-origin, bodyless/range/error,
+ privacy, prefix-before-auction, and codec-equivalence coverage.
+
+## Verification and publication
+
+- Run the focused delivery, publisher, ordered-EOF and Fastly terminal tests through
+ the pinned dependency, then neighboring current-main trace/deadline/ingress tests.
+- Run current repository CI gates, including adapter tests/lints, parity, digest,
+ clock, formatting, JS, docs, script/pin/host checks and the required local EOF and
+ deadline runtime fixtures. Record unavailable tooling or baseline failures rather
+ than claiming those checks pass.
+- Inspect the final diff and dependency graph independently. No application-local
+ Fastly delivery helper or branch capability/options wrapper should remain.
+ Pre-existing static-asset hint behavior is outside this change.
+- Commit the stable consumer state, update the original branch with an explicit
+ lease for its verified old remote SHA, and update draft PR #884. Retain the backup.
+- Cross-link both draft PRs and both issues, record the exact framework dependency,
+ and keep staging negotiation/performance criteria open.
+
+Replace the temporary framework revision pin with a merged EdgeZero release before
+marking the consumer merge-ready. No production deployment or performance claim is
+part of this plan.
+
+## Implementation and verification record
+
+The consumer now uses the six exact dependency pins and eight matching lockfile
+sources. The lockfile changes no registry or SDK versions. The pending output plan
+replaces the local mechanism, and all four adapter `app.rs` files match the main
+baseline. Independent source review found no blockers and verified all ten runtime
+input hashes.
+
+The following records the 2026-11-02 integration checks for consumer `db27e738`
+and framework `63c7b63`, before the test-only review follow-up below. These checks
+passed with Rust 1.98.1, Node 24.20.0 and Viceroy 0.21.0:
+
+- All 32 Rust, adapter, lint, parity, digest, clock, host and documentation gates.
+ Native core passed 3,345 tests with two ignored. The normal Fastly alias passed
+ 244 adapter tests and 3,338 core tests, plus the remaining shared-crate and
+ documentation tests. The reusable-sandbox alias also passed.
+- Actual Fastly deadline cases, Cloudflare and Spin deadline runtime fixtures,
+ and actual Axum plus release-mode Fastly EOF controls, including auction and
+ prefix-before-completion behavior.
+- JavaScript tests passed 1,838 tests with one skipped; JS formatting/build and
+ repository script and Node-pin checks passed.
+- Documentation formatting and build passed. The final review-log update receives
+ a separate documentation check before publication.
+
+An isolated single-package Fastly test build failed to link
+`fastly_http_req::pending_req_header_insert` under Viceroy. Its cause remains
+unresolved; unchanged-main, dependency-pin-only and shared-helper-only probes did
+not reproduce it. The required `cargo test-fastly` and `cargo test-fastly-reuse`
+configurations, actual deadline fixture and release EOF controls all passed with
+the default runner. Final verification uses no trap override, SDK stub, pin change
+or runtime-source workaround. Earlier trap-mode diagnostics are not final proof.
+
+## Review follow-up, 2026-11-03
+
+Review finding R1 replaces the framework Vary test's copied production predicate
+with literal expected header fields. Framework commit `1c0f4750` changes
+only that test; this consumer refreshes all six declarations and eight lock
+sources without changing registry or SDK versions. The original review and
+integration evidence above remains associated with its original revisions.
+
+During the 2026-11-03 review, local SDK contract execution and both consumer
+Fastly alias configurations failed before tests on
+`fastly_http_req::pending_req_header_insert`. Same-tree hosted CI logs verified
+successful SDK, alias and release EOF execution in that hosted environment.
+The current local discrepancy remains unresolved. This follow-up includes no
+runtime workaround and makes no new local WASM-pass claim.
+
+## CI fixture follow-up, 2026-11-03
+
+Framework commit `157f091e` fixes the reusable HTTP smoke probe's
+abort ordering. Its controlled origin now waits for the client to observe body
+bytes before truncating. Immediate aborts remain available for pre-commit faults,
+and the strict status, exact partial-body and correlated-error checks are unchanged.
+No shipping SDK logic or runtime configuration changed.
+
+A new ordering regression failed before the fix. The fixed fixture passed two
+core and 27 harness tests, formatting, strict Clippy and the full Fastly smoke
+suite under pinned Viceroy 0.21.0. All three custom variants observed status 200
+and `partial` followed by the matching guest error. This consumer refreshes the
+same six declarations and eight lock sources to the published fix.
+
+No deployment ran. Real Fastly reader negotiation, progressive CDN delivery,
+service-chain behavior and vCPU/transfer measurements remain staging work.
diff --git a/docs/superpowers/specs/2026-10-29-858-ssat-delivery-compression-offload-design.md b/docs/superpowers/specs/2026-10-29-858-ssat-delivery-compression-offload-design.md
new file mode 100644
index 0000000000..28e434daba
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-29-858-ssat-delivery-compression-offload-design.md
@@ -0,0 +1,228 @@
+# Fastly SSAT delivery compression offload
+
+## Goal and scope
+
+Issue #858 and PR #884 will offload final SSAT HTML compression to Fastly on
+current `main`. Preserve origin encoding negotiation and the existing rewrite,
+auction, streaming, template-cache, and privacy behavior. Do not revive the
+closed parser-safe streaming branch.
+
+The July experiment measured approximately 44.6 ms versus 9.0 ms processing and
+47 ms versus 19 ms Fastly vCPU, with Brotli browser delivery. That experiment
+also requested identity from origin. These are historical measurements, not
+proof of the output-only design's current performance.
+
+## Decision
+
+Automatically use Fastly dynamic delivery compression for eligible SSAT HTML.
+There is no publisher opt-in or new configuration. Keep the existing restricted
+origin `Accept-Encoding` negotiation. Decode supported origin encodings, rewrite
+as today, and hand identity output to Fastly instead of recompressing in WASM.
+
+Alternatives considered:
+
+- Keep identity-origin fetching from the old PR. Rejected for this change because
+ it changes origin bandwidth and representation selection independently of
+ delivery offload.
+- Keep a disabled-by-default publisher flag. Rejected in favor of automatic
+ Fastly behavior, following the maintainer's request to remove the opt-in.
+- Apply offload to every rewritten response. Rejected because this issue is
+ limited to SSAT HTML and must not change assets, RSC, or ordinary proxying.
+
+```mermaid
+flowchart TD
+ A[Existing publisher origin negotiation or template lookup] --> B[Existing SSAT HTML eligibility]
+ B --> C{Fastly delivery capability?}
+ C -->|No| D[Existing delivery encoding]
+ C -->|Yes| E[Decode origin if needed and preserve HTML assembly]
+ E --> F[Install identity output and attach EdgeZero intent]
+ F --> G[EdgeZero Fastly finalizer restores delivery headers after late effects]
+ G --> H[Fastly negotiates delivery compression with reader]
+```
+
+## Adapter and processing boundaries
+
+Read `DeliveryCompressionSupport` from EdgeZero's typed request extension before
+selecting the output codec. Fastly's request converter advertises `GzipBrotli`;
+missing support defaults to unavailable on Axum, Cloudflare, and Spin. This is
+independent of cache headers and origin-streaming support. Remove the branch-local
+capability enum and publisher-options wrapper once no other option needs it.
+No Fastly SDK dependency enters core.
+
+### Framework dependency and pending output
+
+The generic capability, checked identity-response intent, and platform header
+finalizer live in [EdgeZero PR #452](https://github.com/IABTechLab/edgezero/pull/452).
+Pin all six workspace EdgeZero dependencies to exact commit
+`157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec` while the paired PRs are under review.
+All eight resolved EdgeZero packages must use that one source. Move to a merged
+EdgeZero release before Trusted Server becomes merge-ready; do not substitute a
+mutable branch or local path.
+
+Trusted Server owns a pending output plan through processing, template assembly,
+and ordered EOF completion. Preserve-input, explicit guest encoding, and adapter
+identity output are distinct choices. Template delivery must use this plan rather
+than infer its encoder from response headers. Input decoding remains independent.
+
+Only after installing completed identity bytes or the final lazy identity-producing
+stream may a publisher finalizer normalize its identity metadata and call
+`support.mark_identity_response`. Raw origin bodies and partially prepared
+`PublisherResponse` values must not carry the final intent. The shared API rejects
+unavailable support or non-identity encoding metadata without mutating the
+response; propagate such errors through the existing application error boundary.
+Keep all body consumption lazy where it is lazy today.
+
+Fastly's documented dynamic delivery codecs are gzip and Brotli, not deflate.
+Reuse reader negotiation to retain in-guest delivery when negotiation fails or
+selects deflate. This preserves deflate-only readers, including those refusing
+identity, and avoids expanding the existing malformed/unsupported-reader contract.
+Successful identity, gzip, and Brotli negotiation can use offload. Do not change
+origin negotiation in order to make a request eligible.
+
+Select output compression independently from input compression. The selected
+output must survive every affected finalizer, including lazy inline streaming,
+buffered HTML processing, template-cache miss assembly, warm template delivery,
+and fallback assembly. This includes the current ordered `HtmlEofDriver`, whose
+encoder must use the selected output for ordinary, replacement, fallback, and
+auction-tail bytes through both lazy and buffered entry points. Reuse existing
+decoders, encoders, and negotiation helpers. Do not refactor parser or EOF
+completion behavior.
+
+Apply offload only to supported, processable HTML using the existing server-side
+ad-stack eligibility. Do not expand consent gates or enable auctions for requests
+that currently skip them. Unsupported encodings bypass rewriting unchanged.
+Non-HTML, non-SSAT, redirects, error responses, and bodyless responses retain
+their existing contracts. The offload predicate must explicitly require a
+successful, body-bearing, supported HTML response and preserve existing range
+bypasses. The broader processable-response classifier is not this predicate;
+do not stop existing rewriting of redirects or errors just to exclude offload.
+
+Template hits use this reader's ad-stack eligibility and negotiation, not the
+request that populated the template. Ineligible readers can still assemble a
+template with their current encoding and terminal privacy. An auction's presence,
+outcome, or absence is not a substitute for the eligibility decision.
+
+For offloaded delivery:
+
+- Emit identity bytes from the guest, with no `Content-Encoding`.
+- Normalize identity metadata after installing the final body, then attach
+ EdgeZero's opaque intent. A buffered finalizer may calculate a correct identity
+ length, but must not reuse the encoded origin length.
+- The Fastly sender calls EdgeZero's `delivery_compression::finalize_response`
+ after late effects. It sets `X-Compress-Hint: on` and removes encoding/length
+ headers for the selected identity output.
+- Preserve existing `Vary` fields and ensure `Accept-Encoding` is covered;
+ `Vary: *` already covers it.
+- Let Fastly negotiate the final representation using the original reader
+ request. Do not overwrite the reader's encoding preferences. Verify readers
+ with absent encoding headers, identity-only preferences, and identity refusal
+ against the real platform before declaring delivery validated. Keep deflate
+ selection and failed negotiation on the existing in-guest path.
+
+Observe encoding, hint, framing, and privacy at the actual send boundary after
+late effects. Carry a pending application plan until output installation, then
+use the EdgeZero typed response intent, not an inbound or configurable header.
+Remove the local `DynamicDeliveryCompression` marker and `enforce_delivery_compression`
+helper. The existing custom Fastly sender must call the shared EdgeZero finalizer
+for both streamed and buffered sends alongside its terminal privacy handling.
+Do not use EdgeZero's standard collecting converter for this lazy path or broaden
+unrelated header policy. Core publisher tests assert intent and identity output;
+the adapter tests assert the final Fastly hint and framing.
+
+Delivery hints must not alter stored templates or origin cache identities.
+Template admission, key construction, origin readthrough, TTLs, and purge remain
+unchanged. Reader-specific HTML must remain private under the current terminal
+privacy rules, including existing stricter `no-store` paths.
+
+## Failure handling and compatibility
+
+Keep existing decode errors, truncation handling, byte limits, cancellation,
+auction abandonment, and unsupported-encoding fallbacks. Add no retry, new size
+limit, or alternative buffering path. Preserve lazy origin consumption and the
+article-prefix-before-auction behavior.
+
+The old `publisher.ssat_compression_offload_enabled` setting never landed on
+`main`; remove its branch-only implementation, tests, and example documentation.
+This change introduces no configuration migration. Other adapters retain their
+current output, and eligible Fastly deployments change automatically on upgrade.
+Rollback is to the preceding application release, not a new runtime flag.
+
+The Fastly adapter's current 10 MiB origin-response materialization check applies
+to buffered responses, not its streamed-response branch. Publisher pipeline
+limits remain separate. This change does not request larger identity origin
+responses and does not change any limit.
+
+## Scenarios and acceptance
+
+| Scenario | Expected outcome |
+| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
+| Fastly eligible inline HTML, identity/gzip/deflate/Brotli origin | Same decoded rewritten HTML, guest identity output, delivery hint, private response |
+| Fastly eligible buffered HTML | Same encoding and header contract without changing processor ordering |
+| Eligible Fastly template miss, warm hit, and assembly fallback | Guest identity output; unchanged template bytes, admission, and privacy |
+| Ineligible reader of an existing template | Current encoding, assembly, consent gates, and terminal privacy |
+| Axum, Cloudflare, or Spin, including with Fastly-style cache headers | Existing encoding; capability cannot be inferred from cache headers |
+| Ad stack ineligible or non-HTML response | Existing compression and behavior |
+| Unsupported origin encoding | Existing unmodified bypass |
+| HEAD, 204, 205, 304, redirects, and errors | Existing body/framing behavior; no newly activated offload |
+| Deflate-selected or failed reader negotiation | Existing in-guest delivery, no new offload hint |
+| Existing multi-field Vary or wildcard | Fields preserved, no duplicate required variation |
+| Streaming origin and slow auction | Lazy body consumption and prefix delivery remain intact |
+| Decode failure or byte-limit violation | Existing failure behavior and auction cleanup |
+
+Local tests must observe origin request headers, decoded body equivalence,
+response framing, compression metadata, terminal privacy, and adapter capability
+isolation through current handler/finalizer paths. Add focused regressions before
+running affected neighboring suites and the repository CI gates. Existing tests
+can cover unchanged behavior where their inputs remain applicable.
+
+## Live validation
+
+Viceroy can prove guest bytes and headers but not Fastly dynamic compression.
+Before claiming production performance or final reader negotiation, measure on
+Fastly staging with representative normal and large pages:
+
+- inline, template miss, and template hit paths;
+- guest vCPU, TTFB, origin transfer bytes, and final browser transfer bytes;
+- browser `Content-Encoding` and decoded HTML equivalence;
+- absent `Accept-Encoding`, identity-only, supported compressed encodings, and
+ identity-refusing requests;
+- unchanged response privacy and no shared caching of assembled HTML.
+
+Keep this evidence distinct from local regression results. Requesting identity
+from origin is a separate future experiment, not an acceptance requirement here.
+
+## Platform reference
+
+Fastly's `X-Compress-Hint` documentation specifies streaming compression selected
+from the matching reader request, gzip/Brotli support, automatic preference for
+Brotli, removal of the hint, and removal of `Content-Length` when compression
+occurs. See the [header reference](https://www.fastly.com/documentation/reference/http/http-headers/X-Compress-Hint/).
+This documents the intended contract; staging must still verify this application.
+
+## Review log
+
+- Round 1, independent agent using `openai-codex/gpt-6.1-sol`: no blocking
+ design defects. Clarified per-reader template eligibility, the current
+ `HtmlEofDriver`, explicit successful/body-bearing offload gating independent
+ of the rewrite classifier, wildcard Vary, final-send header checks, and valid
+ recomputed buffered lengths. The reviewer confirmed deflate and failed-reader
+ negotiation must retain existing delivery behavior.
+- Round 2, fresh agent session using `openai-codex/gpt-6.1-sol`: source check
+ confirmed the clarifications and found no blocking design gaps. Identified
+ late Fastly response effects as a required send-time boundary; added a typed
+ offload marker and narrow terminal reassertion for both send forms, with a
+ focused regression. No broader header-policy change.
+- 2026-11-02 framework extraction: two independent EdgeZero design checks confirmed
+ the shared capability/intent/finalizer contract and identified the old early
+ application markers as unsuitable for checked marking. This revision replaces
+ them with a pending application output plan and marks only installed identity
+ output.
+- Consumer contract review, 2026-11-02: the implementation worker and a fresh
+ independent `openai-codex/gpt-6.1-sol` session checked this revised spec and plan
+ against rebased application source and published EdgeZero commit `63c7b63`.
+ Both found no blockers. This contract review alone is not runtime proof.
+- Consumer implementation review, 2026-11-02: a fresh independent
+ `openai-codex/gpt-6.1-sol` session found no blockers after tracing the pending
+ output plan, template and EOF encoders, post-install marking, and final Fastly
+ effects. All ten runtime input hashes matched at completion. This was a
+ source-only review; execution results belong to the implementation plan.