You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
With [creative_opportunities] assembly_mode = "esi", the Fastly adapter stores the transformed document in a shared template cache. The stored bytes include every integration head insert. Several of those inserts are programs compiled into the binary:
gpt_bootstrap.js, 894 lines, inlined with include_str!.
Rust string literals for the Prebid, Didomi, DataDome and Sourcepoint initializers (including Sourcepoint's window._sp_ property trap) and the GPT enable and install flags.
The template cache key's template_fingerprint covers only the tsjs bundle hash and the serialized settings. The settings values inside these programs are covered. The program code is not.
A new binary that changes any of these programs, with the same settings and bundles, computes the same key. It serves templates stored by the previous binary, with the previous head code, until they expire, are evicted or purged, or someone bumps TEMPLATE_SCHEMA_VERSION. The version's doc comment asks for a bump on any change to what the transform emits. That rule is manual. The version has stayed at 4 since the cache landed in #1013 (squash commit d97bda6, 2026-08-22).
This has existed since #1013. It affects only Fastly deployments that opt into ESI assembly (the default is inline), and only requests eligible for a shared template.
Steps to reproduce
Add this temporary probe test inside mod template_cache_end_to_end_tests in crates/trusted-server-core/src/publisher.rs. It prints, so do not commit it.
The stored template contains the whole bootstrap. The two binaries use the same fingerprint and the same cache key for different bytes. The 38-byte length difference is exactly the appended line. The hash values depend on the locally built bundles, so compare the two runs with each other.
Expected behavior
A binary that changes any head program that ends up in cached templates uses different template cache keys (or otherwise stops reading older templates) without anyone having to remember a manual version bump.
Actual behavior
The key depends only on the request dimensions, the bundle hash, the serialized settings and TEMPLATE_SCHEMA_VERSION. After a deploy that changes only inline head code, readers keep getting the previous binary's head bytes until each entry expires.
Root cause
template_fingerprint has two inputs (crates/trusted-server-core/src/publisher.rs:2073-2087):
letmut hasher = sha2::Sha256::new();
hasher.update(
trusted_server_js::concatenated_hash(&trusted_server_js::all_module_ids()).as_bytes(),);// ...let canonical = serde_json::to_value(settings).and_then(|value| serde_json::to_vec(&value)).expect("serializing typed settings should be infallible");
hasher.update(canonical);
The key field is documented as "Digest of every setting that can shape the transformed template plus the tsjs bundle" (platform/template_cache.rs:72-74). Code bytes owned by the binary are neither. The only guard for them is the manual rule at platform/template_cache.rs:25-37:
/// Bump on **any** change to what the transform emits. Without it a deploy reads/// yesterday's template shape and assembles against markers that moved, which fails/// as a rendering bug far from its cause rather than as a cache miss.
...pubconstTEMPLATE_SCHEMA_VERSION:u32 = 4;
The head inserts are prepended to <head> during the transform (html_processor.rs:317-347). The template that is stored is that transformed output (publisher.rs:1834, let bytes = output.into_inner();, stored at publisher.rs:1901). On a hit, the only validity check is the seam marker (publisher.rs:4868-4881). Head bytes are never re-derived, so stale head code passes.
Inline head programs and whether they reach cached templates:
Program
Code
In a cached template?
GPT: window.__tsjs_gpt_enabled and __tsjs_installGptShim call, all of gpt_bootstrap.js, optional slim Prebid URL
integrations/gpt.rs:493-520, 540
Yes, when [integrations.gpt] is enabled
Prebid: window.pbjs queue init with window.__tsjs_prebid=..., and the external bundle <script ... defer> tag
DataDome: window.ddjskey and window.ddoptions, plus the tag <script ... async>
integrations/datadome.rs:874-903
Yes, unless the request carries the suppression marker; those requests bypass the template (publisher.rs:4529-4541)
Sourcepoint: window.__tsjs_sourcepoint=... and the window._sp_ property trap
integrations/sourcepoint.rs:1021-1082
Yes
GPT diagnostics bootstrap and module tag
integrations/gpt_diagnostics.rs:86-114
No. For an authorized shared template the transform gets no diagnostics decision (publisher.rs:1341-1349, used at 1511), and active diagnostics bypass the template (gpt_diagnostics.rs:78-83, publisher.rs:4539-4541)
APS
integrations/aps.rs:1856-1858
Emits nothing
History
d97bda6 (Add shared root caching with hybrid ESI and streaming assembly #1013, 2026-08-22) created the template cache with TEMPLATE_SCHEMA_VERSION = 4 and edited gpt_bootstrap.js in the same commit. No templates predate it, and the version has not changed since (git log -S'TEMPLATE_SCHEMA_VERSION: u32 = ' a4e01eb55 finds only d97bda6).
gpt_bootstrap.js has changed once since then. 6cae7f5 (Prevent competing GPT first impressions and resize PUC shells #1079, 2026-09-16) added 324 lines to it and removed 15, with no schema bump. The same commit also changed JS bundle sources. I built the bundles at 6cae7f5^ and 6cae7f5 with the same node_modules (lib/package-lock.json is unchanged between them): tsjs-core.js, tsjs-gpt.js and tsjs-prebid.js changed, and the other ten modules were identical. concatenated_hash(all_module_ids()) hashes every module, so the fingerprint moved and templates stored before that deploy were not reused. The bootstrap change was masked by the bundle change. It was not handled.
Only Fastly deployments with assembly_mode = "esi" are affected. That mode is opt-in and marked experimental; the default is inline (creative_opportunities.rs:209-220). Within those deployments, only requests eligible for a shared template are affected.
How long stale head code can be served after a deploy:
A stored template lives for the smaller of the origin's remaining shared freshness and template_cache_max_age_seconds (publisher.rs:6253-6335, capped at 6331). The setting defaults to 60 seconds and accepts 1 to 86,400 (creative_opportunities.rs:19-20). The example config suggests 1200 (trusted-server.example.toml:357), and Increase template_cache_max_age_seconds to 1200s #1087 (closed as completed) asked to raise the configured value to 1200 seconds because "invalidation will force a refetch".
A hit only reads the entry, so it does not extend the lifetime (trusted-server-adapter-fastly/src/template_cache.rs:164-188). Stale entries read as misses (same file, 76-78). Earlier eviction can shorten the window.
So each URL variant can serve the old head for up to its remaining lifetime: at most 60 seconds by default, 20 minutes at 1200 seconds, and up to 24 hours at the maximum.
While old and new versions both serve traffic during a rollout, each can read templates the other stored, because the keys are identical.
What readers see: the old head program, the current bundle, and the current per-request seam. The seam script from the running binary calls tsjs.scheduleInitialAdInit (publisher.rs:5788-5797), which the cached bootstrap defines (gpt_bootstrap.js:293) until the bundle replaces it. Suppose a release changes that contract, or the bootstrap's fallback first-impression behavior (the kind of change #1078 required), without also changing a bundle. Readers then get the new seam with the old bootstrap for the whole window.
Fold a build-time digest of the core crate's sources into template_fingerprint.
In crates/trusted-server-core/build.rs, which is a stub today, hash every file under src/ in sorted relative-path order, hashing path and bytes. Emit cargo:rerun-if-changed=src and write pub(crate) const TEMPLATE_BUILD_DIGEST: &str = "<hex>"; to OUT_DIR.
include! that file and add hasher.update(TEMPLATE_BUILD_DIGEST.as_bytes()); to template_fingerprint.
This covers gpt_bootstrap.js, every Rust-literal head program, and every other Rust change to emitted template bytes, with no list to maintain. The cost is over-invalidation: any core source change cold-starts templates at deploy. That is one fill per URL variant, the same cost as any JS bundle change today, and the key doc already states that over-invalidating is safe. There is no runtime cost beyond hashing 64 extra bytes.
Keep TEMPLATE_SCHEMA_VERSION for the stored entry format (marker and metadata layout), and update its doc to say that emitted-byte changes no longer need a manual bump. Dependency upgrades that change serialization (for example lol_html) are still not covered. Hashing Cargo.lock into the digest, when present, would cover them.
Alternatives, if the over-invalidation is unwanted:
Hash only the inline program sources through a registered list, with a test that fails when a new include_str! asset or head injector is not registered. This is narrower but needs upkeep.
Add only GPT_BOOTSTRAP_JS to the fingerprint. This is a one-line change that covers the largest and most frequently changed program, but leaves the Rust literals uncovered.
Add a CI test that pins a digest of the inline program sources and fails until TEMPLATE_SCHEMA_VERSION and the pinned digest are updated. This keeps the runtime unchanged but still depends on a human bump.
Related direction: #855 proposes build-time tsjs hashes. The same build step could also replace the runtime concatenated_hash call in the fingerprint.
Compatibility: the first deploy with the fix changes every fingerprint once, which costs one cold fill per URL variant. No config or storage migration is needed.
Done when
template_fingerprint includes a build-time digest that changes whenever gpt_bootstrap.js or any other source that emits template bytes changes.
A unit test shows that two different build digests with identical settings produce different fingerprints (through a helper that takes the digest as a parameter).
The probe procedure above prints a different stored_key after the one-line bootstrap edit.
The TEMPLATE_SCHEMA_VERSION doc states what still requires a manual bump.
Description
With
[creative_opportunities] assembly_mode = "esi", the Fastly adapter stores the transformed document in a shared template cache. The stored bytes include every integration head insert. Several of those inserts are programs compiled into the binary:gpt_bootstrap.js, 894 lines, inlined withinclude_str!.window._sp_property trap) and the GPT enable and install flags.The template cache key's
template_fingerprintcovers only the tsjs bundle hash and the serialized settings. The settings values inside these programs are covered. The program code is not.A new binary that changes any of these programs, with the same settings and bundles, computes the same key. It serves templates stored by the previous binary, with the previous head code, until they expire, are evicted or purged, or someone bumps
TEMPLATE_SCHEMA_VERSION. The version's doc comment asks for a bump on any change to what the transform emits. That rule is manual. The version has stayed at 4 since the cache landed in #1013 (squash commit d97bda6, 2026-08-22).This has existed since #1013. It affects only Fastly deployments that opt into ESI assembly (the default is
inline), and only requests eligible for a shared template.Steps to reproduce
Add this temporary probe test inside
mod template_cache_end_to_end_testsincrates/trusted-server-core/src/publisher.rs. It prints, so do not commit it.Run it, append one statement to the bootstrap, and run it again. Revert the edit afterwards.
Observed at a4e01eb, before the edit:
After the edit:
The stored template contains the whole bootstrap. The two binaries use the same fingerprint and the same cache key for different bytes. The 38-byte length difference is exactly the appended line. The hash values depend on the locally built bundles, so compare the two runs with each other.
Expected behavior
A binary that changes any head program that ends up in cached templates uses different template cache keys (or otherwise stops reading older templates) without anyone having to remember a manual version bump.
Actual behavior
The key depends only on the request dimensions, the bundle hash, the serialized settings and
TEMPLATE_SCHEMA_VERSION. After a deploy that changes only inline head code, readers keep getting the previous binary's head bytes until each entry expires.Root cause
template_fingerprinthas two inputs (crates/trusted-server-core/src/publisher.rs:2073-2087):The key field is documented as "Digest of every setting that can shape the transformed template plus the tsjs bundle" (
platform/template_cache.rs:72-74). Code bytes owned by the binary are neither. The only guard for them is the manual rule atplatform/template_cache.rs:25-37:The head inserts are prepended to
<head>during the transform (html_processor.rs:317-347). The template that is stored is that transformed output (publisher.rs:1834,let bytes = output.into_inner();, stored atpublisher.rs:1901). On a hit, the only validity check is the seam marker (publisher.rs:4868-4881). Head bytes are never re-derived, so stale head code passes.Inline head programs and whether they reach cached templates:
window.__tsjs_gpt_enabledand__tsjs_installGptShimcall, all ofgpt_bootstrap.js, optional slim Prebid URLintegrations/gpt.rs:493-520,540[integrations.gpt]is enabledwindow.pbjsqueue init withwindow.__tsjs_prebid=..., and the external bundle<script ... defer>tagintegrations/prebid.rs:1115-1137,1305-1319,1483-1487window.__tsjs_didomi=...integrations/didomi.rs:525-547window.ddjskeyandwindow.ddoptions, plus the tag<script ... async>integrations/datadome.rs:874-903publisher.rs:4529-4541)window.__tsjs_sourcepoint=...and thewindow._sp_property trapintegrations/sourcepoint.rs:1021-1082integrations/gpt_diagnostics.rs:86-114publisher.rs:1341-1349, used at1511), and active diagnostics bypass the template (gpt_diagnostics.rs:78-83,publisher.rs:4539-4541)integrations/aps.rs:1856-1858History
TEMPLATE_SCHEMA_VERSION = 4and editedgpt_bootstrap.jsin the same commit. No templates predate it, and the version has not changed since (git log -S'TEMPLATE_SCHEMA_VERSION: u32 = ' a4e01eb55finds only d97bda6).gpt_bootstrap.jshas changed once since then. 6cae7f5 (Prevent competing GPT first impressions and resize PUC shells #1079, 2026-09-16) added 324 lines to it and removed 15, with no schema bump. The same commit also changed JS bundle sources. I built the bundles at 6cae7f5^ and 6cae7f5 with the samenode_modules(lib/package-lock.jsonis unchanged between them):tsjs-core.js,tsjs-gpt.jsandtsjs-prebid.jschanged, and the other ten modules were identical.concatenated_hash(all_module_ids())hashes every module, so the fingerprint moved and templates stored before that deploy were not reused. The bootstrap change was masked by the bundle change. It was not handled.gpt_bootstrap.js". A follow-up that corrected only the bootstrap would have hit this bug.git log -Lover each head-insert function range, from d97bda6 to a4e01eb, shows changes only in Prebid, from dbc1012 (Add configuration-driven OpenRTB auction providers #1016) and 9558943 (Add managed LiveRamp RampID integration #1054). Both refactored the payload code, and both also changedcrates/trusted-server-js/lib/src/integrations/prebid/index.tsin the same commit. Add configuration-driven OpenRTB auction providers #1016 moved the literals into free functions without changing their bytes. I found no change to the GPT, Didomi, DataDome, Sourcepoint or GPT-diagnostics ranges.Impact
Only Fastly deployments with
assembly_mode = "esi"are affected. That mode is opt-in and marked experimental; the default isinline(creative_opportunities.rs:209-220). Within those deployments, only requests eligible for a shared template are affected.How long stale head code can be served after a deploy:
template_cache_max_age_seconds(publisher.rs:6253-6335, capped at6331). The setting defaults to 60 seconds and accepts 1 to 86,400 (creative_opportunities.rs:19-20). The example config suggests 1200 (trusted-server.example.toml:357), and Increase template_cache_max_age_seconds to 1200s #1087 (closed as completed) asked to raise the configured value to 1200 seconds because "invalidation will force a refetch".trusted-server-adapter-fastly/src/template_cache.rs:164-188). Stale entries read as misses (same file,76-78). Earlier eviction can shorten the window.What readers see: the old head program, the current bundle, and the current per-request seam. The seam script from the running binary calls
tsjs.scheduleInitialAdInit(publisher.rs:5788-5797), which the cached bootstrap defines (gpt_bootstrap.js:293) until the bundle replaces it. Suppose a release changes that contract, or the bootstrap's fallback first-impression behavior (the kind of change #1078 required), without also changing a bundle. Readers then get the new seam with the old bootstrap for the whole window.Mitigations at a4e01eb:
TEMPLATE_SCHEMA_VERSIONin the release (manual).ts-template, which the adapter attaches at insert (trusted-server-adapter-fastly/src/template_cache.rs:141,255-258). Trusted Server has no production caller ofpurge_allat a4e01eb, so this means a purge through Fastly's own tools. Open PR Split origin shareability from template eligibility, and add the readthrough evidence gate #1169 addsts cache purge.Proposed fix
Fold a build-time digest of the core crate's sources into
template_fingerprint.crates/trusted-server-core/build.rs, which is a stub today, hash every file undersrc/in sorted relative-path order, hashing path and bytes. Emitcargo:rerun-if-changed=srcand writepub(crate) const TEMPLATE_BUILD_DIGEST: &str = "<hex>";toOUT_DIR.include!that file and addhasher.update(TEMPLATE_BUILD_DIGEST.as_bytes());totemplate_fingerprint.This covers
gpt_bootstrap.js, every Rust-literal head program, and every other Rust change to emitted template bytes, with no list to maintain. The cost is over-invalidation: any core source change cold-starts templates at deploy. That is one fill per URL variant, the same cost as any JS bundle change today, and the key doc already states that over-invalidating is safe. There is no runtime cost beyond hashing 64 extra bytes.Keep
TEMPLATE_SCHEMA_VERSIONfor the stored entry format (marker and metadata layout), and update its doc to say that emitted-byte changes no longer need a manual bump. Dependency upgrades that change serialization (for examplelol_html) are still not covered. HashingCargo.lockinto the digest, when present, would cover them.Alternatives, if the over-invalidation is unwanted:
include_str!asset or head injector is not registered. This is narrower but needs upkeep.GPT_BOOTSTRAP_JSto the fingerprint. This is a one-line change that covers the largest and most frequently changed program, but leaves the Rust literals uncovered.TEMPLATE_SCHEMA_VERSIONand the pinned digest are updated. This keeps the runtime unchanged but still depends on a human bump.Related direction: #855 proposes build-time tsjs hashes. The same build step could also replace the runtime
concatenated_hashcall in the fingerprint.Compatibility: the first deploy with the fix changes every fingerprint once, which costs one cold fill per URL variant. No config or storage migration is needed.
Done when
template_fingerprintincludes a build-time digest that changes whenevergpt_bootstrap.jsor any other source that emits template bytes changes.stored_keyafter the one-line bootstrap edit.TEMPLATE_SCHEMA_VERSIONdoc states what still requires a manual bump.Affected area
HTML processing / JS injection
Version
main at a4e01eb
Related
template_fingerprint, the shared template cache andTEMPLATE_SCHEMA_VERSION = 4.gpt_bootstrap.jswithout a schema bump. Masked by bundle changes in the same commit.TEMPLATE_SCHEMA_VERSIONto 5 and adds purge tooling. A mitigation lever, not a fix.allowed_context_keyshas two or more entries.