Skip to content

Add one module context for every kind of module - #1349

Open
jwrosewell wants to merge 29 commits into
feature/7-integration-seam-implfrom
feature/8-module-context
Open

jwrosewell wants to merge 29 commits into
feature/7-integration-seam-implfrom
feature/8-module-context

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 16 files, 2137 lines added, 452 removed
docs/guide 1 file, 95 lines added, 8 removed
crates/trusted-server-adapter-fastly/src 2 files, 84 lines added, 24 removed
crates/cmp/sourcepoint 1 file, 49 lines added, 61 removed
crates/cmp/didomi 1 file, 43 lines added, 51 removed
crates/audience/permutive 1 file, 42 lines added, 32 removed
crates/testing/seam-probe 1 file, 41 lines added, 32 removed
crates/tag/google-tag-manager 1 file, 38 lines added, 28 removed
crates/trusted-server-adapter-axum/tests 2 files, 31 lines added, 6 removed
crates/auction/prebid 1 file, 29 lines added, 19 removed
crates/permission-signal/mtm 1 file, 27 lines added, 29 removed
crates/testing/testlight 1 file, 27 lines added, 17 removed
crates/ad-tag/google 1 file, 26 lines added, 16 removed
crates/identity/lockr 1 file, 25 lines added, 15 removed
crates/bot-protection/datadome 1 file, 24 lines added, 16 removed
crates/device/fastly 1 file, 18 lines added, 14 removed
crates/auction/aps 1 file, 15 lines added, 11 removed
crates/permission-signal/tcf 1 file, 15 lines added, 14 removed
crates/permission-signal/gpp 1 file, 13 lines added, 12 removed
crates/permission-signal/us-privacy 1 file, 13 lines added, 12 removed
crates/permission-signal/gpc 1 file, 11 lines added, 10 removed
tools/permissions-inspector 1 file, 9 lines added, 4 removed
crates/trusted-server-adapter-cloudflare/src 1 file, 7 lines added, 1 removed
crates/trusted-server-adapter-spin/src 1 file, 7 lines added, 1 removed
crates/trusted-server-adapter-axum/src 1 file, 3 lines added, 1 removed

Closes

Closes #1267

Spec

Spec: module-context

Test plan

CI on this pull request at 6a0561c passed all 32 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at 6a0561c

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on the integration seam (#1348), and depends on it because every module this pull request converts is one #1348 moved into a crate of its own.

What this pull request does

Core builds one context for a request where it calls modules, and each module names what it needs from it. A value whose use needs a permission is passed only to a module that declares that permission and is granted it on the request.

The trait method takes a ModuleCall, and a new value is one more thing a function may name.

async fn route(
    &self,
    req: Request<EdgeBody>,
    settings: &Settings,
    geo: Option<&GeoInfo>,
) -> Result<Response<EdgeBody>, Report<TrustedServerError>> { ... }

async fn handle(
    &self,
    call: ModuleCall<'_>,
    req: Request<EdgeBody>,
) -> Result<Response<EdgeBody>, Report<TrustedServerError>> {
    call.inject_with(self, req, Self::route)?.await
}

The values a function may name, and where each is absent, are listed once, in the module_context rustdoc. A parameter may be an Option, which is None when the value cannot be passed. A function that names a value itself is not called when it cannot be passed. A route then answers 403 when the request's permissions withhold the value, and 500 when the value is absent where the route runs.

Module Called through What its context carries
Device DeviceModule::detect The request, the evidence, the settings and the services. No permission is resolved yet
Permission signal SignalInput::call, valid_signal and tdls The request, the evidence, the connection, the consent, the settings and the services, and the location when its use needs no permission
Edge Cookie generate and resolve_from_client Every value
Route IntegrationProxy::handle Every value except the evidence, because the route holds the request itself

A request preparer and a response finalizer stay plain functions on the builder. A preparer runs before anything is resolved and holds the request mutably. A finalizer is handed only what its own module left.

Why

Each kind of module had its own parameter list, so handing a module one more value meant changing every implementation of its trait.

What breaks

The module traits change once, on purpose, so that a further value later does not break them again. Every module in this repository is converted.

Trait Before After
DeviceModule detect(&self, request_info, services) detect(&self, call)
EdgeCookieModule generate(&self, request_info, input, services) and resolve_from_client(&self, input, services) generate(&self, call) and resolve_from_client(&self, call, payload). IdentityInput and ClientResolveInput are removed
PermissionSignalModule valid_signal(&self, consent, evidence) and tdls(&self, consent, evidence) valid_signal(&self, call) and tdls(&self, call). required_permissions is new, none by default
IntegrationProxy handle(&self, settings, services, req) handle(&self, call, req). required_permissions is new, none by default
assemble_permissions (consent, evidence, geo, modules) (context, geo, modules)

What it costs a request

  • The Axum, Cloudflare and Spin adapters now resolve the Edge Cookie state for every module route, which is a geo lookup and a store read the route did not make before. A route there now fails when the selected Edge Cookie module is unavailable. Before, a route was dispatched with an empty state and could not be told what the request allowed.
  • A module route and the resolve endpoint copy what the request resolved to (method, address, host and scheme) for the context. Neither copies the headers.
  • The Edge Cookie snapshot, taken only when a module is configured and the request has no identifier or is a navigation, now holds the method, host and scheme beside the path and query.

Closes #1267.

jwrosewell and others added 10 commits October 8, 2026 17:23
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.
…once

A route whose parameter the request's permissions withhold answers 403 as
before. A value absent where the route runs, or needing permissions that are
not resolved there, is a fault in how the module was written or placed, and
answers 500 rather than 403.

The injection rule is stated in the module_context rustdoc and the trait
methods link to it. The route helpers' repeated doc sentence, three repeats of
the Edge Cookie snapshot comment and the guide's copy of the parameter table
are removed. A misplaced test helper doc is moved back to its function, and
the device-signal gate's limit for a module the adapter builds is stated.
The merge applies without a textual conflict. This row hands module
routes the module context, so the Sourcepoint proxy's work moved from
IntegrationProxy::handle into SourcepointIntegration::route, and the
Sourcepoint rewrite (#1183) carried at split/7 lands in route.

The handler tests carried with that rewrite called handle with settings
and services, which this row's handle no longer takes. They call route
with the request and services instead, as the row's other module tests
do, and their unused settings bindings are removed.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:23
@jwrosewell
jwrosewell removed this pull request from stack #1422 October 28, 2026 11:47

This branch has not been deployed

No deployments
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.

Every kind of module has a parameter list of its own, so handing a module one more value changes every trait

2 participants