From d37d283130ce9eb8e3a5fb3326bdcde1c037cbcb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 12 Oct 2026 17:47:54 +0100 Subject: [PATCH 01/21] A module declares the caches it loads through A deployment selects a loading cache for each feature in its own [cache.] table, and core listed one feature, robots-txt, so a module's own cache was never selected and every one of its loads ran on every call. A builder now declares the caches its modules load through, by name, [cache.] is read for each declared name with the same settings as [cache.robots-txt], and a table naming a cache no linked module declares is refused when the deployment starts, naming the ones that are declared. --- CHANGELOG.md | 1 + .../src/integrations/mod.rs | 80 ++++++++++++++++++- .../src/platform/loading_cache.rs | 8 +- crates/trusted-server-core/src/settings.rs | 43 ++++++++-- docs/guide/configuration.md | 9 +++ 5 files changed, 131 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3a8d59f6..90ae9eb420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A module declares the caches it loads through, by name, and a deployment selects a loading cache for each in its own `[cache.]` table, the way `[cache.robots-txt]` selects one for robots.txt. A `[cache.]` table naming a cache no linked module declares is refused when the deployment starts, naming the ones it does. `IntegrationBuilder::with_cache_features` declares them, `CacheSettings::loading_caches` lists core's own and the declared ones, and `LoadingCaches::insert` takes any name. - **Breaking for crates outside core:** the cookie constants `COOKIE_TS_EC`, `COOKIE_TS_EC_RESOLVED`, `COOKIE_TS_EC_PULL_COMPLETE`, `COOKIE_TS_EIDS` and `COOKIE_TS_TESTER` are `COOKIE_EC`, `COOKIE_EC_RESOLVED`, `COOKIE_EC_PULL_COMPLETE`, `COOKIE_EIDS` and `COOKIE_TESTER`, holding the entry's name alone, and a cookie is named through the request's public prefix with `services.public_prefix().cookie_name(COOKIE_EC)`. A module's response header naming a cookie under the public prefix is refused as one under `ts-` was. - A robots.txt module's answer is held by the deployment's loading cache, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. - Holding a robots.txt module's answer is opt-in. With `[cache.robots-txt] module` set, the answer is held by the loading cache that module offers, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules. With it unset nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index f10e6c8465..dc31e7791a 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -425,6 +425,7 @@ pub struct IntegrationBuilder { section: Option<&'static str>, loading_cache: Option, shared: Option<&'static str>, + cache_features: &'static [&'static str], } /// Gives the loading cache a module supplies, see @@ -471,6 +472,7 @@ impl IntegrationBuilder { section: None, loading_cache: None, shared: None, + cache_features: &[], } } @@ -500,6 +502,7 @@ impl IntegrationBuilder { section: None, loading_cache: None, shared: None, + cache_features: &[], } } @@ -659,6 +662,22 @@ impl IntegrationBuilder { self.shared } + /// Declares the caches this builder's modules load through, by name, + /// each selected in its own `[cache.]` table the way core's own + /// are. A table naming a cache no builder declares is refused by + /// [`loading_caches_from`]. + #[must_use] + pub const fn with_cache_features(mut self, features: &'static [&'static str]) -> Self { + self.cache_features = features; + self + } + + /// The caches this builder's modules load through, by name. + #[must_use] + pub const fn cache_features(&self) -> &'static [&'static str] { + self.cache_features + } + /// The loading cache this builder offers, when it offers one. #[must_use] pub const fn loading_cache(&self) -> Option { @@ -812,7 +831,8 @@ pub(crate) fn all_builders( /// # Errors /// /// When a table names a module no linked builder offers a cache from, naming -/// the table and the modules that do. +/// the table and the modules that do, and when a `[cache.]` table names +/// a cache no linked module declares, naming the ones they do. pub fn loading_caches_from( settings: &Settings, builders: &[IntegrationBuilder], @@ -825,9 +845,25 @@ pub fn loading_caches_from( .map(|factory| (builder.id(), factory)) }) .collect(); + let declared: Vec<&str> = std::iter::once(crate::settings::ROBOTS_TXT_CACHE) + .chain( + builders + .iter() + .flat_map(|builder| builder.cache_features().iter().copied()), + ) + .collect(); let mut made: Vec<(&'static str, Arc)> = Vec::new(); let mut caches = crate::platform::LoadingCaches::default(); for (table, config) in settings.cache.loading_caches() { + if !declared.contains(&table) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[cache.{table}] names a cache no linked module loads through. The caches \ + this build's modules declare are: {}", + declared.join(", ") + ), + })); + } let Some(written) = config.module.as_deref() else { continue; }; @@ -936,6 +972,48 @@ mod loading_caches_from_tests { IntegrationBuilder::implementations(id, "a-crate").with_loading_cache(nothing) } + /// A module `id` declaring a cache of its own, `answers`. + fn declaring(id: &'static str) -> IntegrationBuilder { + IntegrationBuilder::implementations(id, "a-crate").with_cache_features(&["answers"]) + } + + fn settings_declaring(table: &str, module: Option<&str>) -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.cache.declared.insert( + table.to_owned(), + crate::settings::LoadingCacheConfig { + module: module.map(str::to_owned), + ..Default::default() + }, + ); + settings + } + + #[test] + fn a_cache_a_module_declares_selects_a_module_as_core_s_own_does() { + let caches = loading_caches_from( + &settings_declaring("answers", Some("one")), + &[declaring("a_vendor"), offering("loading-cache.one")], + ) + .expect("a declared cache selects a module"); + + assert!(caches.for_feature("answers").keeps_values()); + assert!(!caches.for_feature(ROBOTS_TXT_CACHE).keeps_values()); + } + + #[test] + fn a_cache_no_module_declares_is_refused_naming_the_declared_ones() { + let refused = loading_caches_from( + &settings_declaring("answres", Some("one")), + &[declaring("a_vendor"), offering("loading-cache.one")], + ) + .expect_err("a misspelt cache name is refused"); + + let message = format!("{refused:?}"); + assert!(message.contains("[cache.answres]"), "{message}"); + assert!(message.contains("robots-txt, answers"), "{message}"); + } + fn settings_selecting(module: Option<&str>) -> Settings { let mut settings = crate::test_support::tests::create_test_settings(); settings.cache.robots_txt.module = module.map(str::to_owned); diff --git a/crates/trusted-server-core/src/platform/loading_cache.rs b/crates/trusted-server-core/src/platform/loading_cache.rs index 8677381e1e..1c81c540b2 100644 --- a/crates/trusted-server-core/src/platform/loading_cache.rs +++ b/crates/trusted-server-core/src/platform/loading_cache.rs @@ -242,7 +242,7 @@ impl PlatformLoadingCache for NoLoadingCache { /// [`NoLoadingCache`]. #[derive(Default)] pub struct LoadingCaches { - by_feature: HashMap<&'static str, Arc>, + by_feature: HashMap>, /// What a cache whose table selected nothing loads through, when a test /// gives every caller one cache. unselected: Option>, @@ -259,8 +259,8 @@ impl LoadingCaches { } /// Keeps `cache` as the one the table `name` selected. - pub fn insert(&mut self, name: &'static str, cache: Arc) { - self.by_feature.insert(name, cache); + pub fn insert(&mut self, name: &str, cache: Arc) { + self.by_feature.insert(name.to_owned(), cache); } /// The cache named `name` loads through. @@ -281,7 +281,7 @@ impl LoadingCaches { impl fmt::Debug for LoadingCaches { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut selected: Vec<&&str> = self.by_feature.keys().collect(); + let mut selected: Vec<&str> = self.by_feature.keys().map(String::as_str).collect(); selected.sort(); f.debug_struct("LoadingCaches") .field("selected", &selected) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index be7db39517..389eaa4b47 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2968,7 +2968,6 @@ impl AnalyticsConfig { /// Cache behavior configuration. #[derive(Debug, Default, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] pub struct CacheSettings { /// Ordered static/rehosted asset rules. The first enabled matching rule wins. #[serde(default)] @@ -2981,6 +2980,12 @@ pub struct CacheSettings { skip_serializing_if = "LoadingCacheConfig::is_default" )] pub robots_txt: LoadingCacheConfig, + /// The caches modules declare and load through, each in its own + /// `[cache.]` table with the same settings as `[cache.robots-txt]`. + /// A name no linked module declares is refused when the deployment's + /// caches are resolved, see `integrations::loading_caches_from`. + #[serde(flatten, default)] + pub declared: BTreeMap, } /// The most seconds a loading cache lifetime setting accepts, a year. @@ -3075,10 +3080,17 @@ fn check_loading_cache_seconds( impl CacheSettings { /// Each cache a loading cache may hold, by its name, with the settings - /// its `[cache.]` table carries. + /// its `[cache.]` table carries: core's own first, then the ones + /// modules declare, in name order. #[must_use] - pub fn loading_caches(&self) -> [(&'static str, &LoadingCacheConfig); 1] { - [(ROBOTS_TXT_CACHE, &self.robots_txt)] + pub fn loading_caches(&self) -> Vec<(&str, &LoadingCacheConfig)> { + std::iter::once((ROBOTS_TXT_CACHE, &self.robots_txt)) + .chain( + self.declared + .iter() + .map(|(name, config)| (name.as_str(), config)), + ) + .collect() } fn normalize(&mut self) { @@ -3096,6 +3108,9 @@ impl CacheSettings { /// invalid policy/matcher or cannot compile its regex/glob. pub fn prepare_runtime(&self) -> Result<(), Report> { self.robots_txt.check("cache.robots-txt")?; + for (name, config) in &self.declared { + config.check(&format!("cache.{name}"))?; + } let mut seen_ids = HashSet::new(); for rule in &self.asset_rules { if rule.id.is_empty() { @@ -6868,6 +6883,24 @@ module = \"none\"", assert_eq!(written, serde_json::json!({ "asset_rules": [] })); } + #[test] + fn a_cache_a_module_declares_is_read_from_its_own_table() { + let toml_str = format!( + "{}\n[cache.answers]\nmodule = \"51degrees\"\nstore_seconds = 3600\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse a declared cache"); + + let caches = settings.cache.loading_caches(); + + assert_eq!(caches.len(), 2); + assert_eq!(caches[1].0, "answers"); + assert_eq!(caches[1].1.module.as_deref(), Some("51degrees")); + assert_eq!(caches[1].1.store_seconds, Some(3600)); + let written = serde_json::to_value(&settings.cache).expect("should serialize"); + assert_eq!(written["answers"]["module"], "51degrees"); + } + #[test] fn a_loading_cache_module_is_read_and_written_back() { let toml_str = format!( @@ -6887,7 +6920,7 @@ module = \"none\"", assert!(!settings.cache.robots_txt.is_default()); assert_eq!( settings.cache.loading_caches(), - [(super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt)], + vec![(super::ROBOTS_TXT_CACHE, &settings.cache.robots_txt)], "should list the robots.txt cache by its name" ); let written = serde_json::to_value(&settings.cache).expect("should serialize"); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c47c0bc487..b80950067d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1746,6 +1746,15 @@ the names that could be written. | `[cache.robots-txt]` | `store_seconds` | `604800`, a week | 1 to 31536000 | The longest a module's answer is used. The module's own refresh ends it sooner. | | `[cache.robots-txt]` | `store_idle_seconds` | `2592000`, 30 days | 1 to 31536000 | How long an answer in the shared store lasts unused. | +A module may declare caches of its own, named by the module, and a deployment +selects a loading cache for each in its own `[cache.]` table with the +same four settings. The 51Degrees modules declare `cloud-answer`, the cache +that holds each answer the 51Degrees cloud gives for one request's evidence, +so `[cache.cloud-answer] module = "51degrees"` asks the cloud once however +many requests carry the same evidence. A `[cache.]` table naming a +cache no linked module declares is refused when the deployment starts, and +the message lists the names this build's modules declare. + ```toml [cache.robots-txt] module = "51degrees" From d9656fda848e53b2d7e2782ad34910ab362e5d8f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 02:18:27 +0100 Subject: [PATCH 02/21] A declared cache with no table takes the one loading cache module the build links --- .../src/integrations/mod.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 0df311315d..8bedc71a82 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -893,6 +893,28 @@ pub fn loading_caches_from( }; caches.insert(table, cache); } + // A declared cache with no table at all takes the one loading cache + // module the build links, as a table naming no module does, so a stock + // build holds a module's answers without a line in the document. + if let [(_, factory)] = offered.as_slice() { + let tabled: Vec<&str> = settings + .cache + .loading_caches() + .into_iter() + .map(|(table, _)| table) + .collect(); + let mut default = None; + for name in declared.iter().filter(|name| !tabled.contains(name)) { + let cache = default + .get_or_insert_with(|| { + made.iter() + .find(|(made_name, _)| *made_name == offered[0].0) + .map_or_else(factory, |(_, cache)| Arc::clone(cache)) + }) + .clone(); + caches.insert(name, cache); + } + } Ok(Arc::new(caches)) } @@ -1009,6 +1031,34 @@ mod loading_caches_from_tests { ); } + #[test] + fn a_declared_cache_with_no_table_takes_the_one_module_offered() { + let caches = loading_caches_from( + &settings_selecting(None), + &[declaring("a_vendor"), offering("loading-cache.one")], + ) + .expect("no table is not an error"); + + assert!(caches.for_feature("answers").keeps_values()); + assert!(caches.for_feature(ROBOTS_TXT_CACHE).keeps_values()); + } + + #[test] + fn a_declared_cache_with_no_table_keeps_nothing_when_two_are_offered() { + let caches = loading_caches_from( + &settings_selecting(None), + &[ + declaring("a_vendor"), + offering("loading-cache.one"), + offering("loading-cache.two"), + ], + ) + .expect("no table is not an error"); + + assert!(!caches.for_feature("answers").keeps_values()); + assert!(caches.is_empty()); + } + #[test] fn a_cache_no_module_declares_is_refused_naming_the_declared_ones() { let refused = loading_caches_from( From 1133273b28251753c24398070a4c981bd5614000 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Tue, 13 Oct 2026 11:00:53 +0100 Subject: [PATCH 03/21] The deploy check refuses the cache table the cache build refuses A cache name no linked module declares is refused by validate_settings_for_deploy_with as it is when the caches are made, so a misspelt [cache.] fails ts config validate and not the deployment. A builder declaring robots-txt, or a name another builder declares, is refused naming the builders. The seam probe declares seam-probe-answers and offers a loading cache of its own, and the Axum seam tests prove the table selects it from outside core. The guide names a fictional module. --- CHANGELOG.md | 2 +- Cargo.lock | 1 + crates/testing/seam-probe/Cargo.toml | 1 + crates/testing/seam-probe/src/lib.rs | 63 ++++++++ .../tests/seam_probe.rs | 90 +++++++++++ crates/trusted-server-core/src/config.rs | 7 + .../src/integrations/mod.rs | 149 +++++++++++++++--- docs/guide/configuration.md | 14 +- 8 files changed, 301 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0feb98502..589d0671b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- A module declares the caches it loads through, by name, and a deployment selects a loading cache for each in its own `[cache.]` table, the way `[cache.robots-txt]` selects one for robots.txt. A `[cache.]` table naming a cache no linked module declares is refused when the deployment starts, naming the ones it does. `IntegrationBuilder::with_cache_features` declares them, `CacheSettings::loading_caches` lists core's own and the declared ones, and `LoadingCaches::insert` takes any name. +- A module declares the caches it loads through, by name, and a deployment selects a loading cache for each in its own `[cache.]` table, the way `[cache.robots-txt]` selects one for robots.txt. A `[cache.]` table naming a cache no linked module declares is refused by `ts config validate` and when the deployment starts, naming the ones it does, and a module declaring `robots-txt` or a name another module declares is refused. `IntegrationBuilder::with_cache_features` declares them, `CacheSettings::loading_caches` lists core's own and the declared ones, and `LoadingCaches::insert` takes any name. - **Breaking for crates outside core:** the cookie constants `COOKIE_TS_EC`, `COOKIE_TS_EC_RESOLVED`, `COOKIE_TS_EC_PULL_COMPLETE`, `COOKIE_TS_EIDS` and `COOKIE_TS_TESTER` are `COOKIE_EC`, `COOKIE_EC_RESOLVED`, `COOKIE_EC_PULL_COMPLETE`, `COOKIE_EIDS` and `COOKIE_TESTER`, holding the entry's name alone, and a cookie is named through the request's public prefix with `services.public_prefix().cookie_name(COOKIE_EC)`. A module's response header naming a cookie under the public prefix is refused as one under `ts-` was. - A robots.txt module's answer is held by the deployment's loading cache, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. - A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. diff --git a/Cargo.lock b/Cargo.lock index 7770aec6ec..b6c45f7ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7038,6 +7038,7 @@ name = "trusted-server-integration-seam-probe" version = "0.1.0" dependencies = [ "async-trait", + "bytes", "edgezero-core", "error-stack", "hex", diff --git a/crates/testing/seam-probe/Cargo.toml b/crates/testing/seam-probe/Cargo.toml index a3221b34d4..776793769f 100644 --- a/crates/testing/seam-probe/Cargo.toml +++ b/crates/testing/seam-probe/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] async-trait = { workspace = true } +bytes = { workspace = true } edgezero-core = { workspace = true } error-stack = { workspace = true } http = { workspace = true } diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index ae85c75cbb..720bbde6f6 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -652,6 +652,9 @@ impl SeamProbeProxy { "request_preparer_runs": prepared.map_or(0, |marker| marker.runs), "public_prefix": services.public_prefix().as_str(), "server_key_prefix": services.server_key_prefix().as_str(), + "answers_cache_keeps_values": services + .loading_cache_for(SEAM_PROBE_ANSWERS_CACHE) + .keeps_values(), }) .to_string(); @@ -813,6 +816,66 @@ pub fn builder() -> IntegrationBuilder { .with_request_preparer(prepare_request) .with_demand(&SEAM_PROBE_DEMAND) .with_adserver(&SEAM_PROBE_ADSERVER) + .with_cache_features(&[SEAM_PROBE_ANSWERS_CACHE]) +} + +/// The cache the probe declares and loads its answers through, selected in +/// `[cache.seam-probe-answers]`. The report route says whether the cache +/// that table selected keeps values. +pub const SEAM_PROBE_ANSWERS_CACHE: &str = "seam-probe-answers"; + +/// The id of the loading cache module the probe offers, selected in a +/// `[cache.]` table as `module = "seam-probe"`. +pub const SEAM_PROBE_LOADING_CACHE_ID: &str = "loading-cache.seam-probe"; + +/// A loading cache that keeps every value in this process's memory for as +/// long as the process runs, enough to prove from outside core that a table +/// selected it. +#[derive(Default)] +struct MemoryLoadingCache { + held: Mutex>, +} + +#[async_trait(?Send)] +impl trusted_server_core::platform::PlatformLoadingCache for MemoryLoadingCache { + async fn get_or_load( + &self, + _services: &RuntimeServices, + _spec: &trusted_server_core::platform::CacheSpec, + key: &trusted_server_core::platform::StoreKey, + load: trusted_server_core::platform::Loader, + ) -> Result { + if let Some(held) = self.held.lock().expect("lock").get(&key.to_string()) { + return Ok(held.clone()); + } + let loaded = load().await?.bytes; + self.held + .lock() + .expect("lock") + .insert(key.to_string(), loaded.clone()); + Ok(loaded) + } + + async fn remove( + &self, + _services: &RuntimeServices, + _spec: &trusted_server_core::platform::CacheSpec, + key: &trusted_server_core::platform::StoreKey, + ) { + self.held.lock().expect("lock").remove(&key.to_string()); + } +} + +fn memory_loading_cache() -> Arc { + Arc::new(MemoryLoadingCache::default()) +} + +/// The builder offering the probe's loading cache, for a test that proves a +/// declared cache's table selects a module from outside core. +#[must_use] +pub fn loading_cache_builder() -> IntegrationBuilder { + IntegrationBuilder::implementations(SEAM_PROBE_LOADING_CACHE_ID, SEAM_PROBE_SOURCE) + .with_loading_cache(memory_loading_cache) } /// The vendor's name, which names the table `[shared.seam-probe]` that runs diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 6e7e75d20e..80492f8fe3 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -458,6 +458,96 @@ fn deploy_validation_rejects_a_violation_of_the_modules_own_rule() { ); } +// --------------------------------------------------------------------------- +// 3b. A cache the probe declares is selected in its own table +// --------------------------------------------------------------------------- + +/// Reads the probe's report through the router, as a page would. +async fn report_with(settings: Settings, integrations: &[IntegrationBuilder]) -> serde_json::Value { + let mut service = service_with(settings, integrations); + let response = get(&mut service, seam_probe::SEAM_PROBE_REPORT_PATH).await; + assert_eq!( + response.status().as_u16(), + 200, + "the report route should answer" + ); + let body = body_text(response).await; + serde_json::from_str(&body).expect("the report route should return JSON") +} + +/// `[cache.seam-probe-answers]` selects the loading cache the probe's answers +/// are held in, the way `[cache.robots-txt]` selects core's. Left out, the +/// cache takes the one loading cache module the build links, and this build +/// links two, the stock one and the probe's, so it keeps nothing until the +/// table names one. With the probe's cache not linked, the stock one alone +/// is, and the cache takes it without a table. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cache_the_probe_declares_is_selected_in_its_own_table() { + let named = settings_with(&format!( + r#" + [cache.seam-probe-answers] + module = "seam-probe" + {PROBE_BLOCK} + "# + )); + let both = [seam_probe::builder(), seam_probe::loading_cache_builder()]; + + let report = report_with(named, &both).await; + assert_eq!( + report["answers_cache_keeps_values"], true, + "the table should select the module it names: {report}" + ); + + let report = report_with(settings_with(PROBE_BLOCK), &both).await; + assert_eq!( + report["answers_cache_keeps_values"], false, + "with two loading cache modules linked and no table the cache should keep nothing: {report}" + ); + + let report = report_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]).await; + assert_eq!( + report["answers_cache_keeps_values"], true, + "a declared cache with no table should take the one loading cache module linked: {report}" + ); +} + +/// A `[cache.]` table naming a cache no linked module declares is +/// refused by the deploy check and at startup with one message, naming the +/// caches that could be written, so a misspelt name fails `ts config +/// validate` and not the deployment. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_startup() { + let settings = settings_with(&format!( + r#" + [cache.seam-probe-answres] + module = "seam-probe" + {PROBE_BLOCK} + "# + )); + let both = [seam_probe::builder(), seam_probe::loading_cache_builder()]; + + let deploy = validate_settings_for_deploy_with(&settings, &both) + .expect_err("the deploy check should refuse the misspelt table"); + let startup = TrustedServerApp::routes_with_registrations(settings, &both) + .err() + .expect("startup should refuse the misspelt table"); + + let message = format!("{deploy:?}"); + assert!( + message.contains("[cache.seam-probe-answres]"), + "should name the table: {message}" + ); + assert!( + message.contains("robots-txt, seam-probe-answers"), + "should list the caches this build declares: {message}" + ); + assert_eq!( + message, + format!("{startup:?}"), + "the deploy check should refuse what startup refuses" + ); +} + // --------------------------------------------------------------------------- // 4. A geo selector naming a module that supplies no module fails at startup // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 69eacabce6..4e508cbde0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -357,6 +357,13 @@ pub fn validate_settings_for_deploy_with( let mut structural_settings = settings.clone(); structural_settings.prepare_runtime()?; + // A `[cache.]` table is refused here as the cache build refuses it, + // so a misspelt name fails `ts config validate` and not the deployment's + // first request. + let builders: Vec = + crate::integrations::all_builders(extra_integrations).collect(); + crate::integrations::check_cache_tables(settings, &builders)?; + // The plan is compiled with the builders the blocks are validated // against, so a `[demand]` or `[ad-server]` name one of them supplies // compiles here. diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 8bedc71a82..4257adc7d4 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -664,8 +664,10 @@ impl IntegrationBuilder { /// Declares the caches this builder's modules load through, by name, /// each selected in its own `[cache.]` table the way core's own - /// are. A table naming a cache no builder declares is refused by - /// [`loading_caches_from`]. + /// are. A name is the module's own, as `example-answers` is for a + /// module from `crates/geo/example`: core's `robots-txt` and a name + /// another builder declares are refused by [`declared_caches`], and a + /// table naming a cache no builder declares by [`check_cache_tables`]. #[must_use] pub const fn with_cache_features(mut self, features: &'static [&'static str]) -> Self { self.cache_features = features; @@ -824,6 +826,74 @@ pub(crate) fn all_builders( builders().iter().copied().chain(extra.iter().copied()) } +/// The caches a deployment may hold answers in, by name: core's own first, +/// then the ones `builders` declare, in builder order. +/// +/// # Errors +/// +/// When a builder declares core's own `robots-txt`, or two builders declare +/// one name, naming the builders, since the two would otherwise share one +/// cache and its keys without a word. +pub fn declared_caches( + builders: &[IntegrationBuilder], +) -> Result, Report> { + let mut declared: Vec<(&'static str, &'static str)> = + vec![(crate::settings::ROBOTS_TXT_CACHE, "core")]; + for builder in builders { + for name in builder.cache_features() { + if let Some((_, by)) = declared.iter().find(|(declared, _)| declared == name) { + return Err(Report::new(TrustedServerError::Configuration { + message: if *by == "core" { + format!( + "`{}` declares the cache `{name}`, which is core's own. A module \ + names its caches under itself, as modules are named", + builder.id() + ) + } else { + format!( + "`{}` and `{by}` both declare the cache `{name}`, which would hold \ + both their answers under one set of keys. A module names its \ + caches under itself, as modules are named", + builder.id() + ) + }, + })); + } + declared.push((name, builder.id())); + } + } + Ok(declared.into_iter().map(|(name, _)| name).collect()) +} + +/// Refuses a `[cache.]` table naming a cache no linked module declares. +/// Run when a deployment's settings are validated and again when its caches +/// are made, so a misspelt table fails the deploy check and not the first +/// request. +/// +/// # Errors +/// +/// When a table names an undeclared cache, naming the table and the caches +/// this build's modules declare, or when the declarations themselves are +/// refused, see [`declared_caches`]. +pub fn check_cache_tables( + settings: &Settings, + builders: &[IntegrationBuilder], +) -> Result<(), Report> { + let declared = declared_caches(builders)?; + for (table, _) in settings.cache.loading_caches() { + if !declared.contains(&table) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[cache.{table}] names a cache no linked module loads through. The caches \ + this build's modules declare are: {}", + declared.join(", ") + ), + })); + } + } + Ok(()) +} + /// The loading cache each `[cache.]` table selects, from the caches the /// builders a deployment links offer. A table that names no module keeps no /// copy, and a module's cache is made once however many tables select it. @@ -832,11 +902,13 @@ pub(crate) fn all_builders( /// /// When a table names a module no linked builder offers a cache from, naming /// the table and the modules that do, and when a `[cache.]` table names -/// a cache no linked module declares, naming the ones they do. +/// a cache no linked module declares, naming the ones they do, see +/// [`check_cache_tables`]. pub fn loading_caches_from( settings: &Settings, builders: &[IntegrationBuilder], ) -> Result, Report> { + check_cache_tables(settings, builders)?; let offered: Vec<(&'static str, LoadingCacheFactory)> = builders .iter() .filter_map(|builder| { @@ -845,25 +917,10 @@ pub fn loading_caches_from( .map(|factory| (builder.id(), factory)) }) .collect(); - let declared: Vec<&str> = std::iter::once(crate::settings::ROBOTS_TXT_CACHE) - .chain( - builders - .iter() - .flat_map(|builder| builder.cache_features().iter().copied()), - ) - .collect(); + let declared = declared_caches(builders)?; let mut made: Vec<(&'static str, Arc)> = Vec::new(); let mut caches = crate::platform::LoadingCaches::default(); for (table, config) in settings.cache.loading_caches() { - if !declared.contains(&table) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "[cache.{table}] names a cache no linked module loads through. The caches \ - this build's modules declare are: {}", - declared.join(", ") - ), - })); - } // A table naming no module takes the one loading cache module the // build links, so answers are held without a line in the document. // With none linked, or several, it holds nothing. @@ -1072,6 +1129,60 @@ mod loading_caches_from_tests { assert!(message.contains("robots-txt, answers"), "{message}"); } + #[test] + fn the_deploy_check_refuses_the_table_the_cache_build_refuses() { + let settings = settings_declaring("answres", Some("one")); + let builders = [declaring("a_vendor"), offering("loading-cache.one")]; + + let refused = check_cache_tables(&settings, &builders) + .expect_err("the check refuses a misspelt cache name without making a cache"); + let built = loading_caches_from(&settings, &builders).expect_err("as the build does"); + + assert_eq!(format!("{refused:?}"), format!("{built:?}")); + check_cache_tables(&settings_declaring("answers", Some("one")), &builders) + .expect("a declared cache passes the check"); + } + + #[test] + fn two_builders_declaring_one_cache_are_refused_naming_both() { + let refused = loading_caches_from( + &settings_selecting(None), + &[ + declaring("a_vendor"), + declaring("another_vendor"), + offering("loading-cache.one"), + ], + ) + .expect_err("two builders sharing a cache name are refused"); + + let message = format!("{refused:?}"); + assert!( + message.contains("`another_vendor` and `a_vendor`"), + "{message}" + ); + assert!(message.contains("`answers`"), "{message}"); + } + + #[test] + fn a_builder_declaring_core_s_own_cache_is_refused() { + let refused = loading_caches_from( + &settings_selecting(None), + &[ + IntegrationBuilder::implementations("a_vendor", "a-crate") + .with_cache_features(&[ROBOTS_TXT_CACHE]), + offering("loading-cache.one"), + ], + ) + .expect_err("a builder declaring robots-txt is refused"); + + let message = format!("{refused:?}"); + assert!( + message.contains("`a_vendor` declares the cache `robots-txt`"), + "{message}" + ); + assert!(message.contains("core's own"), "{message}"); + } + fn settings_selecting(module: Option<&str>) -> Settings { let mut settings = crate::test_support::tests::create_test_settings(); settings.cache.robots_txt.module = module.map(str::to_owned); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e078b0e3c4..6480e02a8e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1752,12 +1752,14 @@ the names that could be written. A module may declare caches of its own, named by the module, and a deployment selects a loading cache for each in its own `[cache.]` table with the -same four settings. The 51Degrees modules declare `cloud-answer`, the cache -that holds each answer the 51Degrees cloud gives for one request's evidence, -so `[cache.cloud-answer] module = "51degrees"` asks the cloud once however -many requests carry the same evidence. A `[cache.]` table naming a -cache no linked module declares is refused when the deployment starts, and -the message lists the names this build's modules declare. +same four settings. A module from `crates/geo/example` whose service answers +once for each request's evidence declares `example-answers`, the cache that +holds those answers, so `[cache.example-answers] module = "51degrees"` asks +the service once however many requests carry the same evidence. A name is +the module's own: `robots-txt` and a name two modules declare are refused. A +`[cache.]` table naming a cache no linked module declares is refused by +`ts config validate` and when the deployment starts, and the message lists +the names this build's modules declare. ```toml [cache.robots-txt] From 0f84dd60d08225fb948887e0d6729dd321bac86d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 13:51:29 +0100 Subject: [PATCH 04/21] The probe compares the two refusals of a misspelt cache table by message The debug form of a report carries a backtrace where the test runner asks for one, as CI does, and the deploy check and startup raise the refusal on different stacks, so the comparison held only while both were inlined alike. The message is what the test is about. --- crates/trusted-server-adapter-axum/tests/seam_probe.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 80492f8fe3..e132bfbb87 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -532,7 +532,9 @@ async fn a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_ .err() .expect("startup should refuse the misspelt table"); - let message = format!("{deploy:?}"); + // The message alone: the debug form carries a backtrace where the + // runner asks for one, and the two are raised on different stacks. + let message = format!("{deploy}"); assert!( message.contains("[cache.seam-probe-answres]"), "should name the table: {message}" @@ -543,7 +545,7 @@ async fn a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_ ); assert_eq!( message, - format!("{startup:?}"), + format!("{startup}"), "the deploy check should refuse what startup refuses" ); } From 6fe25f715fc34f7b6c3d7c84f562c263eb17f8ee Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 14:09:17 +0100 Subject: [PATCH 05/21] The cache check's test compares the two refusals of a misspelt table by message As with the probe's test: the debug form of a report carries a backtrace where the test runner asks for one, and the deploy check and the cache build raise the refusal on different stacks. --- crates/trusted-server-core/src/integrations/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 4257adc7d4..78068f906d 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -1138,7 +1138,13 @@ mod loading_caches_from_tests { .expect_err("the check refuses a misspelt cache name without making a cache"); let built = loading_caches_from(&settings, &builders).expect_err("as the build does"); - assert_eq!(format!("{refused:?}"), format!("{built:?}")); + // The message alone: the debug form carries a backtrace where the + // runner asks for one, and the two are raised on different stacks. + assert_eq!( + format!("{refused}"), + format!("{built}"), + "the deploy check should refuse what the cache build refuses" + ); check_cache_tables(&settings_declaring("answers", Some("one")), &builders) .expect("a declared cache passes the check"); } From 0ef3ff78d6a694dab651448f6055783095de324b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 18:24:42 +0100 Subject: [PATCH 06/21] The module cache declarations design Series 32 (IABTechLab/trusted-server#1373, for issue #1320) had a pull request and an issue but no design document. This one describes what the code does, adapter by adapter, with the configuration and its validation, the consent and privacy position, the failure handling, the rollout for existing deployments and the tests that exist, and it says plainly which joins have no test. --- ...-10-15-module-cache-declarations-design.md | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md new file mode 100644 index 0000000000..30e6a98e28 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -0,0 +1,234 @@ +# Design Spec: A module declares the caches it loads through + +**Status:** Proposed, 2026-10-15. Written after the code, for draft pull request IABTechLab/trusted-server#1373. Every design document of the stack is in IABTechLab/trusted-server#1342. +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-10-12-loading-cache-design.md`, `2026-10-12-robots-txt-from-modules-design.md`, `2026-08-27-integration-provider-seam-design.md` +**Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 +**Issues:** IABTechLab/trusted-server#1320 +**Last updated:** 2026-10-15 + +> **Why this spec exists.** A module that holds its own answers asks core for the cache of the name it chose, finds no `[cache.]` table for that name, and gets the cache that holds nothing, so every request loads the answer again. + +## 1. Problem + +A feature that holds answers in a loading cache selects the module that holds them in its own `[cache.]` table, as `2026-10-12-loading-cache-design.md` describes. Before this change core read one such table. `CacheSettings::loading_caches` in `crates/trusted-server-core/src/settings.rs` returned an array of one entry, `robots-txt`, and `CacheSettings` refused every other key under `[cache]`. + +A module outside core that holds its own answers, such as one service answer that every page of a deployment asks for, therefore had no table. The module asks `RuntimeServices::loading_cache_for` for the cache of the name it chose. `LoadingCaches::for_feature` in `crates/trusted-server-core/src/platform/loading_cache.rs` has no entry for that name and answers `NoLoadingCache`, which holds nothing and runs every load. So every request loaded the answer again. One deployment measured six service calls for each page request where one was needed, with a loading cache module linked. + +Two things were missing. A deployment had no place in its document to select a cache for the module's answers. Core also had no way to learn the module's cache names, because core keeps every vendor's names out of its code and so cannot list the module's cache names itself. + +## 2. Design + +A builder declares the names of the caches its module loads through. Core reads a `[cache.]` table for each declared name, with the same four settings as `[cache.robots-txt]`, and refuses a table whose name no linked module declares. No adapter source changes, because every adapter already passes its builders to `loading_caches_from`. + +### 2.1 A builder declares its caches + +```rust +impl IntegrationBuilder { + pub const fn with_cache_features(mut self, features: &'static [&'static str]) -> Self; + pub const fn cache_features(&self) -> &'static [&'static str]; +} +``` + +Both methods are in `crates/trusted-server-core/src/integrations/mod.rs`. Both constructors of `IntegrationBuilder` start with an empty list, so a builder that declares nothing behaves as before. A fictional module from `crates/geo/example` declares its cache like this. + +```rust +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::implementations("geo.example", SOURCE) + .with_cache_features(&["example-answers"]) +} +``` + +A declared name is the cache's name wherever the cache is used. The name is the `` of the `[cache.]` table. `RuntimeServices::load_cached` finds a cache by the `name` of the `CacheSpec` it is given, so a module that gives its `CacheSpec` the declared name is held by the cache that table selected. The name is also the middle part of every `StoreKey` the module builds with that name. Such a key reads `//`. + +`declared_caches(builders)` returns the names a deployment may hold answers in, being core's own `robots-txt` first and then each builder's names in builder order. `declared_caches` refuses two cases, and each refusal names the builders. + +1. A builder declares `robots-txt`, which is core's own. +2. Two builders declare one name. The two modules would otherwise hold their answers in one cache under one set of keys without a word. + +`declared_caches` compares a name only with the names already declared. The message of each refusal asks a module to name its caches under itself, as modules are named, but the check does not look at the spelling of a name or at whether the name starts with the module's own name. + +### 2.2 A deployment selects each cache in its own table + +`CacheSettings` gains `declared`, a `BTreeMap` read with `#[serde(flatten)]`. Every key under `[cache]` other than `asset_rules` and `robots-txt` is read as a table of the four `LoadingCacheConfig` settings. `CacheSettings::loading_caches` returns a `Vec` of name and settings pairs, being `robots-txt` first, always, and then the declared tables in name order. `loading_caches` returned an array of one entry before. + +`LoadingCaches` is keyed by `String`, and `LoadingCaches::insert` takes any `&str` where it took a `&'static str`. `for_feature` and `RuntimeServices::loading_cache_for` keep their signatures, and a name with no entry still answers `NoLoadingCache`. + +`loading_caches_from(settings, builders)` makes the caches. The function applies one rule to each table, `robots-txt` and declared tables alike. + +1. A `module` that names a loading cache module selects that module. +2. No `module` selects the one loading cache module the build links, or nothing when the build links none or several. +3. A module's cache is made once however many tables select it. + +The new step comes after the tables. A declared cache that has no table at all follows the rule for a table with no `module`, and shares the cache already made for that module when a table made one. A stock build therefore holds a module's answers without a line in the document. The table below shows the result for a declared cache named `answers`. + +| The document | Loading cache modules linked | The `answers` cache | +| ---------------------------------- | ---------------------------- | ----------------------------------------------- | +| No `[cache.answers]` table | One | Held by that module | +| No `[cache.answers]` table | None, or two or more | Holds nothing, and loads every time | +| `[cache.answers]` with no `module` | One | Held by that module | +| `[cache.answers]` with no `module` | None, or two or more | Holds nothing, and loads every time | +| `[cache.answers] module = "x"` | `x` is among them | Held by `x` | +| `[cache.answers] module = "x"` | `x` is not among them | Refused when the state is built | +| `[cache.answres]`, not declared | Any | Refused by the deploy check and the state build | + +```toml +[cache.example-answers] +module = "51degrees" +store_seconds = 3600 +``` + +### 2.3 Configuration, defaults and validation + +Each `[cache.]` table takes the four settings that `[cache.robots-txt]` takes. + +| Setting | Default | Range | Meaning | +| -------------------- | ------------------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `module` | Absent | The short name or the full name of a loading cache module | The module whose loading cache holds the copies. Absent, the rule in 2.2 applies. | +| `memory_seconds` | The declaring module's default | 0 to 31536000 | How long the brief copy in process memory is used. Zero keeps none. | +| `store_seconds` | The declaring module's default | 1 to 31536000 | The longest a copy in the shared store is used. | +| `store_idle_seconds` | The declaring module's default | 1 to 31536000 | How long a copy in the shared store lasts unused. | + +A module supplies its defaults when it calls `LoadingCacheConfig::lifetimes`. For `[cache.robots-txt]` the three defaults are 60, 604800 and 2592000 seconds, as `docs/guide/configuration.md` lists. This change defines no default for a declared cache, because no module in this repository loads through one. + +Validation is in three places. + +1. `LoadingCacheConfig` keeps `#[serde(deny_unknown_fields)]`, so a table with a setting other than the four is refused when the document is parsed. +2. `CacheSettings::prepare_runtime` runs `LoadingCacheConfig::check` on every declared table, as it does for `cache.robots-txt`. A value out of range is refused naming the table, as in `[cache.answers] store_seconds is 0, and must be from 1 to 31536000`. Loading the document and the deploy check both reach `prepare_runtime`. +3. The name of each table is checked against the declared names, see 2.4. + +`CacheSettings` no longer carries `#[serde(deny_unknown_fields)]`, because a key under `[cache]` is now read as a declared table. The refusal of an unknown name moves from parsing to `check_cache_tables`. + +### 2.4 Where the names are checked + +`check_cache_tables(settings, builders)` calls `declared_caches` and then refuses the first table, in the order `loading_caches` lists them, whose name is not declared. The message names the table and lists the declared names, core's own first. + +```text +[cache.answres] names a cache no linked module loads through. The caches this build's modules declare are: robots-txt, answers +``` + +The check runs in two places. + +1. **The deploy check.** `validate_settings_for_deploy_with` in `crates/trusted-server-core/src/config.rs` runs the check after `prepare_runtime`, over core's built-in builders followed by the builders the caller passes. `TrustedServerAppConfig::validate` passes the builders a tool registered with `register_deploy_integrations`. A misspelled table therefore fails `ts config validate` and not the deployment. The `ts` command registers the stock modules through `register_stock_modules` in `crates/trusted-server-cli/src/app_config.rs`, so the command knows the caches the stock modules declare and no others. A deployment whose own module declares a cache validates with a tool that registers that module's builder, as the deployment already must for the module's other settings. `validate_settings_for_deploy` passes no builders, so the function refuses every table except `robots-txt`. +2. **The state build.** `loading_caches_from` runs the check first, so a service started with a misspelled table fails when its state is built and not when a request first needs the cache. + +The two lists of builders differ in one respect. The deploy check chains core's built-in builders (`all_builders`) ahead of the registered ones. Each adapter passes `trusted_server_modules::builders_with(integrations)` to `loading_caches_from` and does not chain core's built-in list. The two lists of declared names agree today, because the one built-in builder outside core's tests, `js_asset_proxy`, declares no cache. A built-in builder that did declare one would be seen by the deploy check and not by the adapters. + +### 2.5 The adapters + +All four adapters make the same call in the function that builds their application state, and none of their source changed. + +```rust +let loading_caches = + trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; +``` + +- **Fastly.** `build_state_with_registrations` in `crates/trusted-server-adapter-fastly/src/app.rs` makes the call and keeps the result in `AppState::loading_caches`. The adapter hands the result to the `RuntimeServices` builder of each request and to `build_finalize_services`. +- **Axum.** `build_state_with_registrations_and_services` in `crates/trusted-server-adapter-axum/src/app.rs` makes the call and keeps the result in `AppState::loading_caches`. `services_for_request` adds the result to each request's services with `with_loading_caches`. +- **Cloudflare.** `build_state_with_registrations_and_services` in `crates/trusted-server-adapter-cloudflare/src/app.rs` does the same as the Axum function. +- **Spin.** `build_state_with_registrations_and_services` in `crates/trusted-server-adapter-spin/src/app.rs` does the same as the Axum function. + +Each adapter passes `trusted_server_modules::builders_with(integrations)`, being the stock modules followed by the deployment's own, so a deployment's own module can declare a cache. The Axum, Cloudflare and Spin adapters accept `RuntimeServices` from the caller. Where a caller supplies `RuntimeServices`, those services carry their own loading caches and `services_for_request` does not add the state's. The Fastly adapter builds its services itself and has no such parameter. + +The adapters differ in how a failed state build answers, see 2.7. Only the Axum adapter has a test that asserts on this change, see section 4. + +### 2.6 Consent and privacy + +This change stores nothing new and reads no request data. The change decides which loading cache module holds a module's copies and how long a copy lasts. Five facts follow. + +1. **What core knows about a copy.** Core sees a cache's name, its lifetimes and the key a module builds with `StoreKey::new(prefix, cache, key)`. Core does not read a key or a value, and the change adds no consent check on this path. A module that holds an answer which depends on the visitor has to put what the answer depends on into its key, or not hold the answer. The change gives the module a cache and does not settle that question for the module. +2. **Who can read a copy.** A key starts with the publisher's key prefix and the cache's name, so two publishers sharing a store never share a copy. Because a declared name cannot repeat or be `robots-txt`, two modules of one publisher never share a copy either. Within one publisher, the copy in the shared store is the one every instance reads. +3. **How long a copy lasts.** Each declared table sets its own lifetimes, so a deployment can shorten them. The shortest the document can ask for is no copy in process memory (`memory_seconds = 0`) and one second in the shared store (`store_seconds = 1`). +4. **Whether the document can turn a cache off.** The document cannot turn a declared cache off in a build that links one loading cache module. Such a build holds a declared cache's answers whether or not the document has a table, and a table naming `none`, or any other name that no linked crate offers a cache from, is refused when the state is built. +5. **What changes today.** No module that ships declares a cache, so no deployment holds a new kind of answer because of this change. + +### 2.7 Failure handling + +| What goes wrong | Refused by | What the operator sees | +| ------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A table names a cache no linked module declares | The deploy check and the state build | The message in 2.4, naming the first such table | +| A builder declares `robots-txt` | The same two, with or without a table | A message saying the builder declares the cache `robots-txt`, which is core's own, and naming the builder | +| Two builders declare one name | The same two, with or without a table | A message naming both builders and the cache, so linking the two modules fails every deployment that does | +| A table's `module` names a crate that offers no loading cache | The state build only | A message naming the table and the module written, saying no linked crate offers a loading cache from it, and listing the loading cache modules the build has | +| A lifetime out of range | Loading the document and the deploy check | `[cache.answers] store_seconds is 0, and must be from 1 to 31536000` | +| A declared cache with no table, and no or several loading cache modules linked | Nothing | The cache holds nothing and loads every time, with no log line | +| A module loads through a name it did not declare | Nothing | `LoadingCaches::for_feature` answers `NoLoadingCache`, with no error | + +When the state build fails, each adapter logs the full report and serves its startup error router. The Fastly, Axum and Cloudflare routers answer every path for GET, POST, HEAD, OPTIONS, PUT, PATCH and DELETE with the status and body that `TrustedServerError::Configuration` gives, being 500 and `An internal error occurred`. The Spin router answers every path with 503 and `Service Unavailable`, and still answers `/health` with 200. The names in the message reach the log and the deploy check's output, and never a response. + +A load that fails is handled as `2026-10-12-loading-cache-design.md` describes. Every request waiting on the load receives the error, and nothing is stored. + +### 2.8 Rolling out to an existing deployment + +1. **No document has to change.** `[cache.robots-txt]` is untouched and no other table is needed. A build that links one loading cache module holds a declared cache's answers with no table. +2. **No running deployment gains a cache.** No module that ships declares one, so a deployment holds a new kind of answer only when it links a module that declares a cache. +3. **A document with another `[cache.]` table did not load before.** `CacheSettings` refused the key at parse time, so no running deployment has one. Now the parse accepts the table, and `check_cache_tables` refuses the table at the deploy check and when the state is built unless a linked module declares the name. A tool that parses a document without running the deploy check no longer refuses the table. +4. **A module that adds a declaration changes nothing until a deployment links the module.** A deployment then writes a table only to choose a loading cache module other than the one linked, to set lifetimes other than the module's defaults, or because its build links two or more loading cache modules. +5. **Crates outside core meet three changes.** `CacheSettings::loading_caches` returns a `Vec` where it returned an array, `LoadingCaches::insert` takes `&str` where it took `&'static str`, and `CacheSettings` has a public `declared` field, so code that builds the struct by listing every field needs the new field. No code in this repository builds the struct that way. The changelog entry under Changed names the three and does not mark them as breaking. +6. **The documentation.** `docs/guide/configuration.md` describes the declaration under the loading cache settings, and the guide's example module is fictional. + +### 2.9 The seam probe + +The seam probe, `crates/testing/seam-probe`, is a fixture crate that uses the extension points core offers to modules, so that tests can drive those extension points from outside core. The stock list does not include the probe. This change gives the probe four things. + +1. `builder()` declares `seam-probe-answers`, the constant `SEAM_PROBE_ANSWERS_CACHE`. +2. `loading_cache_builder()` offers a loading cache module, `loading-cache.seam-probe` (`SEAM_PROBE_LOADING_CACHE_ID`), whose `MemoryLoadingCache` keeps every value in process memory until the process ends. +3. The report route's JSON gains `answers_cache_keeps_values`, read with `services.loading_cache_for(SEAM_PROBE_ANSWERS_CACHE).keeps_values()`. +4. The crate depends on `bytes`, in its `Cargo.toml` and in `Cargo.lock`. + +The probe asks only whether the cache keeps values. The probe never loads a value through the cache. + +## 3. What is not in this change + +- **A module that loads through a declared cache.** No module in this repository does. The probe declares one and reports whether the cache keeps values. Moving a module's own lookups onto its declared cache is each module's change. +- **A way to hold nothing in a build that links one loading cache module.** A table names a module that offers a cache, and a table naming anything else, `none` included, is refused when the state is built. +- **A helper that hands a module its own table's settings.** `CacheSettings::declared` is public and `LoadingCacheConfig::lifetimes` takes the module's defaults, but the only caller of `lifetimes` is the robots.txt cache in `crates/trusted-server-core/src/robots_txt/holding.rs`. +- **A check that a module calls only the names it declared.** `LoadingCaches::for_feature` answers `NoLoadingCache` for any name without an entry. +- **A check of a declared name's spelling,** or of whether the name starts with the module's own name. +- **A warning when a declared cache holds nothing** because the build links several loading cache modules and no table names one. +- **A `module` check in the deploy check.** A table whose `module` names a crate that offers no loading cache is still refused only when the state is built. +- **A row in the integration guide.** The table of builder methods in `docs/guide/integration-guide.md` lists `.with_loading_cache(...)` and does not list `.with_cache_features(...)`. + +## 4. Acceptance + +These tests exist. + +In `crates/trusted-server-core/src/integrations/mod.rs`, module `loading_caches_from_tests`: + +- `a_cache_a_module_declares_selects_a_module_as_core_s_own_does` shows a declared table naming a module selecting that module, and `robots-txt` with no table taking the one module offered. +- `a_declared_cache_with_no_table_takes_the_one_module_offered` shows a declared cache with no table taking the one loading cache module. +- `a_declared_cache_with_no_table_keeps_nothing_when_two_are_offered` shows the declared cache keeping nothing, and `LoadingCaches::is_empty` true, when two modules are offered and no table names one. +- `a_cache_no_module_declares_is_refused_naming_the_declared_ones` shows the state build refusing `[cache.answres]` and listing `robots-txt, answers`. +- `the_deploy_check_refuses_the_table_the_cache_build_refuses` shows `check_cache_tables` and `loading_caches_from` giving the same message, and a declared name passing the check. +- `two_builders_declaring_one_cache_are_refused_naming_both` shows two builders declaring `answers` refused with no table in the document, and both named. +- `a_builder_declaring_core_s_own_cache_is_refused` shows a builder declaring `robots-txt` refused, with a message that calls the name core's own. + +In `crates/trusted-server-core/src/settings.rs`: + +- `a_cache_a_module_declares_is_read_from_its_own_table` shows `[cache.answers]` read with its `module` and `store_seconds`, listed second by `loading_caches`, and written back with its `module`. +- `a_loading_cache_module_is_read_and_written_back` is an earlier test, changed only to compare with a `Vec`. + +In `crates/trusted-server-adapter-axum/tests/seam_probe.rs`, through the Axum router: + +- `a_cache_the_probe_declares_is_selected_in_its_own_table` shows three cases. A table naming `seam-probe` makes the cache keep values. Two loading cache modules and no table make the cache keep nothing. The stock loading cache module alone, with no table, is taken and keeps values. +- `a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_startup` shows `validate_settings_for_deploy_with` and `routes_with_registrations` refusing a misspelled table with the same message, listing `robots-txt, seam-probe-answers`. + +These joins have no test. + +1. **The Fastly, Cloudflare and Spin adapters.** No test in them writes a `[cache.]` table for a declared cache or asserts on one. The Fastly adapter's probe tests in `crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs` now build their state with a builder that declares a cache, so the declaration check runs there, but no assertion in them is about caches. The Cloudflare and Spin test files do not use the probe at all. +2. **`ts config validate` and `TrustedServerAppConfig::validate`.** The Axum test calls `validate_settings_for_deploy_with`, the function that `ts config validate` and `TrustedServerAppConfig::validate` both reach. No test registers a builder that declares a cache through `register_deploy_integrations`. +3. **A range error on a declared table.** The range test covers `cache.robots-txt` only, so the loop over `CacheSettings::declared` in `prepare_runtime` has no test. +4. **A declared table's lifetimes holding a copy.** No module loads through a declared cache, so no test shows `memory_seconds`, `store_seconds` or `store_idle_seconds` of a declared table reaching a cache. +5. **The stock loading cache module selected for a declared cache by name.** No test in `crates/loading-cache/51degrees` or `crates/trusted-server-modules` does this. The stock module is reached for a declared cache only as the one module linked, in the third case of the Axum test. +6. **The deploy check's builder list against the adapters'.** No test covers a built-in builder that declares a cache. Only the deploy check would see that cache. +7. **A module loading through a name it did not declare.** Nothing refuses the call and no test shows what happens. + +## Review log + +No review rounds yet. + +## Revision record + +| Date | Change | +| ---------- | ------------------------------------ | +| 2026-10-15 | First draft, written after the code. | From be4f47842a89e902882d2f460bc1ca125f545bbd Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 15 Oct 2026 19:32:46 +0100 Subject: [PATCH 07/21] The module cache declarations design takes its round 1 review Review round 1 of the design document found three blocking points. A declared table's lifetimes are range-checked and then applied by nothing, two modules of one publisher keep their copies apart only by a naming convention that nothing checks, and the changelog entry left out the public declared field and the Vec return. The document now states each as the code stands, adds the order of a push, the other callers of loading_caches_from, the name forms core does not check, the paths with no test and four open questions, and drops the service call figure, which has no source. The changelog entry now names the declared field and the Vec return. --- CHANGELOG.md | 2 +- ...-10-15-module-cache-declarations-design.md | 101 ++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce35449e4..8f9d6e12fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- A module declares the caches it loads through, by name, and a deployment selects a loading cache for each in its own `[cache.]` table, the way `[cache.robots-txt]` selects one for robots.txt. A `[cache.]` table naming a cache no linked module declares is refused by `ts config validate` and when the deployment starts, naming the ones it does, and a module declaring `robots-txt` or a name another module declares is refused. `IntegrationBuilder::with_cache_features` declares them, `CacheSettings::loading_caches` lists core's own and the declared ones, and `LoadingCaches::insert` takes any name. +- A module declares the caches it loads through, by name, and a deployment selects a loading cache for each in its own `[cache.]` table, the way `[cache.robots-txt]` selects one for robots.txt. A `[cache.]` table naming a cache no linked module declares is refused by `ts config validate` and when the deployment starts, naming the ones it does, and a module declaring `robots-txt` or a name another module declares is refused. `IntegrationBuilder::with_cache_features` declares them, `CacheSettings` has a new public field `declared`, `CacheSettings::loading_caches` returns a `Vec` in place of an array, listing core's own and the declared ones, and `LoadingCaches::insert` takes any name. - **Breaking for crates outside core:** the cookie constants `COOKIE_TS_EC`, `COOKIE_TS_EC_RESOLVED`, `COOKIE_TS_EC_PULL_COMPLETE`, `COOKIE_TS_EIDS` and `COOKIE_TS_TESTER` are `COOKIE_EC`, `COOKIE_EC_RESOLVED`, `COOKIE_EC_PULL_COMPLETE`, `COOKIE_EIDS` and `COOKIE_TESTER`, holding the entry's name alone, and a cookie is named through the request's public prefix with `services.public_prefix().cookie_name(COOKIE_EC)`. A module's response header naming a cookie under the public prefix is refused as one under `ts-` was. - A robots.txt module's answer is held by the deployment's loading cache, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. - A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index 30e6a98e28..f9af5836f1 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -13,13 +13,15 @@ A feature that holds answers in a loading cache selects the module that holds them in its own `[cache.]` table, as `2026-10-12-loading-cache-design.md` describes. Before this change core read one such table. `CacheSettings::loading_caches` in `crates/trusted-server-core/src/settings.rs` returned an array of one entry, `robots-txt`, and `CacheSettings` refused every other key under `[cache]`. -A module outside core that holds its own answers, such as one service answer that every page of a deployment asks for, therefore had no table. The module asks `RuntimeServices::loading_cache_for` for the cache of the name it chose. `LoadingCaches::for_feature` in `crates/trusted-server-core/src/platform/loading_cache.rs` has no entry for that name and answers `NoLoadingCache`, which holds nothing and runs every load. So every request loaded the answer again. One deployment measured six service calls for each page request where one was needed, with a loading cache module linked. +A module outside core that holds its own answers, such as one service answer that every page of a deployment asks for, therefore had no table. The module asks `RuntimeServices::loading_cache_for` for the cache of the name it chose. `LoadingCaches::for_feature` in `crates/trusted-server-core/src/platform/loading_cache.rs` has no entry for that name and answers `NoLoadingCache`, which holds nothing and runs every load. So every request loaded the answer again, with a loading cache module linked or not. Two things were missing. A deployment had no place in its document to select a cache for the module's answers. Core also had no way to learn the module's cache names, because core keeps every vendor's names out of its code and so cannot list the module's cache names itself. +IABTechLab/trusted-server#1320 asks for both. A module declares the names of the caches it loads through on its builder. Core reads a `[cache.]` table for each declared name as it reads its own, and refuses at startup a table whose name no linked module declares, naming the ones it could be. The selection stays per table, and a table naming no module behaves as core's own does. + ## 2. Design -A builder declares the names of the caches its module loads through. Core reads a `[cache.]` table for each declared name, with the same four settings as `[cache.robots-txt]`, and refuses a table whose name no linked module declares. No adapter source changes, because every adapter already passes its builders to `loading_caches_from`. +A builder declares the names of the caches its module loads through. Core reads a `[cache.]` table for each declared name, with the same four settings as `[cache.robots-txt]`, and refuses a table whose name no linked module declares. Core acts on a declared table's `module` and checks the range of its three lifetimes, and applies the lifetimes only for `[cache.robots-txt]`, see 2.3. No adapter source changes, because every adapter already passes its builders to `loading_caches_from`. ### 2.1 A builder declares its caches @@ -39,14 +41,14 @@ pub fn builder() -> IntegrationBuilder { } ``` -A declared name is the cache's name wherever the cache is used. The name is the `` of the `[cache.]` table. `RuntimeServices::load_cached` finds a cache by the `name` of the `CacheSpec` it is given, so a module that gives its `CacheSpec` the declared name is held by the cache that table selected. The name is also the middle part of every `StoreKey` the module builds with that name. Such a key reads `//`. +A declared name is the cache's name wherever the cache is used. The name is the `` of the `[cache.]` table. `RuntimeServices::load_cached` finds a cache by the `name` of the `CacheSpec` it is given, so a module that gives its `CacheSpec` the declared name is held by the cache that table selected. The name is also the middle part of every `StoreKey` the module builds with that name. Such a key reads `//`. Core does not check that a module gives its `CacheSpec` or its `StoreKey` a name it declared, see 2.6. `declared_caches(builders)` returns the names a deployment may hold answers in, being core's own `robots-txt` first and then each builder's names in builder order. `declared_caches` refuses two cases, and each refusal names the builders. 1. A builder declares `robots-txt`, which is core's own. 2. Two builders declare one name. The two modules would otherwise hold their answers in one cache under one set of keys without a word. -`declared_caches` compares a name only with the names already declared. The message of each refusal asks a module to name its caches under itself, as modules are named, but the check does not look at the spelling of a name or at whether the name starts with the module's own name. +`declared_caches` compares a name only with the names already declared. The message of each refusal asks a module to name its caches under itself, as modules are named. The doc comment of `with_cache_features` and the seam probe show the form, being the module's short name and then what the cache holds, as in `example-answers` for a module from `crates/geo/example` and `seam-probe-answers` for the probe. The form is a convention. The check does not look at the spelling of a name or at whether the name starts with the module's own name, see 3. ### 2.2 A deployment selects each cache in its own table @@ -80,17 +82,19 @@ store_seconds = 3600 ### 2.3 Configuration, defaults and validation -Each `[cache.]` table takes the four settings that `[cache.robots-txt]` takes. +Each `[cache.]` table takes the four settings that `[cache.robots-txt]` takes. Core acts on `module` for every table, checks the range of the three lifetimes for every table, and applies the lifetimes only for `[cache.robots-txt]`, see below the table. -| Setting | Default | Range | Meaning | -| -------------------- | ------------------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `module` | Absent | The short name or the full name of a loading cache module | The module whose loading cache holds the copies. Absent, the rule in 2.2 applies. | -| `memory_seconds` | The declaring module's default | 0 to 31536000 | How long the brief copy in process memory is used. Zero keeps none. | -| `store_seconds` | The declaring module's default | 1 to 31536000 | The longest a copy in the shared store is used. | -| `store_idle_seconds` | The declaring module's default | 1 to 31536000 | How long a copy in the shared store lasts unused. | +| Setting | Default | Range | Meaning | +| -------------------- | ------------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `module` | Absent | The short name or the full name of a loading cache module | The module whose loading cache holds the copies. Absent, the rule in 2.2 applies. | +| `memory_seconds` | The module's own default | 0 to 31536000 | How long the brief copy in process memory is used. Zero keeps none. | +| `store_seconds` | The module's own default | 1 to 31536000 | The longest a copy in the shared store is used. | +| `store_idle_seconds` | The module's own default | 1 to 31536000 | How long a copy in the shared store lasts unused. | A module supplies its defaults when it calls `LoadingCacheConfig::lifetimes`. For `[cache.robots-txt]` the three defaults are 60, 604800 and 2592000 seconds, as `docs/guide/configuration.md` lists. This change defines no default for a declared cache, because no module in this repository loads through one. +**Core does not apply a declared table's lifetimes.** `loading_caches_from` reads only the `module` of each table, and the function that makes a module's cache (`LoadingCacheFactory`) takes no arguments. A `CacheSpec` carries the lifetimes its module gives it, and `RuntimeServices::load_cached` passes the `CacheSpec` to the cache as the module made it. Outside tests, the only code that calls `LoadingCacheConfig::lifetimes` is the robots.txt cache, for `[cache.robots-txt]`. So `memory_seconds`, `store_seconds` and `store_idle_seconds` in a declared table are range-checked and then used by nothing, unless a module reads `settings.cache.declared` and applies them to its own `CacheSpec`. No module in this repository does. Writing a lifetime in a declared table therefore changes nothing until a module reads the table, see 2.10. + Validation is in three places. 1. `LoadingCacheConfig` keeps `#[serde(deny_unknown_fields)]`, so a table with a setting other than the four is refused when the document is parsed. @@ -116,7 +120,7 @@ The two lists of builders differ in one respect. The deploy check chains core's ### 2.5 The adapters -All four adapters make the same call in the function that builds their application state, and none of their source changed. +All four adapters make the same call in the function that builds their application state, and none of their source changed. Apart from the four adapters, only tests call `loading_caches_from`, in core, in `crates/loading-cache/51degrees` and in `crates/trusted-server-modules`, so no other runtime makes the call. ```rust let loading_caches = @@ -134,25 +138,25 @@ The adapters differ in how a failed state build answers, see 2.7. Only the Axum ### 2.6 Consent and privacy -This change stores nothing new and reads no request data. The change decides which loading cache module holds a module's copies and how long a copy lasts. Five facts follow. +This change stores nothing new and reads no request data. The change decides which loading cache module holds a module's copies. Five facts follow. 1. **What core knows about a copy.** Core sees a cache's name, its lifetimes and the key a module builds with `StoreKey::new(prefix, cache, key)`. Core does not read a key or a value, and the change adds no consent check on this path. A module that holds an answer which depends on the visitor has to put what the answer depends on into its key, or not hold the answer. The change gives the module a cache and does not settle that question for the module. -2. **Who can read a copy.** A key starts with the publisher's key prefix and the cache's name, so two publishers sharing a store never share a copy. Because a declared name cannot repeat or be `robots-txt`, two modules of one publisher never share a copy either. Within one publisher, the copy in the shared store is the one every instance reads. -3. **How long a copy lasts.** Each declared table sets its own lifetimes, so a deployment can shorten them. The shortest the document can ask for is no copy in process memory (`memory_seconds = 0`) and one second in the shared store (`store_seconds = 1`). -4. **Whether the document can turn a cache off.** The document cannot turn a declared cache off in a build that links one loading cache module. Such a build holds a declared cache's answers whether or not the document has a table, and a table naming `none`, or any other name that no linked crate offers a cache from, is refused when the state is built. +2. **Who can read a copy.** A key starts with the publisher's key prefix and the cache's name, so two publishers sharing a store never share a copy. Within one publisher, the copy in the shared store is the one every instance reads. Two modules of one publisher keep their copies apart by convention and not by a check. Two builders cannot declare one name, and a module that gives its `CacheSpec` and its `StoreKey` the name it declared holds its copies apart from every other module's. Core does not check that a module does so, because `CacheSpec.name` is any `&'static str`, `StoreKey::new` takes any string for the cache, and `load_cached` picks the cache by the name in the `CacheSpec` it is given. A module that loads through another module's declared name, or through `robots-txt`, shares that cache and keys that start the same way, see 2.10. +3. **How long a copy lasts.** A module's `CacheSpec` sets how long its copies last, and core applies no declared table's lifetimes, see 2.3. Where a module applies its table's lifetimes itself, the shortest the range check allows is no copy in process memory (`memory_seconds = 0`) and one second in the shared store (`store_seconds = 1`). +4. **Whether the document can turn a cache off.** The document cannot turn a declared cache off in a build that links one loading cache module. Such a build holds a declared cache's answers whether or not the document has a table, and a table naming `none`, or any other name that no linked crate offers a cache from, is refused when the state is built. Core already does the same for `[cache.robots-txt]`. Reviewers are asked to confirm that a declared cache has no off switch, see 2.10. 5. **What changes today.** No module that ships declares a cache, so no deployment holds a new kind of answer because of this change. ### 2.7 Failure handling -| What goes wrong | Refused by | What the operator sees | -| ------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A table names a cache no linked module declares | The deploy check and the state build | The message in 2.4, naming the first such table | -| A builder declares `robots-txt` | The same two, with or without a table | A message saying the builder declares the cache `robots-txt`, which is core's own, and naming the builder | -| Two builders declare one name | The same two, with or without a table | A message naming both builders and the cache, so linking the two modules fails every deployment that does | -| A table's `module` names a crate that offers no loading cache | The state build only | A message naming the table and the module written, saying no linked crate offers a loading cache from it, and listing the loading cache modules the build has | -| A lifetime out of range | Loading the document and the deploy check | `[cache.answers] store_seconds is 0, and must be from 1 to 31536000` | -| A declared cache with no table, and no or several loading cache modules linked | Nothing | The cache holds nothing and loads every time, with no log line | -| A module loads through a name it did not declare | Nothing | `LoadingCaches::for_feature` answers `NoLoadingCache`, with no error | +| What goes wrong | Refused by | What the operator sees | +| ------------------------------------------------------------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A table names a cache no linked module declares | The deploy check and the state build | The message in 2.4, naming the first such table | +| A builder declares `robots-txt` | The same two, with or without a table | A message saying the builder declares the cache `robots-txt`, which is core's own, and naming the builder | +| Two builders declare one name | The same two, with or without a table | A message naming both builders and the cache, so linking the two modules fails every deployment that does | +| A table's `module` names a crate that offers no loading cache | The state build only | A message naming the table and the module written, saying no linked crate offers a loading cache from it, and listing the loading cache modules the build has | +| A lifetime out of range | Loading the document and the deploy check | `[cache.answers] store_seconds is 0, and must be from 1 to 31536000` | +| A declared cache with no table, and no or several loading cache modules linked | Nothing | The cache holds nothing and loads every time, with no log line | +| A module loads through a name it did not declare | Nothing | `LoadingCaches::for_feature` answers `NoLoadingCache` for a name no one declared, and the cache selected for the name where another module or core declared it, with no error either way | When the state build fails, each adapter logs the full report and serves its startup error router. The Fastly, Axum and Cloudflare routers answer every path for GET, POST, HEAD, OPTIONS, PUT, PATCH and DELETE with the status and body that `TrustedServerError::Configuration` gives, being 500 and `An internal error occurred`. The Spin router answers every path with 503 and `Service Unavailable`, and still answers `/health` with 200. The names in the message reach the log and the deploy check's output, and never a response. @@ -162,9 +166,9 @@ A load that fails is handled as `2026-10-12-loading-cache-design.md` describes. 1. **No document has to change.** `[cache.robots-txt]` is untouched and no other table is needed. A build that links one loading cache module holds a declared cache's answers with no table. 2. **No running deployment gains a cache.** No module that ships declares one, so a deployment holds a new kind of answer only when it links a module that declares a cache. -3. **A document with another `[cache.]` table did not load before.** `CacheSettings` refused the key at parse time, so no running deployment has one. Now the parse accepts the table, and `check_cache_tables` refuses the table at the deploy check and when the state is built unless a linked module declares the name. A tool that parses a document without running the deploy check no longer refuses the table. -4. **A module that adds a declaration changes nothing until a deployment links the module.** A deployment then writes a table only to choose a loading cache module other than the one linked, to set lifetimes other than the module's defaults, or because its build links two or more loading cache modules. -5. **Crates outside core meet three changes.** `CacheSettings::loading_caches` returns a `Vec` where it returned an array, `LoadingCaches::insert` takes `&str` where it took `&'static str`, and `CacheSettings` has a public `declared` field, so code that builds the struct by listing every field needs the new field. No code in this repository builds the struct that way. The changelog entry under Changed names the three and does not mark them as breaking. +3. **A document with another `[cache.]` table did not load before.** `CacheSettings` refused the key at parse time, so no running deployment has one. Now the parse accepts the table, and `check_cache_tables` refuses the table at the deploy check and when the state is built unless a linked module declares the name. A tool that parses a document without running the deploy check no longer refuses the table. The order of a push matters, because a binary built before this change still refuses the key. Each adapter loads the document with `Settings::from_json_value` (`crates/trusted-server-core/src/config_payload.rs`) in the function that builds its state, so a service running such a binary answers with its startup error, as 2.7 describes, whenever it loads a document that carries a `[cache.]` table. Deploy the binary with this change to every instance before pushing a document with a table, and push a document without the tables before rolling an instance back to a binary from before this change. The warning "Deploy the code before pushing the config" in `docs/guide/configuration.md` gives the same order for other settings. +4. **A module that adds a declaration changes nothing until a deployment links the module.** A deployment then writes a table only to choose a loading cache module other than the one linked, because its build links two or more loading cache modules, or, where the module reads its own table, to set lifetimes other than the module's defaults. +5. **Crates outside core meet three changes.** `CacheSettings::loading_caches` returns a `Vec` where it returned an array, `LoadingCaches::insert` takes `&str` where it took `&'static str`, and `CacheSettings` has a public `declared` field. The `Vec` stops a crate building if the crate uses the result as an array, by type, by pattern or by an array method, or keeps a name as a `&'static str`, because a declared name is borrowed from the settings. The new field stops a crate building if the crate builds `CacheSettings`, or takes `CacheSettings` apart, by listing every field. The wider parameter of `insert` stops no caller. No code in this repository uses `loading_caches()` as an array, keeps a name as a `&'static str` or lists every field of `CacheSettings`. The changelog entry under Changed names all three and does not mark them as breaking, whereas the next entry, for the cookie constants, is marked "Breaking for crates outside core" because each old constant name stops building wherever it is used. Whether to mark these three is an open question, see 2.10. 6. **The documentation.** `docs/guide/configuration.md` describes the declaration under the loading cache settings, and the guide's example module is fictional. ### 2.9 The seam probe @@ -178,16 +182,25 @@ The seam probe, `crates/testing/seam-probe`, is a fixture crate that uses the ex The probe asks only whether the cache keeps values. The probe never loads a value through the cache. +### 2.10 Open questions + +The first two questions ask for a code change and the other two for a decision. This design chooses none of the answers. + +1. Should core apply a declared table's lifetimes? Today nothing does, see 2.3. One choice is for core to lay them over the lifetimes in the `CacheSpec` a module passes to `load_cached`. Another is a helper that hands each module its table's settings (see 3) and leaves the module to apply them. A third is for a declared table to take only `module`, so that no setting is checked and then left unused. +2. Should core check the name a module loads through? Today nothing does, see 2.6. One choice is for core to refuse a name the calling module did not declare, which needs core to know which module is loading, and `load_cached` carries no module today. The other choice is to keep the convention in 2.1 and leave the names unchecked. +3. Should a deployment be able to turn a declared cache off in a build that links one loading cache module? Today a declared cache with no table is held and `module = "none"` is refused, as for `[cache.robots-txt]`, see 2.6. The other choice is to keep the behavior 2.6 describes. +4. Should the changelog entry mark the three changes in 2.8 as breaking for crates outside core? Today the entry names the three and does not mark them. The other choice is to mark them as the entry for the cookie constants is marked. + ## 3. What is not in this change - **A module that loads through a declared cache.** No module in this repository does. The probe declares one and reports whether the cache keeps values. Moving a module's own lookups onto its declared cache is each module's change. -- **A way to hold nothing in a build that links one loading cache module.** A table names a module that offers a cache, and a table naming anything else, `none` included, is refused when the state is built. -- **A helper that hands a module its own table's settings.** `CacheSettings::declared` is public and `LoadingCacheConfig::lifetimes` takes the module's defaults, but the only caller of `lifetimes` is the robots.txt cache in `crates/trusted-server-core/src/robots_txt/holding.rs`. -- **A check that a module calls only the names it declared.** `LoadingCaches::for_feature` answers `NoLoadingCache` for any name without an entry. -- **A check of a declared name's spelling,** or of whether the name starts with the module's own name. +- **A way to hold nothing in a build that links one loading cache module.** A table names a module that offers a cache, and a table naming anything else, `none` included, is refused when the state is built. See 2.10. +- **A helper that hands a module its own table's settings.** `CacheSettings::declared` is public and `LoadingCacheConfig::lifetimes` takes the module's defaults, but the only caller of `lifetimes` outside tests is the robots.txt cache in `crates/trusted-server-core/src/robots_txt/holding.rs`, so nothing applies a declared table's lifetimes, see 2.3. +- **A check that a module calls only the names it declared.** `LoadingCaches::for_feature` answers `NoLoadingCache` for a name no one declared, and the cache selected for the name where another module or core declared it, see 2.6. +- **A check of a declared name's spelling,** or of whether the name starts with the module's own name. Core reserves only `robots-txt`. `asset_rules` is read as the list of asset rules, so a builder can declare it and no table can then select a module for it. TOML reads `[cache.a.b]` as a table `b` inside a table `a`, so a name with a dot can be written only quoted, as `[cache."a.b"]`. A name with a `/` blurs where the name ends and the key begins in `//`. - **A warning when a declared cache holds nothing** because the build links several loading cache modules and no table names one. -- **A `module` check in the deploy check.** A table whose `module` names a crate that offers no loading cache is still refused only when the state is built. -- **A row in the integration guide.** The table of builder methods in `docs/guide/integration-guide.md` lists `.with_loading_cache(...)` and does not list `.with_cache_features(...)`. +- **A `module` check in the deploy check.** A table whose `module` names a crate that offers no loading cache is still refused only when the state is built, as `[cache.robots-txt] module` was before this change. `loading_caches_from` compares `module` inside the loop that makes each cache, and `check_cache_tables` runs in the deploy check to refuse a table without making a cache. A comparison of `module` with the loading cache modules the builders offer would need no input the deploy check lacks, because the deploy check is given the same builders. +- **A row in the integration guide.** The table of builder methods in `docs/guide/integration-guide.md` lists `.with_loading_cache(...)` and does not list `.with_cache_features(...)`. The section "Supplying a loading cache" in the same guide does not mention declared caches, and says a feature that selects nothing loads through `NoLoadingCache`, which is not so in a build that links one loading cache module, see 2.2. ## 4. Acceptance @@ -195,7 +208,7 @@ These tests exist. In `crates/trusted-server-core/src/integrations/mod.rs`, module `loading_caches_from_tests`: -- `a_cache_a_module_declares_selects_a_module_as_core_s_own_does` shows a declared table naming a module selecting that module, and `robots-txt` with no table taking the one module offered. +- `a_cache_a_module_declares_selects_a_module_as_core_s_own_does` shows a declared table that names the one module offered taking that module's cache, and `robots-txt` with no table taking the same module. With one module offered, a table with no `module` takes the same cache, so the test does not show that `module` is read for a declared table. - `a_declared_cache_with_no_table_takes_the_one_module_offered` shows a declared cache with no table taking the one loading cache module. - `a_declared_cache_with_no_table_keeps_nothing_when_two_are_offered` shows the declared cache keeping nothing, and `LoadingCaches::is_empty` true, when two modules are offered and no table names one. - `a_cache_no_module_declares_is_refused_naming_the_declared_ones` shows the state build refusing `[cache.answres]` and listing `robots-txt, answers`. @@ -210,7 +223,7 @@ In `crates/trusted-server-core/src/settings.rs`: In `crates/trusted-server-adapter-axum/tests/seam_probe.rs`, through the Axum router: -- `a_cache_the_probe_declares_is_selected_in_its_own_table` shows three cases. A table naming `seam-probe` makes the cache keep values. Two loading cache modules and no table make the cache keep nothing. The stock loading cache module alone, with no table, is taken and keeps values. +- `a_cache_the_probe_declares_is_selected_in_its_own_table` shows three cases. A table naming `seam-probe` makes the cache keep values, and the build links the stock loading cache module as well as the probe's, so the cache would keep nothing if the table's `module` were not read. The probe's cache and the stock cache both keep values, so the case does not show which of them the table selected. The same two modules and no table make the cache keep nothing. The stock loading cache module alone, with no table, is taken and keeps values. - `a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_startup` shows `validate_settings_for_deploy_with` and `routes_with_registrations` refusing a misspelled table with the same message, listing `robots-txt, seam-probe-answers`. These joins have no test. @@ -218,17 +231,19 @@ These joins have no test. 1. **The Fastly, Cloudflare and Spin adapters.** No test in them writes a `[cache.]` table for a declared cache or asserts on one. The Fastly adapter's probe tests in `crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs` now build their state with a builder that declares a cache, so the declaration check runs there, but no assertion in them is about caches. The Cloudflare and Spin test files do not use the probe at all. 2. **`ts config validate` and `TrustedServerAppConfig::validate`.** The Axum test calls `validate_settings_for_deploy_with`, the function that `ts config validate` and `TrustedServerAppConfig::validate` both reach. No test registers a builder that declares a cache through `register_deploy_integrations`. 3. **A range error on a declared table.** The range test covers `cache.robots-txt` only, so the loop over `CacheSettings::declared` in `prepare_runtime` has no test. -4. **A declared table's lifetimes holding a copy.** No module loads through a declared cache, so no test shows `memory_seconds`, `store_seconds` or `store_idle_seconds` of a declared table reaching a cache. -5. **The stock loading cache module selected for a declared cache by name.** No test in `crates/loading-cache/51degrees` or `crates/trusted-server-modules` does this. The stock module is reached for a declared cache only as the one module linked, in the third case of the Axum test. +4. **A declared table's lifetimes reaching a cache.** Nothing in core applies them (see 2.3), so no test shows `memory_seconds`, `store_seconds` or `store_idle_seconds` of a declared table reaching a cache. +5. **A declared table choosing one of several loading cache modules.** The core test links one module and the Axum test links two whose caches both keep values, so no test shows which module a declared table selected. Only `[cache.robots-txt]` has a test that tells two modules apart, `a_feature_selects_a_module_by_its_short_name_or_in_full`. No test in `crates/loading-cache/51degrees` or `crates/trusted-server-modules` selects the stock loading cache module for a declared cache by name, and the stock module is reached for a declared cache only as the one module linked, in the third case of the Axum test. 6. **The deploy check's builder list against the adapters'.** No test covers a built-in builder that declares a cache. Only the deploy check would see that cache. -7. **A module loading through a name it did not declare.** Nothing refuses the call and no test shows what happens. +7. **A module loading through a name it did not declare.** Nothing refuses the call, and no test shows what happens for a name no one declared, for a name another module declared, or for `robots-txt`. +8. **A module's cache made once, and shared by a declared cache with no table.** Rule 3 in 2.2 and the sharing in the step for a declared cache with no table have no test. No test checks that two tables naming one module, or a table and a declared cache with no table, are given one cache. ## Review log -No review rounds yet. +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/32-cache-features` at `0ef3ff78d`. Issues found, three of them blocking. The page said a deployment can shorten a declared cache's lifetimes although core applies none of them, said two modules of one publisher never share a copy although nothing checks the names a module loads through, and said the changelog entry names the three changes for crates outside core although the entry left out two of them. This revision states each as the code stands today, adds the order of a push, the other callers of `loading_caches_from`, the names core does not check, the cache made once and the other untested paths, and corrects the changelog entry, whereas the code changes and the decisions wait on the open questions in 2.10. ## Revision record -| Date | Change | -| ---------- | ------------------------------------ | -| 2026-10-15 | First draft, written after the code. | +| Date | Change | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-15 | First draft, written after the code. | +| 2026-10-15 | Fixes from review round 1. What core does with a declared table's lifetimes, the names core does not check, the changelog entry and the three changes the entry names, the order of a push, the other callers of `loading_caches_from`, the reserved and unchecked name forms, why the deploy check does not read `module`, the untested paths, the open questions, the review log, and the service call figure in section 1, dropped because it has no source. | From 778247e4ad5c1bafac9d9a2c7f2a9062077b7887 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 00:10:38 +0100 Subject: [PATCH 08/21] The module cache declarations design takes its round 2 review Three statements that were only partly true now say what the code does, and the round 1 log sentence no longer claims that every code change waits on a decision. The guide, rustdoc and visibility follow-ups are listed under the open questions with their files and lines. --- ...026-10-15-module-cache-declarations-design.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index f9af5836f1..3f6b5c3cca 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -141,7 +141,7 @@ The adapters differ in how a failed state build answers, see 2.7. Only the Axum This change stores nothing new and reads no request data. The change decides which loading cache module holds a module's copies. Five facts follow. 1. **What core knows about a copy.** Core sees a cache's name, its lifetimes and the key a module builds with `StoreKey::new(prefix, cache, key)`. Core does not read a key or a value, and the change adds no consent check on this path. A module that holds an answer which depends on the visitor has to put what the answer depends on into its key, or not hold the answer. The change gives the module a cache and does not settle that question for the module. -2. **Who can read a copy.** A key starts with the publisher's key prefix and the cache's name, so two publishers sharing a store never share a copy. Within one publisher, the copy in the shared store is the one every instance reads. Two modules of one publisher keep their copies apart by convention and not by a check. Two builders cannot declare one name, and a module that gives its `CacheSpec` and its `StoreKey` the name it declared holds its copies apart from every other module's. Core does not check that a module does so, because `CacheSpec.name` is any `&'static str`, `StoreKey::new` takes any string for the cache, and `load_cached` picks the cache by the name in the `CacheSpec` it is given. A module that loads through another module's declared name, or through `robots-txt`, shares that cache and keys that start the same way, see 2.10. +2. **Who can read a copy.** A key starts with the publisher's key prefix and the cache's name, so two publishers sharing a store never share a copy. Within one publisher, the copy in the shared store is the one every instance reads. Two modules of one publisher keep their copies apart by convention and not by a check. Two builders cannot declare one name, and a module that gives its `CacheSpec` and its `StoreKey` the name it declared holds its copies apart from every other module's. Core does not check that a module does so, because `CacheSpec.name` is any `&'static str`, `StoreKey::new` takes any string for the cache, and `load_cached` picks the cache by the name in the `CacheSpec` it is given. A module that gives its `CacheSpec` another module's declared name, or `robots-txt`, is held by the cache that name selected. Its keys start the same way as that module's only if it also builds its `StoreKey` with that name, because the key is a separate argument to `load_cached`. See 2.10. 3. **How long a copy lasts.** A module's `CacheSpec` sets how long its copies last, and core applies no declared table's lifetimes, see 2.3. Where a module applies its table's lifetimes itself, the shortest the range check allows is no copy in process memory (`memory_seconds = 0`) and one second in the shared store (`store_seconds = 1`). 4. **Whether the document can turn a cache off.** The document cannot turn a declared cache off in a build that links one loading cache module. Such a build holds a declared cache's answers whether or not the document has a table, and a table naming `none`, or any other name that no linked crate offers a cache from, is refused when the state is built. Core already does the same for `[cache.robots-txt]`. Reviewers are asked to confirm that a declared cache has no off switch, see 2.10. 5. **What changes today.** No module that ships declares a cache, so no deployment holds a new kind of answer because of this change. @@ -166,7 +166,7 @@ A load that fails is handled as `2026-10-12-loading-cache-design.md` describes. 1. **No document has to change.** `[cache.robots-txt]` is untouched and no other table is needed. A build that links one loading cache module holds a declared cache's answers with no table. 2. **No running deployment gains a cache.** No module that ships declares one, so a deployment holds a new kind of answer only when it links a module that declares a cache. -3. **A document with another `[cache.]` table did not load before.** `CacheSettings` refused the key at parse time, so no running deployment has one. Now the parse accepts the table, and `check_cache_tables` refuses the table at the deploy check and when the state is built unless a linked module declares the name. A tool that parses a document without running the deploy check no longer refuses the table. The order of a push matters, because a binary built before this change still refuses the key. Each adapter loads the document with `Settings::from_json_value` (`crates/trusted-server-core/src/config_payload.rs`) in the function that builds its state, so a service running such a binary answers with its startup error, as 2.7 describes, whenever it loads a document that carries a `[cache.]` table. Deploy the binary with this change to every instance before pushing a document with a table, and push a document without the tables before rolling an instance back to a binary from before this change. The warning "Deploy the code before pushing the config" in `docs/guide/configuration.md` gives the same order for other settings. +3. **A document with another `[cache.]` table did not load before.** `CacheSettings` refused the key at parse time, so no running deployment has one. Now the parse accepts the table, and `check_cache_tables` refuses the table at the deploy check and when the state is built unless a linked module declares the name. A tool that parses a document without running the deploy check no longer refuses the table. The order of a push matters, because a binary built before this change still refuses the key. Each adapter loads the document with `Settings::from_json_value` (`crates/trusted-server-core/src/config_payload.rs`) in the function that builds its state, so a service running such a binary answers with its startup error, as 2.7 describes, whenever it loads a document that carries a `[cache.]` table. Deploy the binary with this change to every instance before pushing a document with a table, and push a document without the tables before rolling an instance back to a binary from before this change. The warning "Deploy the code before pushing the config" in `docs/guide/configuration.md` gives the same order for the `[trusted_client_ip]` section alone, and the guide has no such warning for `[cache.]` tables, see 2.10. 4. **A module that adds a declaration changes nothing until a deployment links the module.** A deployment then writes a table only to choose a loading cache module other than the one linked, because its build links two or more loading cache modules, or, where the module reads its own table, to set lifetimes other than the module's defaults. 5. **Crates outside core meet three changes.** `CacheSettings::loading_caches` returns a `Vec` where it returned an array, `LoadingCaches::insert` takes `&str` where it took `&'static str`, and `CacheSettings` has a public `declared` field. The `Vec` stops a crate building if the crate uses the result as an array, by type, by pattern or by an array method, or keeps a name as a `&'static str`, because a declared name is borrowed from the settings. The new field stops a crate building if the crate builds `CacheSettings`, or takes `CacheSettings` apart, by listing every field. The wider parameter of `insert` stops no caller. No code in this repository uses `loading_caches()` as an array, keeps a name as a `&'static str` or lists every field of `CacheSettings`. The changelog entry under Changed names all three and does not mark them as breaking, whereas the next entry, for the cookie constants, is marked "Breaking for crates outside core" because each old constant name stops building wherever it is used. Whether to mark these three is an open question, see 2.10. 6. **The documentation.** `docs/guide/configuration.md` describes the declaration under the loading cache settings, and the guide's example module is fictional. @@ -184,12 +184,16 @@ The probe asks only whether the cache keeps values. The probe never loads a valu ### 2.10 Open questions -The first two questions ask for a code change and the other two for a decision. This design chooses none of the answers. +The first two questions ask for a code change and the next two for a decision. Items 5 to 8 are follow-ups that need an edit and no decision, each with the file and lines it has in `split/32-cache-features` at `be4f47842`. This design chooses none of the answers and changes none of the files the follow-ups name. 1. Should core apply a declared table's lifetimes? Today nothing does, see 2.3. One choice is for core to lay them over the lifetimes in the `CacheSpec` a module passes to `load_cached`. Another is a helper that hands each module its table's settings (see 3) and leaves the module to apply them. A third is for a declared table to take only `module`, so that no setting is checked and then left unused. 2. Should core check the name a module loads through? Today nothing does, see 2.6. One choice is for core to refuse a name the calling module did not declare, which needs core to know which module is loading, and `load_cached` carries no module today. The other choice is to keep the convention in 2.1 and leave the names unchecked. 3. Should a deployment be able to turn a declared cache off in a build that links one loading cache module? Today a declared cache with no table is held and `module = "none"` is refused, as for `[cache.robots-txt]`, see 2.6. The other choice is to keep the behavior 2.6 describes. 4. Should the changelog entry mark the three changes in 2.8 as breaking for crates outside core? Today the entry names the three and does not mark them. The other choice is to mark them as the entry for the cookie constants is marked. +5. **The configuration guide.** `docs/guide/configuration.md`, lines 1771 to 1773, offers a declared table "the same four settings" and says nothing of two things this design states. Core applies none of a declared table's lifetimes (2.3), and a document with a `[cache.]` table is pushed only after every instance runs the new binary (2.8). The warning "Deploy the code before pushing the config" at lines 543 to 550 of the same guide covers the `[trusted_client_ip]` section alone. +6. **The integration guide.** `docs/guide/integration-guide.md`, line 364, lists `.with_loading_cache(...)` and has no row for `.with_cache_features(...)`, and lines 841 to 842 say a feature that selects nothing loads through `NoLoadingCache`, which is not so in a build that links one loading cache module. Section 3 describes both gaps. +7. **The rustdoc.** The doc comment of `loading_caches_from` in `crates/trusted-server-core/src/integrations/mod.rs`, lines 897 to 899, and the doc comment of the `module` field of `LoadingCacheConfig` in `crates/trusted-server-core/src/settings.rs`, lines 3059 to 3061, say a table with no `module` keeps no copy. Rule 2 in 2.2 says such a table takes the one loading cache module a build links, and keeps no copy only when the build links none or several. +8. **The visibility of two functions.** `declared_caches` (line 837) and `check_cache_tables` (line 878) in `crates/trusted-server-core/src/integrations/mod.rs` are `pub`, and no crate outside core calls either. The one call from another file is `check_cache_tables` at line 367 of `crates/trusted-server-core/src/config.rs`, which is in core. Both could be `pub(crate)`, unless the design states why a crate outside core needs them. ## 3. What is not in this change @@ -199,7 +203,7 @@ The first two questions ask for a code change and the other two for a decision. - **A check that a module calls only the names it declared.** `LoadingCaches::for_feature` answers `NoLoadingCache` for a name no one declared, and the cache selected for the name where another module or core declared it, see 2.6. - **A check of a declared name's spelling,** or of whether the name starts with the module's own name. Core reserves only `robots-txt`. `asset_rules` is read as the list of asset rules, so a builder can declare it and no table can then select a module for it. TOML reads `[cache.a.b]` as a table `b` inside a table `a`, so a name with a dot can be written only quoted, as `[cache."a.b"]`. A name with a `/` blurs where the name ends and the key begins in `//`. - **A warning when a declared cache holds nothing** because the build links several loading cache modules and no table names one. -- **A `module` check in the deploy check.** A table whose `module` names a crate that offers no loading cache is still refused only when the state is built, as `[cache.robots-txt] module` was before this change. `loading_caches_from` compares `module` inside the loop that makes each cache, and `check_cache_tables` runs in the deploy check to refuse a table without making a cache. A comparison of `module` with the loading cache modules the builders offer would need no input the deploy check lacks, because the deploy check is given the same builders. +- **A `module` check in the deploy check.** A table whose `module` names a crate that offers no loading cache is still refused only when the state is built, as `[cache.robots-txt] module` was before this change. `loading_caches_from` compares `module` inside the loop that makes each cache, and `check_cache_tables` runs in the deploy check to refuse a table without making a cache. A comparison of `module` with the loading cache modules the builders offer needs the builders that offer them. The deploy check is given core's built-in builders followed by the ones a tool registers, and the adapters are given the stock modules followed by the deployment's own, see 2.4. No built-in builder offers a loading cache, so a loading cache module reaches the deploy check only as a registered builder. The `ts` command registers the stock list, which carries the stock loading cache module, and a deployment's own loading cache module needs a tool that registers it. - **A row in the integration guide.** The table of builder methods in `docs/guide/integration-guide.md` lists `.with_loading_cache(...)` and does not list `.with_cache_features(...)`. The section "Supplying a loading cache" in the same guide does not mention declared caches, and says a feature that selects nothing loads through `NoLoadingCache`, which is not so in a build that links one loading cache module, see 2.2. ## 4. Acceptance @@ -239,7 +243,8 @@ These joins have no test. ## Review log -1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/32-cache-features` at `0ef3ff78d`. Issues found, three of them blocking. The page said a deployment can shorten a declared cache's lifetimes although core applies none of them, said two modules of one publisher never share a copy although nothing checks the names a module loads through, and said the changelog entry names the three changes for crates outside core although the entry left out two of them. This revision states each as the code stands today, adds the order of a push, the other callers of `loading_caches_from`, the names core does not check, the cache made once and the other untested paths, and corrects the changelog entry, whereas the code changes and the decisions wait on the open questions in 2.10. +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/32-cache-features` at `0ef3ff78d`. Issues found, three of them blocking. The page said a deployment can shorten a declared cache's lifetimes although core applies none of them, said two modules of one publisher never share a copy although nothing checks the names a module loads through, and said the changelog entry names the three changes for crates outside core although the entry left out two of them. This revision states each as the code stands today, adds the order of a push, the other callers of `loading_caches_from`, the names core does not check, the cache made once and the other untested paths, and corrects the changelog entry, whereas the decisions, and the code changes that depend on them, wait on the open questions in 2.10. +2. **Round 2, 2026-10-15.** A lighter review in a fresh session, checking the round 1 fixes against `split/32-cache-features` at `be4f47842`. Approved, none blocking. This revision corrects the three statements that were only partly true, being what a module that borrows another module's cache name shares, which section the guide's push-order warning covers and which builders the deploy check is given, corrects the round 1 sentence on the code changes, and adds the guide, rustdoc and visibility follow-ups to 2.10. ## Revision record @@ -247,3 +252,4 @@ These joins have no test. | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-10-15 | First draft, written after the code. | | 2026-10-15 | Fixes from review round 1. What core does with a declared table's lifetimes, the names core does not check, the changelog entry and the three changes the entry names, the order of a push, the other callers of `loading_caches_from`, the reserved and unchecked name forms, why the deploy check does not read `module`, the untested paths, the open questions, the review log, and the service call figure in section 1, dropped because it has no source. | +| 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | From 414ccfa8a86f1c347f7939b4e01b7fa57febd41f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 20:35:21 +0100 Subject: [PATCH 09/21] The design header names the pull request that carries it --- .../specs/2026-10-15-module-cache-declarations-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index 3f6b5c3cca..b0106e054b 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -3,9 +3,9 @@ **Status:** Proposed, 2026-10-15. Written after the code, for draft pull request IABTechLab/trusted-server#1373. Every design document of the stack is in IABTechLab/trusted-server#1342. **Author:** 51Degrees (contributed), for Tech Lab review **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-10-12-robots-txt-from-modules-design.md`, `2026-08-27-integration-provider-seam-design.md` -**Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 +**Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 (closed) **Issues:** IABTechLab/trusted-server#1320 -**Last updated:** 2026-10-15 +**Last updated:** 2026-10-16 > **Why this spec exists.** A module that holds its own answers asks core for the cache of the name it chose, finds no `[cache.]` table for that name, and gets the cache that holds nothing, so every request loads the answer again. From 6e752f23f35a8bdc221265b50bc7905fbd09ae85 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 11:10:43 +0100 Subject: [PATCH 10/21] The module cache design names the shared store on each adapter Who can read a copy said the copy in the shared store is the one every instance reads. That store is Fastly's cache, or the key-value store the Cloudflare and Spin adapters declare once the loading cache branch has them declare TRUSTED_SERVER_KV and default, and Axum has none. The same text goes to the copy of this design on the specs branch. --- .../specs/2026-10-15-module-cache-declarations-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index b0106e054b..ced5d85380 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -5,7 +5,7 @@ **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-10-12-robots-txt-from-modules-design.md`, `2026-08-27-integration-provider-seam-design.md` **Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 (closed) **Issues:** IABTechLab/trusted-server#1320 -**Last updated:** 2026-10-16 +**Last updated:** 2026-10-17 > **Why this spec exists.** A module that holds its own answers asks core for the cache of the name it chose, finds no `[cache.]` table for that name, and gets the cache that holds nothing, so every request loads the answer again. @@ -141,7 +141,7 @@ The adapters differ in how a failed state build answers, see 2.7. Only the Axum This change stores nothing new and reads no request data. The change decides which loading cache module holds a module's copies. Five facts follow. 1. **What core knows about a copy.** Core sees a cache's name, its lifetimes and the key a module builds with `StoreKey::new(prefix, cache, key)`. Core does not read a key or a value, and the change adds no consent check on this path. A module that holds an answer which depends on the visitor has to put what the answer depends on into its key, or not hold the answer. The change gives the module a cache and does not settle that question for the module. -2. **Who can read a copy.** A key starts with the publisher's key prefix and the cache's name, so two publishers sharing a store never share a copy. Within one publisher, the copy in the shared store is the one every instance reads. Two modules of one publisher keep their copies apart by convention and not by a check. Two builders cannot declare one name, and a module that gives its `CacheSpec` and its `StoreKey` the name it declared holds its copies apart from every other module's. Core does not check that a module does so, because `CacheSpec.name` is any `&'static str`, `StoreKey::new` takes any string for the cache, and `load_cached` picks the cache by the name in the `CacheSpec` it is given. A module that gives its `CacheSpec` another module's declared name, or `robots-txt`, is held by the cache that name selected. Its keys start the same way as that module's only if it also builds its `StoreKey` with that name, because the key is a separate argument to `load_cached`. See 2.10. +2. **Who can read a copy.** A key starts with the publisher's key prefix and the cache's name, so two publishers sharing a store never share a copy. Within one publisher, the copy in the shared store is the one every instance reads, being Fastly's cache, or the key-value store the Cloudflare and Spin adapters declare, `TRUSTED_SERVER_KV` and `default`, and Axum has no shared store. Two modules of one publisher keep their copies apart by convention and not by a check. Two builders cannot declare one name, and a module that gives its `CacheSpec` and its `StoreKey` the name it declared holds its copies apart from every other module's. Core does not check that a module does so, because `CacheSpec.name` is any `&'static str`, `StoreKey::new` takes any string for the cache, and `load_cached` picks the cache by the name in the `CacheSpec` it is given. A module that gives its `CacheSpec` another module's declared name, or `robots-txt`, is held by the cache that name selected. Its keys start the same way as that module's only if it also builds its `StoreKey` with that name, because the key is a separate argument to `load_cached`. See 2.10. 3. **How long a copy lasts.** A module's `CacheSpec` sets how long its copies last, and core applies no declared table's lifetimes, see 2.3. Where a module applies its table's lifetimes itself, the shortest the range check allows is no copy in process memory (`memory_seconds = 0`) and one second in the shared store (`store_seconds = 1`). 4. **Whether the document can turn a cache off.** The document cannot turn a declared cache off in a build that links one loading cache module. Such a build holds a declared cache's answers whether or not the document has a table, and a table naming `none`, or any other name that no linked crate offers a cache from, is refused when the state is built. Core already does the same for `[cache.robots-txt]`. Reviewers are asked to confirm that a declared cache has no off switch, see 2.10. 5. **What changes today.** No module that ships declares a cache, so no deployment holds a new kind of answer because of this change. @@ -253,3 +253,4 @@ These joins have no test. | 2026-10-15 | First draft, written after the code. | | 2026-10-15 | Fixes from review round 1. What core does with a declared table's lifetimes, the names core does not check, the changelog entry and the three changes the entry names, the order of a push, the other callers of `loading_caches_from`, the reserved and unchecked name forms, why the deploy check does not read `module`, the untested paths, the open questions, the review log, and the service call figure in section 1, dropped because it has no source. | | 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | +| 2026-10-17 | Who can read a copy names the shared store on each adapter, being Fastly's cache, the key-value store the Cloudflare and Spin adapters declare on row 24, `TRUSTED_SERVER_KV` and `default`, and none on Axum. | From 9786f091e49f362e4b887fabf52a2a170f2c4e61 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 11:10:12 +0100 Subject: [PATCH 11/21] The configuration guide says what a declared table changes and when to push it Core applies none of the lifetimes in a declared [cache.] table. They are range-checked, and outside tests only the robots.txt cache calls LoadingCacheConfig::lifetimes, for [cache.robots-txt]. The guide offered such a table the same four settings without saying so. A binary built before declared caches refuses every [cache.] key other than robots-txt, because CacheSettings denied unknown fields, so a service on that binary answers with its startup error when it loads a document carrying one. The guide now warns to upgrade every instance first, as it already does for [trusted_client_ip]. Follow-up 5 in the open questions of docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md. --- docs/guide/configuration.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index da29d115fb..da0710f0bc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1777,7 +1777,10 @@ the service starts, with the names that could be written. A module may declare caches of its own, named by the module, and a deployment selects a loading cache for each in its own `[cache.]` table with the -same four settings. A module from `crates/geo/example` whose service answers +same four settings. Core applies none of the lifetimes in such a table. They +are checked against the ranges above and change nothing unless the module +reads its own table and applies them. +A module from `crates/geo/example` whose service answers once for each request's evidence declares `example-answers`, the cache that holds those answers, so `[cache.example-answers] module = "51degrees"` asks the service once however many requests carry the same evidence. A name is @@ -1786,6 +1789,14 @@ the module's own: `robots-txt` and a name two modules declare are refused. A `ts config validate` and when the deployment starts, and the message lists the names this build's modules declare. +::: warning Deploy the code before pushing the config +A binary that predates declared caches fails to load a blob containing a +`[cache.]` table other than `[cache.robots-txt]` and returns its +startup-error response. Upgrade every instance before pushing a config with +such a table, and restore a config without those tables before rolling +instances back. +::: + ```toml [cache.robots-txt] module = "51degrees" From 613b386f5ef10a7d6538dba2c601041fc14c9893 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 11:10:38 +0100 Subject: [PATCH 12/21] The integration guide lists the builder method that declares a module's caches The table of builder methods had no row for .with_cache_features(...). The section on supplying a loading cache already says that a feature whose table names no module takes the one loading cache module the build links, and loads through NoLoadingCache only when the build links none or several, since 624230359, so that sentence needs no change. Follow-up 6 in the open questions of docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md. --- docs/guide/integration-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index e943e55058..ec5e708132 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -375,6 +375,7 @@ points at the functions that do the work. | `.with_plan_validator(...)` | Optional. Checks the module's settings against the compiled auction plan, when a deployment is validated and as the settings load | | `.with_demand(...)` and `.with_adserver(...)` | Optional. Registers an auction implementation that `[demand]` or `[ad-server]` can name. A crate that supplies nothing else starts from `IntegrationBuilder::implementations(...)` | | `.with_loading_cache(...)` | Optional. Offers the loading cache the crate supplies, which a feature selects by the module's name in its `[cache.]` table. An adapter takes the selections with `loading_caches_from` | +| `.with_cache_features(...)` | Optional. Declares the caches the module loads through, by name, each selected in its own `[cache.]` table as `[cache.robots-txt]` is. A name core or another builder declares is refused | | `.with_shared_settings(...)` | Optional. Runs the builder when its vendor's `[shared.]` table is present, for a vendor whose modules all read that one table. A builder the table runs is selected by no section | ```rust From 97fc7138f59a011ec327575a39bf6111cbd3d75e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 11:11:34 +0100 Subject: [PATCH 13/21] The cache declaration checks are pub(crate), since only core calls them No crate outside core calls declared_caches or check_cache_tables, on this branch or at the top of the stack, where git grep finds them only in crates/trusted-server-core/src/config.rs and integrations/mod.rs. The public doc comments of with_cache_features and loading_caches_from no longer link to them, and the errors of loading_caches_from now name the refusal of a declaration, which the link to check_cache_tables carried. Follow-up 8 in the open questions of docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md. --- .../src/integrations/mod.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 17f8c59725..58d80202f5 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -665,9 +665,10 @@ impl IntegrationBuilder { /// Declares the caches this builder's modules load through, by name, /// each selected in its own `[cache.]` table the way core's own /// are. A name is the module's own, as `example-answers` is for a - /// module from `crates/geo/example`: core's `robots-txt` and a name - /// another builder declares are refused by [`declared_caches`], and a - /// table naming a cache no builder declares by [`check_cache_tables`]. + /// module from `crates/geo/example`. Declaring core's `robots-txt` or a + /// name another builder declares is refused, and so is a table naming a + /// cache no builder declares, when a deployment is validated and when + /// [`loading_caches_from`] makes its caches. #[must_use] pub const fn with_cache_features(mut self, features: &'static [&'static str]) -> Self { self.cache_features = features; @@ -834,7 +835,7 @@ pub(crate) fn all_builders( /// When a builder declares core's own `robots-txt`, or two builders declare /// one name, naming the builders, since the two would otherwise share one /// cache and its keys without a word. -pub fn declared_caches( +pub(crate) fn declared_caches( builders: &[IntegrationBuilder], ) -> Result, Report> { let mut declared: Vec<(&'static str, &'static str)> = @@ -875,7 +876,7 @@ pub fn declared_caches( /// When a table names an undeclared cache, naming the table and the caches /// this build's modules declare, or when the declarations themselves are /// refused, see [`declared_caches`]. -pub fn check_cache_tables( +pub(crate) fn check_cache_tables( settings: &Settings, builders: &[IntegrationBuilder], ) -> Result<(), Report> { @@ -903,9 +904,10 @@ pub fn check_cache_tables( /// # Errors /// /// When a table names a module no linked builder offers a cache from, naming -/// the table and the modules that do, and when a `[cache.]` table names -/// a cache no linked module declares, naming the ones they do, see -/// [`check_cache_tables`]. +/// the table and the modules that do, when a `[cache.]` table names a +/// cache no linked module declares, naming the ones they do, and when a +/// builder declares core's `robots-txt` or a name another builder declares, +/// naming the builders. pub fn loading_caches_from( settings: &Settings, builders: &[IntegrationBuilder], From e0a8a902f05ec45c7506d3d5253f8a1e52d49708 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 11:13:52 +0100 Subject: [PATCH 14/21] The module cache design takes its four follow-ups out of the open questions The three commits before this one change the configuration guide, the integration guide and the visibility of declared_caches and check_cache_tables. The doc comments of loading_caches_from and LoadingCacheConfig::module that follow-up 7 named already said what rule 2 in 2.2 says, as did the integration guide's sentence on a table naming no module, since 624230359. The order of a push in 2.8 and the integration guide bullet in section 3 now say what the guides say, and the revision record has a row for 2026-10-18. --- .../2026-10-15-module-cache-declarations-design.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index ced5d85380..9b28bb62ea 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -5,7 +5,7 @@ **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-10-12-robots-txt-from-modules-design.md`, `2026-08-27-integration-provider-seam-design.md` **Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 (closed) **Issues:** IABTechLab/trusted-server#1320 -**Last updated:** 2026-10-17 +**Last updated:** 2026-10-18 > **Why this spec exists.** A module that holds its own answers asks core for the cache of the name it chose, finds no `[cache.]` table for that name, and gets the cache that holds nothing, so every request loads the answer again. @@ -166,7 +166,7 @@ A load that fails is handled as `2026-10-12-loading-cache-design.md` describes. 1. **No document has to change.** `[cache.robots-txt]` is untouched and no other table is needed. A build that links one loading cache module holds a declared cache's answers with no table. 2. **No running deployment gains a cache.** No module that ships declares one, so a deployment holds a new kind of answer only when it links a module that declares a cache. -3. **A document with another `[cache.]` table did not load before.** `CacheSettings` refused the key at parse time, so no running deployment has one. Now the parse accepts the table, and `check_cache_tables` refuses the table at the deploy check and when the state is built unless a linked module declares the name. A tool that parses a document without running the deploy check no longer refuses the table. The order of a push matters, because a binary built before this change still refuses the key. Each adapter loads the document with `Settings::from_json_value` (`crates/trusted-server-core/src/config_payload.rs`) in the function that builds its state, so a service running such a binary answers with its startup error, as 2.7 describes, whenever it loads a document that carries a `[cache.]` table. Deploy the binary with this change to every instance before pushing a document with a table, and push a document without the tables before rolling an instance back to a binary from before this change. The warning "Deploy the code before pushing the config" in `docs/guide/configuration.md` gives the same order for the `[trusted_client_ip]` section alone, and the guide has no such warning for `[cache.]` tables, see 2.10. +3. **A document with another `[cache.]` table did not load before.** `CacheSettings` refused the key at parse time, so no running deployment has one. Now the parse accepts the table, and `check_cache_tables` refuses the table at the deploy check and when the state is built unless a linked module declares the name. A tool that parses a document without running the deploy check no longer refuses the table. The order of a push matters, because a binary built before this change still refuses the key. Each adapter loads the document with `Settings::from_json_value` (`crates/trusted-server-core/src/config_payload.rs`) in the function that builds its state, so a service running such a binary answers with its startup error, as 2.7 describes, whenever it loads a document that carries a `[cache.]` table. Deploy the binary with this change to every instance before pushing a document with a table, and push a document without the tables before rolling an instance back to a binary from before this change. `docs/guide/configuration.md` gives this order in the warning "Deploy the code before pushing the config" under its loading cache settings, as it does for the `[trusted_client_ip]` section. 4. **A module that adds a declaration changes nothing until a deployment links the module.** A deployment then writes a table only to choose a loading cache module other than the one linked, because its build links two or more loading cache modules, or, where the module reads its own table, to set lifetimes other than the module's defaults. 5. **Crates outside core meet three changes.** `CacheSettings::loading_caches` returns a `Vec` where it returned an array, `LoadingCaches::insert` takes `&str` where it took `&'static str`, and `CacheSettings` has a public `declared` field. The `Vec` stops a crate building if the crate uses the result as an array, by type, by pattern or by an array method, or keeps a name as a `&'static str`, because a declared name is borrowed from the settings. The new field stops a crate building if the crate builds `CacheSettings`, or takes `CacheSettings` apart, by listing every field. The wider parameter of `insert` stops no caller. No code in this repository uses `loading_caches()` as an array, keeps a name as a `&'static str` or lists every field of `CacheSettings`. The changelog entry under Changed names all three and does not mark them as breaking, whereas the next entry, for the cookie constants, is marked "Breaking for crates outside core" because each old constant name stops building wherever it is used. Whether to mark these three is an open question, see 2.10. 6. **The documentation.** `docs/guide/configuration.md` describes the declaration under the loading cache settings, and the guide's example module is fictional. @@ -184,16 +184,12 @@ The probe asks only whether the cache keeps values. The probe never loads a valu ### 2.10 Open questions -The first two questions ask for a code change and the next two for a decision. Items 5 to 8 are follow-ups that need an edit and no decision, each with the file and lines it has in `split/32-cache-features` at `be4f47842`. This design chooses none of the answers and changes none of the files the follow-ups name. +The first two questions ask for a code change and the next two for a decision. This design chooses none of the answers. 1. Should core apply a declared table's lifetimes? Today nothing does, see 2.3. One choice is for core to lay them over the lifetimes in the `CacheSpec` a module passes to `load_cached`. Another is a helper that hands each module its table's settings (see 3) and leaves the module to apply them. A third is for a declared table to take only `module`, so that no setting is checked and then left unused. 2. Should core check the name a module loads through? Today nothing does, see 2.6. One choice is for core to refuse a name the calling module did not declare, which needs core to know which module is loading, and `load_cached` carries no module today. The other choice is to keep the convention in 2.1 and leave the names unchecked. 3. Should a deployment be able to turn a declared cache off in a build that links one loading cache module? Today a declared cache with no table is held and `module = "none"` is refused, as for `[cache.robots-txt]`, see 2.6. The other choice is to keep the behavior 2.6 describes. 4. Should the changelog entry mark the three changes in 2.8 as breaking for crates outside core? Today the entry names the three and does not mark them. The other choice is to mark them as the entry for the cookie constants is marked. -5. **The configuration guide.** `docs/guide/configuration.md`, lines 1771 to 1773, offers a declared table "the same four settings" and says nothing of two things this design states. Core applies none of a declared table's lifetimes (2.3), and a document with a `[cache.]` table is pushed only after every instance runs the new binary (2.8). The warning "Deploy the code before pushing the config" at lines 543 to 550 of the same guide covers the `[trusted_client_ip]` section alone. -6. **The integration guide.** `docs/guide/integration-guide.md`, line 364, lists `.with_loading_cache(...)` and has no row for `.with_cache_features(...)`, and lines 841 to 842 say a feature that selects nothing loads through `NoLoadingCache`, which is not so in a build that links one loading cache module. Section 3 describes both gaps. -7. **The rustdoc.** The doc comment of `loading_caches_from` in `crates/trusted-server-core/src/integrations/mod.rs`, lines 897 to 899, and the doc comment of the `module` field of `LoadingCacheConfig` in `crates/trusted-server-core/src/settings.rs`, lines 3059 to 3061, say a table with no `module` keeps no copy. Rule 2 in 2.2 says such a table takes the one loading cache module a build links, and keeps no copy only when the build links none or several. -8. **The visibility of two functions.** `declared_caches` (line 837) and `check_cache_tables` (line 878) in `crates/trusted-server-core/src/integrations/mod.rs` are `pub`, and no crate outside core calls either. The one call from another file is `check_cache_tables` at line 367 of `crates/trusted-server-core/src/config.rs`, which is in core. Both could be `pub(crate)`, unless the design states why a crate outside core needs them. ## 3. What is not in this change @@ -204,7 +200,7 @@ The first two questions ask for a code change and the next two for a decision. I - **A check of a declared name's spelling,** or of whether the name starts with the module's own name. Core reserves only `robots-txt`. `asset_rules` is read as the list of asset rules, so a builder can declare it and no table can then select a module for it. TOML reads `[cache.a.b]` as a table `b` inside a table `a`, so a name with a dot can be written only quoted, as `[cache."a.b"]`. A name with a `/` blurs where the name ends and the key begins in `//`. - **A warning when a declared cache holds nothing** because the build links several loading cache modules and no table names one. - **A `module` check in the deploy check.** A table whose `module` names a crate that offers no loading cache is still refused only when the state is built, as `[cache.robots-txt] module` was before this change. `loading_caches_from` compares `module` inside the loop that makes each cache, and `check_cache_tables` runs in the deploy check to refuse a table without making a cache. A comparison of `module` with the loading cache modules the builders offer needs the builders that offer them. The deploy check is given core's built-in builders followed by the ones a tool registers, and the adapters are given the stock modules followed by the deployment's own, see 2.4. No built-in builder offers a loading cache, so a loading cache module reaches the deploy check only as a registered builder. The `ts` command registers the stock list, which carries the stock loading cache module, and a deployment's own loading cache module needs a tool that registers it. -- **A row in the integration guide.** The table of builder methods in `docs/guide/integration-guide.md` lists `.with_loading_cache(...)` and does not list `.with_cache_features(...)`. The section "Supplying a loading cache" in the same guide does not mention declared caches, and says a feature that selects nothing loads through `NoLoadingCache`, which is not so in a build that links one loading cache module, see 2.2. +- **Declared caches in the section "Supplying a loading cache".** The integration guide, `docs/guide/integration-guide.md`, lists `.with_cache_features(...)` among the builder methods, and its section "Supplying a loading cache" does not mention declared caches. ## 4. Acceptance @@ -254,3 +250,4 @@ These joins have no test. | 2026-10-15 | Fixes from review round 1. What core does with a declared table's lifetimes, the names core does not check, the changelog entry and the three changes the entry names, the order of a push, the other callers of `loading_caches_from`, the reserved and unchecked name forms, why the deploy check does not read `module`, the untested paths, the open questions, the review log, and the service call figure in section 1, dropped because it has no source. | | 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | | 2026-10-17 | Who can read a copy names the shared store on each adapter, being Fastly's cache, the key-value store the Cloudflare and Spin adapters declare on row 24, `TRUSTED_SERVER_KV` and `default`, and none on Axum. | +| 2026-10-18 | Items 5 to 8 leave 2.10. The configuration guide says core applies no lifetime of a declared table and when to push one, the integration guide lists `.with_cache_features(...)`, and `declared_caches` and `check_cache_tables` are `pub(crate)`. The rustdoc of `loading_caches_from` and `LoadingCacheConfig::module`, and the integration guide on a table naming no module, already gave rule 2 in 2.2. Section 3 and 2.8 match the guides. | From 9266831339a68e99e73f3978ca99cfc3c4d23669 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 11:24:18 +0100 Subject: [PATCH 15/21] The module cache design ends with its review log, as the other designs do Each of the 53 designs of the stack that carries both sections puts the revision record before the review log, and this one put it after. The text of both sections is unchanged. --- .../2026-10-15-module-cache-declarations-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index 9b28bb62ea..14f8f6b99c 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -237,11 +237,6 @@ These joins have no test. 7. **A module loading through a name it did not declare.** Nothing refuses the call, and no test shows what happens for a name no one declared, for a name another module declared, or for `robots-txt`. 8. **A module's cache made once, and shared by a declared cache with no table.** Rule 3 in 2.2 and the sharing in the step for a declared cache with no table have no test. No test checks that two tables naming one module, or a table and a declared cache with no table, are given one cache. -## Review log - -1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/32-cache-features` at `0ef3ff78d`. Issues found, three of them blocking. The page said a deployment can shorten a declared cache's lifetimes although core applies none of them, said two modules of one publisher never share a copy although nothing checks the names a module loads through, and said the changelog entry names the three changes for crates outside core although the entry left out two of them. This revision states each as the code stands today, adds the order of a push, the other callers of `loading_caches_from`, the names core does not check, the cache made once and the other untested paths, and corrects the changelog entry, whereas the decisions, and the code changes that depend on them, wait on the open questions in 2.10. -2. **Round 2, 2026-10-15.** A lighter review in a fresh session, checking the round 1 fixes against `split/32-cache-features` at `be4f47842`. Approved, none blocking. This revision corrects the three statements that were only partly true, being what a module that borrows another module's cache name shares, which section the guide's push-order warning covers and which builders the deploy check is given, corrects the round 1 sentence on the code changes, and adds the guide, rustdoc and visibility follow-ups to 2.10. - ## Revision record | Date | Change | @@ -251,3 +246,8 @@ These joins have no test. | 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | | 2026-10-17 | Who can read a copy names the shared store on each adapter, being Fastly's cache, the key-value store the Cloudflare and Spin adapters declare on row 24, `TRUSTED_SERVER_KV` and `default`, and none on Axum. | | 2026-10-18 | Items 5 to 8 leave 2.10. The configuration guide says core applies no lifetime of a declared table and when to push one, the integration guide lists `.with_cache_features(...)`, and `declared_caches` and `check_cache_tables` are `pub(crate)`. The rustdoc of `loading_caches_from` and `LoadingCacheConfig::module`, and the integration guide on a table naming no module, already gave rule 2 in 2.2. Section 3 and 2.8 match the guides. | + +## Review log + +1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/32-cache-features` at `0ef3ff78d`. Issues found, three of them blocking. The page said a deployment can shorten a declared cache's lifetimes although core applies none of them, said two modules of one publisher never share a copy although nothing checks the names a module loads through, and said the changelog entry names the three changes for crates outside core although the entry left out two of them. This revision states each as the code stands today, adds the order of a push, the other callers of `loading_caches_from`, the names core does not check, the cache made once and the other untested paths, and corrects the changelog entry, whereas the decisions, and the code changes that depend on them, wait on the open questions in 2.10. +2. **Round 2, 2026-10-15.** A lighter review in a fresh session, checking the round 1 fixes against `split/32-cache-features` at `be4f47842`. Approved, none blocking. This revision corrects the three statements that were only partly true, being what a module that borrows another module's cache name shares, which section the guide's push-order warning covers and which builders the deploy check is given, corrects the round 1 sentence on the code changes, and adds the guide, rustdoc and visibility follow-ups to 2.10. From ce141adb26b3a424824d2cc7034dbaed5cdf3518 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 11:52:39 +0100 Subject: [PATCH 16/21] The module cache design says the deploy check refuses a module nothing offers Since the loading cache change, validate_settings_for_deploy_with calls check_loading_cache_modules, so the deploy check refuses a table whose module names a crate that offers no loading cache, as the state build does. Section 2.7 said the state build alone refused it, and section 3 listed the check as missing. Section 3 now keeps the one limit left, that ts config validate refuses a deployment's own loading cache module unless a tool registers it. --- ...6-10-15-module-cache-declarations-design.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index 14f8f6b99c..ec8416ac79 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -153,7 +153,7 @@ This change stores nothing new and reads no request data. The change decides whi | A table names a cache no linked module declares | The deploy check and the state build | The message in 2.4, naming the first such table | | A builder declares `robots-txt` | The same two, with or without a table | A message saying the builder declares the cache `robots-txt`, which is core's own, and naming the builder | | Two builders declare one name | The same two, with or without a table | A message naming both builders and the cache, so linking the two modules fails every deployment that does | -| A table's `module` names a crate that offers no loading cache | The state build only | A message naming the table and the module written, saying no linked crate offers a loading cache from it, and listing the loading cache modules the build has | +| A table's `module` names a crate that offers no loading cache | The deploy check and the state build | A message naming the table and the module written, saying no linked crate offers a loading cache from it, and listing the loading cache modules the build has | | A lifetime out of range | Loading the document and the deploy check | `[cache.answers] store_seconds is 0, and must be from 1 to 31536000` | | A declared cache with no table, and no or several loading cache modules linked | Nothing | The cache holds nothing and loads every time, with no log line | | A module loads through a name it did not declare | Nothing | `LoadingCaches::for_feature` answers `NoLoadingCache` for a name no one declared, and the cache selected for the name where another module or core declared it, with no error either way | @@ -199,7 +199,7 @@ The first two questions ask for a code change and the next two for a decision. T - **A check that a module calls only the names it declared.** `LoadingCaches::for_feature` answers `NoLoadingCache` for a name no one declared, and the cache selected for the name where another module or core declared it, see 2.6. - **A check of a declared name's spelling,** or of whether the name starts with the module's own name. Core reserves only `robots-txt`. `asset_rules` is read as the list of asset rules, so a builder can declare it and no table can then select a module for it. TOML reads `[cache.a.b]` as a table `b` inside a table `a`, so a name with a dot can be written only quoted, as `[cache."a.b"]`. A name with a `/` blurs where the name ends and the key begins in `//`. - **A warning when a declared cache holds nothing** because the build links several loading cache modules and no table names one. -- **A `module` check in the deploy check.** A table whose `module` names a crate that offers no loading cache is still refused only when the state is built, as `[cache.robots-txt] module` was before this change. `loading_caches_from` compares `module` inside the loop that makes each cache, and `check_cache_tables` runs in the deploy check to refuse a table without making a cache. A comparison of `module` with the loading cache modules the builders offer needs the builders that offer them. The deploy check is given core's built-in builders followed by the ones a tool registers, and the adapters are given the stock modules followed by the deployment's own, see 2.4. No built-in builder offers a loading cache, so a loading cache module reaches the deploy check only as a registered builder. The `ts` command registers the stock list, which carries the stock loading cache module, and a deployment's own loading cache module needs a tool that registers it. +- **A deploy check that knows a deployment's own loading cache module.** The deploy check refuses a table whose `module` names a crate that offers no loading cache, as the state build does, through `check_loading_cache_modules`, which `validate_settings_for_deploy_with` in `crates/trusted-server-core/src/config.rs` calls. It compares `module` with the loading cache modules of the builders it is given, and the deploy check is given core's built-in builders followed by the ones a tool registers, where the adapters are given the stock modules followed by the deployment's own, see 2.4. No built-in builder offers a loading cache, and the `ts` command registers the stock list, which carries the stock loading cache module, so `ts config validate` refuses a table naming a deployment's own loading cache module unless a tool registers that module. - **Declared caches in the section "Supplying a loading cache".** The integration guide, `docs/guide/integration-guide.md`, lists `.with_cache_features(...)` among the builder methods, and its section "Supplying a loading cache" does not mention declared caches. ## 4. Acceptance @@ -239,13 +239,13 @@ These joins have no test. ## Revision record -| Date | Change | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-10-15 | First draft, written after the code. | -| 2026-10-15 | Fixes from review round 1. What core does with a declared table's lifetimes, the names core does not check, the changelog entry and the three changes the entry names, the order of a push, the other callers of `loading_caches_from`, the reserved and unchecked name forms, why the deploy check does not read `module`, the untested paths, the open questions, the review log, and the service call figure in section 1, dropped because it has no source. | -| 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | -| 2026-10-17 | Who can read a copy names the shared store on each adapter, being Fastly's cache, the key-value store the Cloudflare and Spin adapters declare on row 24, `TRUSTED_SERVER_KV` and `default`, and none on Axum. | -| 2026-10-18 | Items 5 to 8 leave 2.10. The configuration guide says core applies no lifetime of a declared table and when to push one, the integration guide lists `.with_cache_features(...)`, and `declared_caches` and `check_cache_tables` are `pub(crate)`. The rustdoc of `loading_caches_from` and `LoadingCacheConfig::module`, and the integration guide on a table naming no module, already gave rule 2 in 2.2. Section 3 and 2.8 match the guides. | +| Date | Change | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-15 | First draft, written after the code. | +| 2026-10-15 | Fixes from review round 1. What core does with a declared table's lifetimes, the names core does not check, the changelog entry and the three changes the entry names, the order of a push, the other callers of `loading_caches_from`, the reserved and unchecked name forms, why the deploy check does not read `module`, the untested paths, the open questions, the review log, and the service call figure in section 1, dropped because it has no source. | +| 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | +| 2026-10-17 | Who can read a copy names the shared store on each adapter, being Fastly's cache, the key-value store the Cloudflare and Spin adapters declare on row 24, `TRUSTED_SERVER_KV` and `default`, and none on Axum. | +| 2026-10-18 | Items 5 to 8 leave 2.10. The configuration guide says core applies no lifetime of a declared table and when to push one, the integration guide lists `.with_cache_features(...)`, and `declared_caches` and `check_cache_tables` are `pub(crate)`. The rustdoc of `loading_caches_from` and `LoadingCacheConfig::module`, and the integration guide on a table naming no module, already gave rule 2 in 2.2. Section 3 and 2.8 match the guides. 2.7 and section 3 say the deploy check also refuses a `module` that offers no loading cache. | ## Review log From 084cf7986459dff367db04cf2482bd80b33acaaf Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:27:36 +0000 Subject: [PATCH 17/21] The probe's cache test no longer counts a stock loading cache a_cache_the_probe_declares_is_selected_in_its_own_table counted the 51Degrees module as linked by the stock list, which now links no loading cache module. Its first case links a second loading cache module beside the probe's, a test builder offering core's MemoryLoadingCache, so only a table that is read keeps values, and a second case with the same build and no table keeps nothing. With the probe's cache alone, a declared cache with no table takes it, and with no loading cache module linked it keeps nothing, so those two assertions flip. The comment in core above the step for a declared cache with no table says that a deployment that links one loading cache module holds a module's answers, where it said a stock build did. --- .../tests/seam_probe.rs | 41 +++++++++++++++---- .../src/integrations/mod.rs | 5 ++- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 080f0de086..800ea50420 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -561,12 +561,26 @@ async fn report_with(settings: Settings, integrations: &[IntegrationBuilder]) -> serde_json::from_str(&body).expect("the report route should return JSON") } +/// The id of a loading cache module these tests link beside the probe's. +const SECOND_LOADING_CACHE_ID: &str = "loading-cache.memory"; + +/// Core's `MemoryLoadingCache`, which keeps what it loads. +fn memory_loading_cache() -> Arc { + Arc::new(trusted_server_core::platform::test_support::MemoryLoadingCache::default()) +} + +/// A loading cache module other than the probe's, so a build can link two. +fn second_loading_cache_builder() -> IntegrationBuilder { + IntegrationBuilder::implementations(SECOND_LOADING_CACHE_ID, env!("CARGO_PKG_NAME")) + .with_loading_cache(memory_loading_cache) +} + /// `[cache.seam-probe-answers]` selects the loading cache the probe's answers /// are held in, the way `[cache.robots-txt]` selects core's. Left out, the -/// cache takes the one loading cache module the build links, and this build -/// links two, the stock one and the probe's, so it keeps nothing until the -/// table names one. With the probe's cache not linked, the stock one alone -/// is, and the cache takes it without a table. +/// cache takes the one loading cache module the build links, and keeps +/// nothing when the build links none or several. A stock build links none, +/// so the first two cases link a second module beside the probe's, and only +/// the one whose table is read keeps values. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_cache_the_probe_declares_is_selected_in_its_own_table() { let named = settings_with(&format!( @@ -576,25 +590,36 @@ async fn a_cache_the_probe_declares_is_selected_in_its_own_table() { {PROBE_BLOCK} "# )); - let both = [seam_probe::builder(), seam_probe::loading_cache_builder()]; + let two = [ + seam_probe::builder(), + seam_probe::loading_cache_builder(), + second_loading_cache_builder(), + ]; - let report = report_with(named, &both).await; + let report = report_with(named, &two).await; assert_eq!( report["answers_cache_keeps_values"], true, "the table should select the module it names: {report}" ); - let report = report_with(settings_with(PROBE_BLOCK), &both).await; + let report = report_with(settings_with(PROBE_BLOCK), &two).await; assert_eq!( report["answers_cache_keeps_values"], false, "with two loading cache modules linked and no table the cache should keep nothing: {report}" ); - let report = report_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]).await; + let both = [seam_probe::builder(), seam_probe::loading_cache_builder()]; + let report = report_with(settings_with(PROBE_BLOCK), &both).await; assert_eq!( report["answers_cache_keeps_values"], true, "a declared cache with no table should take the one loading cache module linked: {report}" ); + + let report = report_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]).await; + assert_eq!( + report["answers_cache_keeps_values"], false, + "with no loading cache module linked the cache should keep nothing: {report}" + ); } /// A `[cache.]` table naming a cache no linked module declares is diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 58d80202f5..09ced573f0 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -938,8 +938,9 @@ pub fn loading_caches_from( caches.insert(table, cache); } // A declared cache with no table at all takes the one loading cache - // module the build links, as a table naming no module does, so a stock - // build holds a module's answers without a line in the document. + // module the build links, as a table naming no module does, so a + // deployment that links one holds a module's answers without a line in + // the document. if let [(_, factory)] = offered.as_slice() { let tabled: Vec<&str> = settings .cache From d3c0d4a41a66544a7e07e9314851d10049c65980 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:27:37 +0000 Subject: [PATCH 18/21] The declared cache example names a module a deployment links The configuration guide said that [cache.example-answers] with module = "51degrees" asks the service once however many requests carry the same evidence. A stock build refuses that module, so the guide now says that this holds in a deployment that links crates/loading-cache/51degrees. --- docs/guide/configuration.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d150ea76ea..5cc20afd0b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1856,8 +1856,9 @@ are checked against the ranges above and change nothing unless the module reads its own table and applies them. A module from `crates/geo/example` whose service answers once for each request's evidence declares `example-answers`, the cache that -holds those answers, so `[cache.example-answers] module = "51degrees"` asks -the service once however many requests carry the same evidence. A name is +holds those answers, so in a deployment that links +`crates/loading-cache/51degrees`, `[cache.example-answers] module = "51degrees"` +asks the service once however many requests carry the same evidence. A name is the module's own: `robots-txt` and a name two modules declare are refused. A `[cache.]` table naming a cache no linked module declares is refused by `ts config validate` and when the deployment starts, and the message lists From f9c7cb9422e40788b8abab8ed01891e240f2b1c1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:47 +0000 Subject: [PATCH 19/21] Row 32 takes the final module cache declarations design --- .../2026-10-15-module-cache-declarations-design.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index ec8416ac79..95e5d49639 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -5,7 +5,7 @@ **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-10-12-robots-txt-from-modules-design.md`, `2026-08-27-integration-provider-seam-design.md` **Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 (closed) **Issues:** IABTechLab/trusted-server#1320 -**Last updated:** 2026-10-18 +**Last updated:** 2026-10-26 > **Why this spec exists.** A module that holds its own answers asks core for the cache of the name it chose, finds no `[cache.]` table for that name, and gets the cache that holds nothing, so every request loads the answer again. @@ -62,7 +62,7 @@ A declared name is the cache's name wherever the cache is used. The name is the 2. No `module` selects the one loading cache module the build links, or nothing when the build links none or several. 3. A module's cache is made once however many tables select it. -The new step comes after the tables. A declared cache that has no table at all follows the rule for a table with no `module`, and shares the cache already made for that module when a table made one. A stock build therefore holds a module's answers without a line in the document. The table below shows the result for a declared cache named `answers`. +The new step comes after the tables. A declared cache that has no table at all follows the rule for a table with no `module`, and shares the cache already made for that module when a table made one. A build that links one loading cache module therefore holds a module's answers without a line in the document. Once the 51Degrees module leaves the stock lists (`2026-10-12-loading-cache-design.md`), a stock build links none, so there a declared cache holds nothing, and the Axum test that section 4 describes changes with the stock lists. The table below shows the result for a declared cache named `answers`. | The document | Loading cache modules linked | The `answers` cache | | ---------------------------------- | ---------------------------- | ----------------------------------------------- | @@ -199,7 +199,7 @@ The first two questions ask for a code change and the next two for a decision. T - **A check that a module calls only the names it declared.** `LoadingCaches::for_feature` answers `NoLoadingCache` for a name no one declared, and the cache selected for the name where another module or core declared it, see 2.6. - **A check of a declared name's spelling,** or of whether the name starts with the module's own name. Core reserves only `robots-txt`. `asset_rules` is read as the list of asset rules, so a builder can declare it and no table can then select a module for it. TOML reads `[cache.a.b]` as a table `b` inside a table `a`, so a name with a dot can be written only quoted, as `[cache."a.b"]`. A name with a `/` blurs where the name ends and the key begins in `//`. - **A warning when a declared cache holds nothing** because the build links several loading cache modules and no table names one. -- **A deploy check that knows a deployment's own loading cache module.** The deploy check refuses a table whose `module` names a crate that offers no loading cache, as the state build does, through `check_loading_cache_modules`, which `validate_settings_for_deploy_with` in `crates/trusted-server-core/src/config.rs` calls. It compares `module` with the loading cache modules of the builders it is given, and the deploy check is given core's built-in builders followed by the ones a tool registers, where the adapters are given the stock modules followed by the deployment's own, see 2.4. No built-in builder offers a loading cache, and the `ts` command registers the stock list, which carries the stock loading cache module, so `ts config validate` refuses a table naming a deployment's own loading cache module unless a tool registers that module. +- **A deploy check that knows a deployment's own loading cache module.** The deploy check refuses a table whose `module` names a crate that offers no loading cache, as the state build does, through `check_loading_cache_modules`, which `validate_settings_for_deploy_with` in `crates/trusted-server-core/src/config.rs` calls. It compares `module` with the loading cache modules of the builders it is given, and the deploy check is given core's built-in builders followed by the ones a tool registers, where the adapters are given the stock modules followed by the deployment's own, see 2.4. No built-in builder offers a loading cache, and the `ts` command registers the stock list, which carries no loading cache module once the 51Degrees module leaves the stock lists (`2026-10-12-loading-cache-design.md`), so `ts config validate` refuses a table naming any loading cache module unless a tool registers that module. - **Declared caches in the section "Supplying a loading cache".** The integration guide, `docs/guide/integration-guide.md`, lists `.with_cache_features(...)` among the builder methods, and its section "Supplying a loading cache" does not mention declared caches. ## 4. Acceptance @@ -223,7 +223,7 @@ In `crates/trusted-server-core/src/settings.rs`: In `crates/trusted-server-adapter-axum/tests/seam_probe.rs`, through the Axum router: -- `a_cache_the_probe_declares_is_selected_in_its_own_table` shows three cases. A table naming `seam-probe` makes the cache keep values, and the build links the stock loading cache module as well as the probe's, so the cache would keep nothing if the table's `module` were not read. The probe's cache and the stock cache both keep values, so the case does not show which of them the table selected. The same two modules and no table make the cache keep nothing. The stock loading cache module alone, with no table, is taken and keeps values. +- `a_cache_the_probe_declares_is_selected_in_its_own_table` shows four cases. A table naming `seam-probe` makes the cache keep values, and the build links a second loading cache module beside the probe's, a test builder offering core's `MemoryLoadingCache`, so the cache would keep nothing if the table's `module` were not read. The probe's cache and that second cache both keep values, so the case does not show which of them the table selected. The same two modules and no table make the cache keep nothing. The probe's loading cache module alone, with no table, is taken and keeps values. No loading cache module and no table, which is the stock list with the probe, make the cache keep nothing. - `a_cache_table_no_module_declares_is_refused_by_the_deploy_check_and_at_startup` shows `validate_settings_for_deploy_with` and `routes_with_registrations` refusing a misspelled table with the same message, listing `robots-txt, seam-probe-answers`. These joins have no test. @@ -232,7 +232,7 @@ These joins have no test. 2. **`ts config validate` and `TrustedServerAppConfig::validate`.** The Axum test calls `validate_settings_for_deploy_with`, the function that `ts config validate` and `TrustedServerAppConfig::validate` both reach. No test registers a builder that declares a cache through `register_deploy_integrations`. 3. **A range error on a declared table.** The range test covers `cache.robots-txt` only, so the loop over `CacheSettings::declared` in `prepare_runtime` has no test. 4. **A declared table's lifetimes reaching a cache.** Nothing in core applies them (see 2.3), so no test shows `memory_seconds`, `store_seconds` or `store_idle_seconds` of a declared table reaching a cache. -5. **A declared table choosing one of several loading cache modules.** The core test links one module and the Axum test links two whose caches both keep values, so no test shows which module a declared table selected. Only `[cache.robots-txt]` has a test that tells two modules apart, `a_feature_selects_a_module_by_its_short_name_or_in_full`. No test in `crates/loading-cache/51degrees` or `crates/trusted-server-modules` selects the stock loading cache module for a declared cache by name, and the stock module is reached for a declared cache only as the one module linked, in the third case of the Axum test. +5. **A declared table choosing one of several loading cache modules.** The core test links one module and the Axum test links two whose caches both keep values, so no test shows which module a declared table selected. Only `[cache.robots-txt]` has a test that tells two modules apart, `a_feature_selects_a_module_by_its_short_name_or_in_full`. No test in `crates/loading-cache/51degrees` or `crates/trusted-server-modules` selects the 51Degrees loading cache module for a declared cache by name, and no case of the Axum test links that module, because no stock build links it (`2026-10-12-loading-cache-design.md`), so no test reaches it for a declared cache. 6. **The deploy check's builder list against the adapters'.** No test covers a built-in builder that declares a cache. Only the deploy check would see that cache. 7. **A module loading through a name it did not declare.** Nothing refuses the call, and no test shows what happens for a name no one declared, for a name another module declared, or for `robots-txt`. 8. **A module's cache made once, and shared by a declared cache with no table.** Rule 3 in 2.2 and the sharing in the step for a declared cache with no table have no test. No test checks that two tables naming one module, or a table and a declared cache with no table, are given one cache. @@ -246,8 +246,12 @@ These joins have no test. | 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | | 2026-10-17 | Who can read a copy names the shared store on each adapter, being Fastly's cache, the key-value store the Cloudflare and Spin adapters declare on row 24, `TRUSTED_SERVER_KV` and `default`, and none on Axum. | | 2026-10-18 | Items 5 to 8 leave 2.10. The configuration guide says core applies no lifetime of a declared table and when to push one, the integration guide lists `.with_cache_features(...)`, and `declared_caches` and `check_cache_tables` are `pub(crate)`. The rustdoc of `loading_caches_from` and `LoadingCacheConfig::module`, and the integration guide on a table naming no module, already gave rule 2 in 2.2. Section 3 and 2.8 match the guides. 2.7 and section 3 say the deploy check also refuses a `module` that offers no loading cache. | +| 2026-10-26 | 2.2 says that a build linking one loading cache module holds a declared cache's answers without a line in the document, where it said a stock build does, because no stock build links one once the 51Degrees module leaves the stock lists (`2026-10-12-loading-cache-design.md`). | +| 2026-10-26 | Section 3, the Axum test in section 4 and the fifth join with no test follow the stock lists linking no loading cache module (`2026-10-12-loading-cache-design.md`). The `ts` command's list carries none, so `ts config validate` refuses a table naming any loading cache module unless a tool registers it, the Axum test's first two cases link core's `MemoryLoadingCache` beside the probe's module and a fourth case links none, and no test reaches the 51Degrees module for a declared cache. | ## Review log 1. **Round 1, 2026-10-15.** A review in a fresh session, against `split/32-cache-features` at `0ef3ff78d`. Issues found, three of them blocking. The page said a deployment can shorten a declared cache's lifetimes although core applies none of them, said two modules of one publisher never share a copy although nothing checks the names a module loads through, and said the changelog entry names the three changes for crates outside core although the entry left out two of them. This revision states each as the code stands today, adds the order of a push, the other callers of `loading_caches_from`, the names core does not check, the cache made once and the other untested paths, and corrects the changelog entry, whereas the decisions, and the code changes that depend on them, wait on the open questions in 2.10. 2. **Round 2, 2026-10-15.** A lighter review in a fresh session, checking the round 1 fixes against `split/32-cache-features` at `be4f47842`. Approved, none blocking. This revision corrects the three statements that were only partly true, being what a module that borrows another module's cache name shares, which section the guide's push-order warning covers and which builders the deploy check is given, corrects the round 1 sentence on the code changes, and adds the guide, rustdoc and visibility follow-ups to 2.10. +3. **Round 3.** A review in a fresh session, against `split/32-cache-features`, of the loading cache change in `2026-10-12-loading-cache-design.md`, which reaches this row. Issues found, one of them blocking. The loading cache change named only rows 24 and 43, while section 2.2 of this design said that a stock build holds a module's answers without a line in the document, and the Axum test that section 4 describes counts the stock loading cache module as linked, so both stop being true once no stock build links a loading cache module. This revision says that a build linking one loading cache module holds those answers, that a stock build then links none, so a declared cache there holds nothing, and that the Axum test changes with the stock lists, as the loading cache design sets out. +4. **Round 4.** A review in a fresh session, checking the round 3 fixes against `split/32-cache-features`. Approved for this design, none blocking. The round's one blocking point on the loading cache change was that the first case of the Axum test in section 4 would then pass whether or not the table's `module` is read, and the loading cache design now has that case link a second loading cache module, a test builder offering core's `MemoryLoadingCache`, so that the case still shows that the table is read. From 1539206cd3513578a792595ccc28b65f1271b6f9 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:58 +0000 Subject: [PATCH 20/21] Row 32's design copy calls its pull request ready --- .../specs/2026-10-15-module-cache-declarations-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index 95e5d49639..5d04a5bf28 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -1,6 +1,6 @@ # Design Spec: A module declares the caches it loads through -**Status:** Proposed, 2026-10-15. Written after the code, for draft pull request IABTechLab/trusted-server#1373. Every design document of the stack is in IABTechLab/trusted-server#1342. +**Status:** Proposed, 2026-10-15. Written after the code, for pull request IABTechLab/trusted-server#1373. Every design document of the stack is in IABTechLab/trusted-server#1342. **Author:** 51Degrees (contributed), for Tech Lab review **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-10-12-robots-txt-from-modules-design.md`, `2026-08-27-integration-provider-seam-design.md` **Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 (closed) From a6a7d3b1989882d95cf3ae3d595ac76d20f10b79 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:32:48 +0000 Subject: [PATCH 21/21] Row 32's design copy carries the date it is proposed, 2026-10-27 --- .../specs/2026-10-15-module-cache-declarations-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md index 5d04a5bf28..48f43b885f 100644 --- a/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md +++ b/docs/superpowers/specs/2026-10-15-module-cache-declarations-design.md @@ -5,7 +5,7 @@ **Related specs:** `2026-10-12-loading-cache-design.md`, `2026-10-12-robots-txt-from-modules-design.md`, `2026-08-27-integration-provider-seam-design.md` **Related PRs:** IABTechLab/trusted-server#1373, reviewed on jwrosewell/trusted-server#27 (closed) **Issues:** IABTechLab/trusted-server#1320 -**Last updated:** 2026-10-26 +**Last updated:** 2026-10-27 > **Why this spec exists.** A module that holds its own answers asks core for the cache of the name it chose, finds no `[cache.]` table for that name, and gets the cache that holds nothing, so every request loads the answer again. @@ -246,8 +246,8 @@ These joins have no test. | 2026-10-15 | Corrections from review round 2. What a module that borrows another module's cache name shares, the section the guide's push-order warning covers, the builders the deploy check is given, the round 1 log sentence on the code changes, and the follow-ups for the configuration guide, the integration guide, the rustdoc and the visibility of two functions. | | 2026-10-17 | Who can read a copy names the shared store on each adapter, being Fastly's cache, the key-value store the Cloudflare and Spin adapters declare on row 24, `TRUSTED_SERVER_KV` and `default`, and none on Axum. | | 2026-10-18 | Items 5 to 8 leave 2.10. The configuration guide says core applies no lifetime of a declared table and when to push one, the integration guide lists `.with_cache_features(...)`, and `declared_caches` and `check_cache_tables` are `pub(crate)`. The rustdoc of `loading_caches_from` and `LoadingCacheConfig::module`, and the integration guide on a table naming no module, already gave rule 2 in 2.2. Section 3 and 2.8 match the guides. 2.7 and section 3 say the deploy check also refuses a `module` that offers no loading cache. | -| 2026-10-26 | 2.2 says that a build linking one loading cache module holds a declared cache's answers without a line in the document, where it said a stock build does, because no stock build links one once the 51Degrees module leaves the stock lists (`2026-10-12-loading-cache-design.md`). | -| 2026-10-26 | Section 3, the Axum test in section 4 and the fifth join with no test follow the stock lists linking no loading cache module (`2026-10-12-loading-cache-design.md`). The `ts` command's list carries none, so `ts config validate` refuses a table naming any loading cache module unless a tool registers it, the Axum test's first two cases link core's `MemoryLoadingCache` beside the probe's module and a fourth case links none, and no test reaches the 51Degrees module for a declared cache. | +| 2026-10-27 | 2.2 says that a build linking one loading cache module holds a declared cache's answers without a line in the document, where it said a stock build does, because no stock build links one once the 51Degrees module leaves the stock lists (`2026-10-12-loading-cache-design.md`). | +| 2026-10-27 | Section 3, the Axum test in section 4 and the fifth join with no test follow the stock lists linking no loading cache module (`2026-10-12-loading-cache-design.md`). The `ts` command's list carries none, so `ts config validate` refuses a table naming any loading cache module unless a tool registers it, the Axum test's first two cases link core's `MemoryLoadingCache` beside the probe's module and a fourth case links none, and no test reaches the 51Degrees module for a declared cache. | ## Review log