Skip to content

Add the loading cache core declares and the module that supplies it - #1312

Closed
Jamesr51d wants to merge 602 commits into
IABTechLab:mainfrom
jwrosewell:split/24-loading-cache
Closed

Jamesr51d wants to merge 602 commits into
IABTechLab:mainfrom
jwrosewell:split/24-loading-cache

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on a middleware that prepares (#1311). The additions that follow the seven pull requests open here are listed in #777.
Compare split/23-prepare with split/24-loading-cache
to see only this pull request's change.

What this pull request does

Core declares a loading cache, PlatformLoadingCache in trusted_server_core::platform, which loads a missing value once per key however many requests ask for it at the same moment, gives every request waiting on that load the value or the error, and holds nothing that failed. Each cache is named by a CacheSpec with the lifetimes of its copies, a brief one in process memory and one in the host's shared store, and keys under a StoreKey that starts with the publisher's prefix, so publishers sharing a store never share a copy.

Each cache is selected by naming, in its own [cache.<name>] table, a module that offers a loading cache. A table that names none gets NoLoadingCache, which holds nothing and runs every load. A name no linked crate offers a cache from is refused when the service starts, with the names that could be written. On Cloudflare, which builds the state for every request, that refusal is per request. Each robots.txt module's answer is the first thing held this way, so a module is asked once per refresh however many instances and requests want its rules.

[cache.robots-txt]
module = "51degrees"
memory_seconds = 60
store_seconds = 604800
store_idle_seconds = 2592000

The implementation is a module crate, crates/loading-cache/51degrees, maintained by 51Degrees, written with the published fiftyone-caching crate over each host's own stores, being Fastly's cache and key-value store, Workers KV and Spin's key-value store. Every load runs in its caller. It offers the cache from its builder, the stock list carries it, and an adapter takes the selections from the settings and the builders it is given with loading_caches_from, so no adapter names the crate.

Why

The same answer is asked for by every instance and every request, and a cache that lets every miss load at once asks a module's service as many times as there are requests. One load per key at a time, with the result shared by every waiter, is the pattern the other 51Degrees language packages use, and the crate that implements it is published for every 51Degrees service to use. Core keeps only the interface, so core names no vendor and depends on no package.

What breaks

Holding a robots.txt module's answer is now opt-in. Pull request 9 held each answer in the key-value store on Axum, Spin and Cloudflare. With [cache.robots-txt] module unset, which the example leaves commented out, nothing is held and every request for the file asks each module's service. A deployment keeps the holding by adding the table.

How it was verified

CI passed on 9dc219c41 on the fork. Not exercised: no load has run against a real host store. Fastly's cache is checked on Viceroy, and Workers KV and Spin's store through core's doubles. The page template does not load through the cache.

References #1282.

The integration guide, the integrations overview, the configuration guides
and the onboarding page still described an integration as code inside
`trusted-server-core`, or as "still in core" with the name its crate would
have. Every page integration now has a crate of its own, so they say that:
an integration is a crate under `crates/<type>/<vendor>/` here or in a
repository of the vendor's, and a module's name is that crate's path.

The agent notes list the module crate folders in the workspace layout, and
the onboarding table gains the rows for a module crate and for the stock
list.
Core's error type had a `Prebid` variant that only Prebid raised. Prebid's
page integration and the Prebid Server demand raise `Integration` under
their own names now, as every other module does, and core's error type
names no vendor's variant.

A reader sees no difference. Both variants answer 502 with the generic
message, and the detail is logged as "Integration error (prebid)" or
"Integration error (auction.prebid-server)" where it was "Prebid error".
`models.rs` held `AdResponse` and `Callback`, the response of one ad
server, kept with dead code allowed "for external ad-server
compatibility". Nothing in the workspace reads either type, so the file
goes, with the ten tests of how the two types deserialize.

The `Gam` error variant named Google Ad Manager and nothing raised it, so
it goes from the error type and from the tables that list every variant.
Core's OpenRTB model held `RequestExt`, `PrebidExt`, `ImpExt`,
`PrebidImpExt` and `ImpStoredRequest`, the typed `ext.prebid` objects of a
request and an impression. Core's driver writes an implementation's
extensions as the objects the implementation hands it and uses none of
these types. Only the provider that Prebid Server's crate keeps for its
tests builds a request through them, so they sit beside it there, compiled
for its tests alone.
The limit on the zone a browser names for a slot was
`MAX_PREBID_ZONE_BYTES`, and the admitted request headers were described
as approved for "Prebid transport forwarding". Routing admits both for
every demand source, so the limit is `MAX_ZONE_BYTES` and the headers are
approved for forwarding to a demand source.
Each module crate says who maintains it in `[package.metadata.maintainers]`
of its manifest, the way Prebid.js requires a named maintainer of every
adapter. Three crates did not: the seam probe, the stock list and, in part,
Testlight, which named an owner and no status. They do now, and the
demonstration ad server's entry, which read "No vendor has not yet adopted
this crate", says that the crate is the project's own.

A status is one of three. `vendor owned` is a crate its vendor has adopted,
`seeking vendor owner` is one the Trusted Server maintainers hold for a
vendor, and `project owned` is one that is the project's own.

A test in the stock list crate reads the manifest of every crate under
`crates/<type>/<vendor>/`, and its own, and fails for one with no
declaration, no owner or a status that is not one of the three. The
integration guide states the convention and its checklist asks for it.
Deploy validation reads the integration builders the process registered,
because the app config validates through a trait that takes no arguments.
The `ts` tool registers the modules a stock build ships before it
validates. Three other places validate a configuration in a process of
their own and registered nothing, so once the Prebid Server demand left
core they refused the fixture's `[demand]` source as an implementation
the build does not have:

- the integration test crate's Viceroy configuration generator, which is
  the "Generate integration Viceroy configs" step of the integration
  workflow,
- the integration tests' own configuration envelope,
- two of the command line tool's integration tests in
  `audit_config_diagnostics.rs`, one of which asserted a refusal and
  would have passed for the wrong reason.

Each registers the stock list first, as the tool does. The tool's own
unit test of a refused candidate validates once before the code under
test registers, so it registers too.

The integration test crate gains a dependency on
`trusted-server-modules`.
`generated_blob_verifies_and_applies_origin_override` builds the
envelope and then loads settings from it the way an adapter does at
startup. It called the loader that knows core's own modules alone, so it
refused the fixture's Prebid Server source once that implementation left
core. It passes the stock list, as every adapter does.

The integration workflow runs this binary's tests before the container
tests, so the failure also hid whether those run.
The plain OpenRTB demand implementation is `auction-protocol.openrtb`, and
it now lives in `crates/auction-protocol/openrtb`. The stock list offers
it, so a `[demand.<name>]` table with
`implementation = "auction-protocol.openrtb"` selects it as before and its
`request_ext` and `imp_ext` settings do not change. It is the project's
own, and its manifest says so.

It was the last module in core's `integrations` folder, which now holds
the registry, the builder a module registers with and the JavaScript
asset proxy. Core's built-in list offers no auction implementation.

What reads an ordinary OpenRTB response is core's and not this
implementation's, so `parse_openrtb_response` moves beside the response
extraction it wraps in `auction::openrtb`, and stays public for an
implementation in a crate of its own.

Core's tests of the plan compiler, the routing, the request driver and
the page path ran on the plain implementation. They run on a stand-in
compiled for core's tests only, `auction.plain-fixture`, which takes
every eligible slot, inherits the auction's timeout, leaves its endpoint
as written and writes the `request_ext` and `imp_ext` its table sets, so
both golden requests stay in core unchanged. `demand_named` takes the
implementation its sources run.

Two of core's tests were the implementation's own and went with it,
under names that say what they cover:

- `openrtb_extensions_are_bounded_and_cannot_claim_reserved_fields` is
  `static_extensions_are_bounded_and_cannot_claim_reserved_fields`,
- `standard_static_extensions_have_no_invented_bidder_param_location` is
  `static_extensions_have_no_invented_bidder_param_location`.

The crate also holds both golden requests, signed and unsigned, byte for
byte as core holds them, a test that its endpoint is left as written and
the test that its name is its folder.

The Prebid crate's two tests of a bidder that runs on both sides name the
plain implementation and hand its builder to the validation they call.
Core builds one `ModuleContext` where it calls modules, by borrowing what
the request already holds, and every module called there borrows the
same context. A module is handed a `ModuleCall`, which is the context
together with the permissions that module declares, and its process
function names what it needs as parameters. `ModuleCall::inject` passes
each one in from the context.

A parameter is the request (method, path, query, host and scheme), the
reader's evidence, the connection, the response, the resolved
permissions, the decoded consent, the settings, the services, the
location, the device signals, the Edge Cookie identifier, the document
state or the request's typed extensions. It may be an `Option` of one,
which is `None` when the value cannot be passed, or a tuple of them. A
function takes the module first and up to eight parameters,
`inject_with` passes one argument of the caller's own after the module,
and the function may be async.

The location, the device signals and the Edge Cookie identifier each
carry the permissions the module that produced them declares. Such a
value is passed only to a module that declares every one of those
permissions and is granted them on this request. A withheld value is
`None` for an `Option` parameter, and a call that names the value itself
is skipped, with the reason in the debug log.

Core holds modules as trait objects, which cannot have a generic method,
so each kind of module's trait method will take a `ModuleCall` and the
module's implementation hands its own function to `inject`. That call is
made where the module's type is known, so nothing is boxed, allocated or
copied.

Nothing calls the context yet. The commits that follow hand it to each
kind of module.

`PermissionSet` gains `difference`, and a `Display` that lists its Data
Use identifiers, which the reason for a skipped call uses.
`DeviceModule::detect` takes a `ModuleCall`, and a device module names
what it reads through `inject`. Classification now sees the request it
classifies, being its method, address, host and scheme, and the
settings, as well as the evidence and the services it had before.

The Fastly entry point is the one place a device module is asked. It
builds the context from the Fastly request before that request is
converted, reading the host from the `Host` header and the scheme from
the TLS mark the entry point has already set from Fastly's own metadata.
The module it asks is borrowed, whether a registration supplied it or
the `[device]` selector built it, so the request allocates nothing more
than before.

Classification runs before the permissions are resolved, so no value
whose use needs a permission is passed to it. Signals stay infallible. A
module whose function is not passed what it names answers
`DeviceSignals::unknown`, an unknown device that does not look like a
browser, and a test asks the built-in module with no evidence to show
it.

The built-in module converts in core, and the Fastly module and the seam
probe's in their own crates. `module_context::test_support::request`
gives a module's own tests a request, behind the `test-utils` feature.
`EdgeCookieModule::generate` takes a `ModuleCall`, and
`resolve_from_client` takes a `ModuleCall` and the posted payload. A
module names what it needs through `inject`, or `inject_with` for the
payload. `IdentityInput` and `ClientResolveInput` are gone, because the
permissions and consent they carried are in the context, along with the
request the identifier is created for, its host and scheme, the
settings, the location and the device signals.

`EcContext` snapshots what the request resolved to beside the headers it
already kept, under the same condition, so generation later in the
request and orphan recovery hand the module the request they are for.
The snapshot holds the host and the scheme where it held the path and
the query alone. `candidate_id` takes the settings and builds the
context from the snapshot and its own state. The resolve endpoint builds
the context from the resolve request and the state read for it, and
lends the module the normalized client IP that state holds, the same
form the module sees when it generates.

The built-in HMAC and host signal modules, the `client_fixed` module and
the shared wrapper convert in core, and the seam probe's module in its
own crate. A module whose function is not passed what it names produces
no identifier. The HMAC module still refuses a request whose evidence
carries no client IP.

The test of the evidence a module reads at generate time also checks the
host and address reach it, and the resolve round trip checks the module
is handed the resolve request.
Permission assembly builds one module context per request, carrying what
the request resolved to, the evidence, consent, settings, services and
location, and every signal module is asked in it. `assemble_permissions`
takes that context in place of the consent and evidence, and reads both
from it.

`SignalInput` keeps every field and gains the context, and `call()`
gives a module its call into it, so `signal` can name anything else the
request offers. `valid_signal` and `tdls` take a `ModuleCall`. A module
declares `required_permissions`, none by default, though nothing gated
is passed here because the permissions are what the run produces.
`SignalInput::new` still gives an input for a module asked on its own,
in `ModuleContext::empty`, and `with_context` puts one in a request's
context.

The request view on this path borrows from the request, so assembly
allocates nothing more per request. `http_util` gives the host and
scheme borrowed, and `RequestInfo::from_request` owns copies of them as
before.

The GPC, GPP, Model Terms, TCF and US Privacy modules read their signals
and terms in their own functions, handed to the call, each naming only
what it reads, being the consent record for the four IAB schemes and the
evidence for the Model Terms.

`ModuleContext` is `Copy`, being borrows alone, so a caller with no
request builds on the empty context. The permissions inspector does, and
the combined signal tests assemble in a context. A new core test shows a
module answering for the host of the request it is handed.
`IntegrationProxy::handle` takes the route's `ModuleCall` and the
request, in place of the settings and the services, and a route declares
the permissions it needs with `required_permissions`, none by default.
The registry builds the context for the request it dispatches from the
request's resolved state, so a route can name the permissions, consent,
location, device signals and Edge Cookie identifier resolved for the
request, as well as the settings and the services. The identifier only
reaches a route that declares the permission its use needs, as for every
other kind of module.

Each route hands its own function to `inject_with`, with the request as
that function's own argument, and the function names only what it reads.
Seven routes name the settings and the services, four name the services
alone, and the APS renderer, which answers the same page to everyone,
takes nothing from its call. The tests of a route call its function
directly.

The route takes the request, so the context holds a copy of what the
request resolved to, being its method, address, host and scheme. It
holds no copy of the headers, because the route has the request itself,
so a route is not passed the evidence.

The Axum, Cloudflare and Spin adapters dispatched a module route with an
empty Edge Cookie context, which carried no permissions. They now
resolve it for the request, as the publisher path on those adapters does
and as Fastly does for every request. On a navigation the registry
generates an identifier for the route's context when the permissions
allow, as it does on Fastly. These three adapters do not run the Edge
Cookie finalization that writes the cookie on a response, so on them
that identifier lasts for the one request.

A new test shows a route handed its request's permissions and
identifier, and one that declares nothing seeing the permissions and not
the identifier. `ModuleCall::empty` gives a call outside any request,
for a test of a route that takes nothing.
The response, the document state and the request's typed extension map
leave the context, with `ModuleResponse`, `ModuleExtensions` and the
three builder methods that set them. Nothing here sets them. They belong
to the middleware that follows and arrive with it, so every value the
context offers has a caller that hands it.

The table of what is absent where now says what this tree does. The
evidence is absent in a route, which holds the request itself. The
connection, the permissions and the consent are absent where the device
is classified, and the permissions where the signal modules run. The
settings and the services are absent outside any request, which is the
empty context a module asked on its own is called in.
A new section sets out the module context for a module author: the
function a module hands to its call, the values that function may name,
what happens when one cannot be passed, the permissions a gated value
needs, and what the context carries for each kind of module. The example
is a route that names the settings and, when it may be passed, the
location.
A deployment answers `GET /_ts/permissions` with a page, and
`/_ts/permissions.json` with the same information as data, with no
credential. It shows the permissions resolved for the request that asks,
the signals that produced them and the terms the data is held under, so
a publisher can point a reader, a regulator or an auditor at it and
nobody has to take anybody's word for what was decided.

The answer is the evaluation a page receives as `window.tsjs.permissions`.
`PermissionState::page_value` is now the one place that shape is
spelled, and `page_json` writes it. The endpoint adds what a person
asking why a permission is not set needs and a page does not: whether
storage was explicitly withdrawn, which signal modules the publisher
configured and which of them contributed a signal, and the version that
answered.

The handler is core's, `inspect::permissions::handle_permissions`, and
all four adapters route to it, so the answer is the same on each, which
a parity test checks. It resolves the request's state as a page's is
resolved and writes nothing for the reader. On Fastly it answers before
the Edge Cookie lifecycle, so no cookie is set and no identity row is
written.

The answer is never stored, and is readable from any origin so another
site can render it. A request from another origin carries no cookies, so
that site reads the resolution of a request with no stored signal.
`inspect::config::build_view` gives the settings a deployment is running,
after defaults are applied, with values masked as `XXXX` and every
masked path listed. Anyone can read the scripts a page runs, and this is
what lets anyone read the configuration that runs at the edge.

Three kinds of value are masked.

1. Every secret, whatever the publisher's document says. The settings
   loader now records each leaf it fills from the secret store, with the
   value it wrote, and the view masks those paths, every leaf declared a
   secret, and then any string that still contains one of the values,
   wherever it sits, a key included.
2. Every value sensitive by default, unless the publisher shows it.
   `Redacted` values are, and so are the settings now written with
   `redacted::sensitive`, being the origin URLs, the origin host
   override, the Basic-auth path, and the store names and ids.
3. Everything the publisher hides.

The publisher decides with a new `[inspect]` section, `show`, `hide` and
`config = false`, each entry a path pattern such as
`proxy.asset_routes[].origin_url`. A pattern that would not be honored
as written refuses the configuration, both when a deployment is
validated and when the settings load. That is a pattern that matches
nothing, a `show` naming a secret, a pattern another pattern already
covers, a `show` a `hide` covers, a pattern that changes nothing, and
any pattern beside `config = false`.

Marking is a mode of serialization. `redacted::to_marked_value` wraps
each sensitive value under a marker key while it runs, and every other
serialization, the configuration push included, is unchanged, which a
test checks field by field. The section is left out of a stored
document unless the publisher wrote one, which a test checks too, so an
existing document gains nothing.

The note at the top of `redacted.rs` said the build script compiles the
file. It does not, on this branch or on `main`, so the note goes.

`handle_config` answers `/_ts/config` and `/_ts/config.json`. No adapter
routes to it yet.
`GET /_ts/config` answers the masked view of the running settings as a
page, and `/_ts/config.json` as data, with no credential, on all four
adapters. Each routes to core's `handle_config`, so the view is the same
on each, which a parity test checks.

It says nothing about the request, so on Fastly it is answered ahead of
everything a request passes through, and the Edge Cookie lifecycle does
not run. `[inspect] config = false` answers `404`.

The API reference describes the endpoint, and the configuration guide
describes `[inspect]`, what is always masked and what is masked by
default.
`inspect::data::handle_data` answers `/_ts/data` with the identity graph
record stored against the identifier in the request's own `ts-ec`
cookie, being when the record was made, the consent recorded, the
location and device class kept, and which partners hold an identifier of
their own for the same browser. A deployment that writes an identifier
to a browser can show the person using that browser what it keeps
against it.

The Edge Cookie is `HttpOnly`, so a script on the page cannot read the
identifier, and the page does not undo that.

1. Only a browser opening the address as a page is answered, which the
   browser marks with `Sec-Fetch-Mode: navigate` and
   `Sec-Fetch-Dest: document`. Anything else is answered `403`.
2. The page is sent with `Content-Security-Policy: sandbox`, so the
   document has an origin of its own and a page that opened it cannot
   read it.
3. No identifier is shown. The Edge Cookie's value and each partner's
   identifier show as `XXXX`.
4. It is never stored, and no other origin is told it may read it.

Checked in Chrome against a server answering by the same rule with the
same headers. A page's `fetch` was refused, with and without the two
headers set by the script, and so were a frame and an embedded object.
A page that opened the address could not read it, and could once the
sandbox policy was left out. A service worker registered for the whole
site did read it, as it reads every navigation on the site, and so can
whoever holds an identifier and sends it as the cookie. Neither finds an
identifier in the page, which is why none is shown.

The record is shown as it is stored. A test sets every field the record
has and lists them, so a field added to the record fails until it is
decided whether the field is an identifier.

The page renderer takes a lede of its own, because this address has no
`.json` form, and the mask moves up to `inspect` now that two pages use
it. `ec::request_cookie_ec` hands out the identifier a request's cookie
carries. No adapter routes to the handler yet.
`GET /_ts/data` routes to core's `handle_data` on all four adapters.

Fastly keeps the identity graph, so it hands the handler the graph and
the Edge Cookie module the deployment selects. It answers ahead of the
Edge Cookie lifecycle, so nothing is written for the reader. Axum,
Cloudflare and Spin keep no identity graph, so each answers that nothing
is held, and a parity test checks that the three refuse and answer
alike.

The API reference describes the endpoint, who can read the page and why
no identifier is in it.
…form

A module changes a page through four hook traits today. The integration
seam design replaces them with one middleware contract (section 3.7),
and asks that each converted implementation reproduces its recorded
output. This is the recording, made by the hooks as they are.

`html_processor::test_support::assert_page_is_recorded`, behind
`test-utils`, runs a page through the document pipeline of a deployment
that writes a module's settings and links its builder, and compares what
a reader receives with a file, byte for byte. With `TS_RECORD_PAGES` set
it writes the file instead. The script bundle's `?v=` hash is replaced
by a fixed word, so a recording changes when a module's page change
does and not when a browser module is rebuilt.

Eleven module crates and core's own script proxy each gain one test and
three files under `src/fixtures`, being the settings a deployment
writes, the page the origin sends and the page a reader receives. A
`.gitattributes` line keeps the two pages' line endings as committed,
as the seam probe does for its script.

Between them the recordings pass through all twenty hook
implementations in this tree, being nine attribute rewriters, three
script rewriters, one stream processor and seven head injectors. Two of
the head injectors act on a request their module marked, the Google
Publisher Tag diagnostics and DataDome's tag, and the recordings are of
a request with no mark, so the first writes nothing and the second
writes its tag.

Two things the recordings show as they are. The Google Tag Manager
page holds `'&amp;l='+l` where the origin's snippet holds `'&l='+l`,
because a rewritten script is written back escaped. And Prebid's script
removal takes out a link whose address matches a script pattern, as it
takes out a script.
Jamesr51d and others added 23 commits October 13, 2026 16:44
The wasm clock lint reaches core's build script, which runs on the host.
Core's version_header module is removed, with its call from the finalize
step of the Axum, Cloudflare and Spin adapters and from the Fastly adapter's
health probe and its settings fallback, so no response carries x-ts-version
unless a [[serve]] entry names the service_version middleware. That
middleware now writes the git version the build compiled in from
TRUSTED_SERVER__GIT_VERSION or local git as X-TS-Version, when the build
knows one, and the version the adapter reports for the service as
X-TS-Service-Version, when it reports one. HEADER_X_TS_FASTLY_VERSION, which
nothing wrote, goes with the module.

The health tests of the three adapters prove the probe carries no version,
the middleware's own tests prove each header is written when its version is
known and nothing when neither is, and the publisher tests prove both
headers reach a buffered page and neither reaches a page no entry names the
middleware for. The configuration guide, the getting started guide, the
EdgeZero guide and the changelog say the same.
# Conflicts:
#	docs/guide/configuration.md
@jwrosewell

Copy link
Copy Markdown
Collaborator

Moved into this repository as #1365, one branch of the stack, so the review continues there. This pull request is closed in its favor and its comments stay here for the record.

@jwrosewell jwrosewell closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants