Skip to content

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

Closed
Jamesr51d wants to merge 387 commits into
IABTechLab:mainfrom
jwrosewell:split/8-module-context
Closed

Jamesr51d wants to merge 387 commits into
IABTechLab:mainfrom
jwrosewell:split/8-module-context

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on the integration seam (#1094), and depends on it because every module this pull request converts is one #1094 moved into a crate of its own. It is the first of the additions that follow the seven pull requests open here, listed in #777.
Compare split/7-integration-seam-impl with split/8-module-context
to see only this pull request's change.

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.

How it was verified

On 359e30a32, locally: format, Clippy with warnings denied on the six adapter targets, trusted-server-core (2,553 passed), Fastly under Viceroy (3,582), Axum with the module crates (897), Cloudflare and Spin.

CI passed on the fork on 359e30a32. CI has not run here, because the branch conflicts with main.

Closes #1267.

aram356 and others added 30 commits September 4, 2026 10:37
The permission model treats permissions as the primitive and says
consent is only one of several ways a permission is established. The
sources did not follow. TCF, a malformed record and the US-style
opt-outs were decoded inline in one function, so a fifth way of learning
a permission could not exist without editing core.

PermissionSignalSource is that seam. It differs from the geo, device and
identity seams in one way that matters: those select one implementation,
because a request has one country and one device answer, while signals
compose, because a request can carry a TCF string and a Global Privacy
Control header at once and both have something to say.

So the combining rule is written down rather than implied by the order
of three if statements. A refusal beats a grant, a grant beats silence,
and silence leaves the place baseline standing. That is not new policy:
it is exactly the precedence the inline version had, where an opt-out
revoked whatever TCF said and an absent signal changed nothing. Writing
it as a rule is the point, because a rule takes a fourth source without
being rewritten.

The three existing models now sit behind the trait unchanged and core
composes them itself, so a deployment configuring nothing behaves
exactly as before. That was the bar for this change and 2,404 core tests
plus the doctests say it is met.

SignalPolicy and its accessors become public, because a seam meant to be
implemented outside core has to hand a source something it can read.
Clippy found that rather than a reviewer, by pointing out that a public
field exposed a crate-private type.

One thing nearly went wrong and is worth recording. The first draft had
the TCF source return Neutral where the original returned Revoke for a
purpose the visitor had not consented to. Silence would have left the
place baseline standing and granted what they declined. An assertion on
the exact original text caught it before it compiled, which is the
argument for matching code you are replacing rather than retyping it
from memory.

Silence and refusal being different is now stated in the trait contract,
because a source that confused them would revoke every permission it had
no opinion on, on every request that did not carry its model.

Verified: 2,404 core tests, 7 doctests, clippy clean at -D warnings,
cargo fmt clean.
The seam added in the previous commit merged every source's answer with a
refusal winning wherever it appeared, so the order of the list carried no
meaning and a later signal could never amend an earlier one. That made a
prompt answer unable to supersede a browser setting, which is the case the
seam exists for.

Permissions now resolve in layers. The country and region rules give the
baseline, then each source is asked in configured order and amends what the
ones before it settled on. A source with no opinion returns Neutral and
leaves the prior value standing, which stays distinct from refusing.

A source is given the baseline, the settled value, the whole ordered list and
its own position in it, so it can look a peer up by name, tell a peer that is
absent from one that is silent, and ask a peer directly what that peer makes
of a permission. That is what makes a rule such as "personalisation is off,
but only because Global Privacy Control set it, so my answer supersedes it"
expressible by whichever source wants it, rather than fixed here. A
consultation goes one level deep, so two sources that consult each other
settle instead of recursing.

Renamed out from under consent to permission_signal. Global Privacy Control
is a setting a browser sends rather than an answer anyone gave to a question,
so consent is one kind of signal arriving here and not the name for all of
them. The README is the module documentation, so there is one copy of the
explanation for the vendor crates to point at.

Three tests asserted the opposite rule, that an opt-out suppressed storage
whatever else the request carried. That rule was written in a comment beside
them and nowhere else; the only specification covering precedence,
docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md, sets out
a fixed chain and says a publisher needing different precedence should raise
it as a configuration change. They now assert the ordered rule.

resolve_with, floor_with and resolve_rules pass the baseline to the signal
closure, since a source cannot amend what it cannot see.

cargo fmt clean, clippy clean on fastly, axum, cloudflare, cloudflare-wasm
and spin-native, 2410 core tests, 166 fastly, 40 axum.
The ordered list was core's own and an operator could not change it, so the
order was real but nobody outside core could set it.

A new [permission_signal] section names the models to run, in order. A model
that is not on the list does not run, and there is no separate switch to turn
one off, so a publisher who does not want to act on Global Privacy Control
removes "gpc" from the list. Leaving the section out runs every model the
build knows about, in the default order, so a signal is never quietly ignored
because someone forgot to list it. An empty list runs none, leaving every
permission at its country and region baseline.

Split the single us-opt-out source into gpc, gpp-sale-opt-out and us-privacy.
One source covering all three could not be pruned: removing Global Privacy
Control would have taken the GPP and US Privacy opt-outs with it. Each still
asks the policy whether its signal counts and what it takes away, so
permissions.yaml keeps the meaning and the list keeps the membership. No
behavior changes by default, because each returns Revoke or Neutral and never
Grant, so three in sequence settle where one did.

An unknown or repeated name is refused at startup by validate_selection,
rather than silently matching nothing and stopping a model being honored.
SOURCE_IDS is what configuration is checked against, and a test asserts it
matches what all_sources actually returns, since the two drifting apart would
let a name validate and then resolve to nothing.

assemble_permissions keeps its signature and runs every model, which is the
default. assemble_permissions_with takes the configured list, and the request
path passes what the operator set.

trusted-server.example.toml ships the full list commented, with a note to
remove what the publisher does not want and a caution that dropping
malformed-record means an unreadable record falls back to the baseline
instead of failing closed.

cargo fmt clean, clippy clean on all five targets, 2423 core tests, 2613
fastly, 40 axum, 43 cloudflare, 83 spin.
malformed-record was never a signalling model. It is the flag
ConsentContext::has_malformed_record sets when a raw TCF, GPP or US Privacy
string arrives and will not decode, and the original code checked it as an
early return ahead of TCF so an unreadable preference failed closed.

Moving the inline chain behind the source trait made it look like a peer of
gpc and tcf, and the configuration work then offered it in the list, so a
publisher could remove it and silently turn off fail-closed handling that was
never optional. It also read as incoherent next to the others: a publisher
who removes tcf is saying which signal they act on, while removing
malformed-record would be saying what happens when a signal they do act on
arrives broken. Those are different questions.

It is now applied ahead of the configured models and regardless of which are
configured, and it is out of SOURCE_IDS entirely.

That also fixes a regression the ordered rule introduced. With it sitting in
the order, a TCF record that consents was asked after the unreadable GPP
string and overwrote the refusal it caused, which the original early return
never allowed. It overrides rather than holding a position, so one model
arriving unreadable is not cured by another arriving readable. Two tests
cover it, including that case.

Startup still logs the selection, and now warns about every model left out
rather than one, since a signal read from a request and then ignored is worth
seeing when someone asks why it had no effect.

cargo fmt clean, clippy clean on all five targets, 2429 core tests, 2619
fastly, 40 axum.
Core now supplies no signal model of its own. The seam that decides whether
a permission is set takes providers from outside core, asks them in the
order configuration gives, and applies the country and region rules to what
they settle on. The four models that lived inline in ec/consent.rs are gone
from core, and the trait they implemented is renamed PermissionSignalProvider
and given what a provider for a scheme core has never heard of needs: the
request as evidence, through RequestInfo, with a defaulted cookie accessor,
alongside the decoded consent record for the schemes core's pipeline already
decodes and caches.

Three things move with the models. The TCF purpose to Data Use table leaves
permissions.yaml and SignalPolicy, because which purpose grants which Data
Use is the TCF scheme's own meaning rather than a deployment's policy, and a
deployment that runs no TCF should carry no table of another scheme's
numbers. Storage withdrawal becomes a provider's answer through a defaulted
withdraws hook, scoped by core to the jurisdiction's storage baseline exactly
as ec_storage_withdrawn scoped it, and is recorded on PermissionState so the
finalize path reads it there. The hardcoded list of model identifiers leaves
settings validation, because which names are valid is only known where the
provider crates are linked, so an adapter's composition root now selects the
providers once at startup through build_permission_signal_providers, which
refuses an unknown or repeated name with a message naming what is available,
and carries the shared list on RuntimeServices.

One behavior of PR3 as pushed is not preserved, and it changed in the four
commits this builds on rather than here. A US-style opt-out used to win over
a consenting TCF record as a fixed rule in code. The providers are now asked
in order and the last with an opinion decides. The default order asks the one
signal with no interface of its own first, Global Privacy Control, and the
three carrying a choice someone made through an interface after, so an answer
given at a prompt amends the header the visitor arrived with, and a deployment
wanting the opposite reorders the list.
Every document that still stated the old rule is corrected here.
Global Privacy Control, the GPP sale opt-out, the US Privacy string and TCF
v2 each become a crate under crates/permission-signal, implementing
PermissionSignalProvider against the record core's consent pipeline already
decoded, so a returning visitor's cached record and the expiry rule are
honored the same way by every reader of the request. The TCF crate owns the
purpose mapping in code, with a test that every identifier in the table is a
real Data Use and none is granted by two purposes, and answers the storage
withdrawal question. The three opt-outs stay separate so a publisher who does
not act on Global Privacy Control can remove it and keep the other two. Each
crate carries the maintainers declaration the vendor crates carry.

Every adapter links the four, in one order, selects them once at startup so a
name no crate answers to fails there rather than on the first request, and
hands the shared list to every request's services. The cross-scheme behavior
that only shows when multiple providers run together, being a prompt's answer
applying over an opt-out, one opt-out standing when another is removed, a
scheme left off the list not running, and withdrawal being TCF's alone and
scoped to the place, is tested in the Axum adapter, which links all four
and runs natively.

Nothing in these crates is vendor-specific, and core links none of them.
A guide page for the seam and the four shipped providers, in the site's
sidebar, covering why the providers are not in core, how a request resolves,
that the order is the policy and what the default order does, withdrawal as
a separate question, and how a scheme is added. The permission model guide
is corrected where it still described the TCF mapping as living in
permissions.yaml or an opt-out as always winning over a consenting TCF
record, and the example configuration now calls the entries providers and
says the TCF purpose mapping is in the crate.
A permission says what may be done with the data. It does not say on what
basis, and a recipient offered data needs both, because it has to decide
whether the terms are ones it accepts and whether it may pass the data on.
So a provider may now declare the terms documents that cover the request,
core carries what every configured provider declared on the permission
state, and the page reads them as `tdls` beside `set`.

A locator is the address of a published document a person can read. The
document must never be edited once published, which is why a version belongs
in its address, because a document that can be rewritten tomorrow means a
recipient can never prove what it agreed to and one edit silently rewrites
the basis of every transaction already sent under it. That is a property of
how a document is published rather than of its address, so the type refuses
only an address nothing could fetch, and says why in its own documentation.
The name matches the `tdl` member the Data Labels work puts on a node of an
OpenRTB request, which is where these travel when a bid request carries them.

None of the four schemes here declares terms, so the list is empty in every
shipped path and the tests use a provider that declares one, through the
production assembly rather than beside it. Model Terms for Marketing (MTM)
is the first scheme that will declare terms and one of many rather than the
only one, since a publisher, a trade body or a regulator can each publish
terms and each set becomes a provider.

An empty list says no terms were declared, which is not the same as terms
permitting anything, so a recipient needing a basis and finding none has
none. That is the reason the state carries the list at all rather than
leaving a reader to assume.

The permission state stops being `Copy`, because it now owns a list whose
length varies. One test helper copied it and now clones it; nothing else in
the workspace was relying on the copy.
Brings the permission signal work on split/3-permissions up to this
branch, so the client-set value path builds on the permission model as
it now stands. The merge has no conflicts.
Brings the permission signal work up to the documentation branch. The
merge has no conflicts.
Brings the permission signal work up to the integration seam.

Eight files conflicted, because both sides changed the same startup and
runtime services code. They are CLAUDE.md, Cargo.toml, Cargo.lock,
platform/types.rs and the app.rs of each of the four adapters. Every
resolution keeps this branch's seam in full, being the registrations,
the Edge Cookie provider resolved at the composition root and the
providers a module supplies, and adds the permission signal providers
beside it. Each adapter selects those once at startup from the four
scheme crates it links and hands them to every request's runtime
services. In platform/types.rs the new field, accessor and builder
method sit next to the device provider, and CLAUDE.md and Cargo.toml
list the seam probe and the four signal crates together.

The resulting tree is the one the local gate run covered before this
merge, and it differs from this branch by exactly the permission signal
work.
The permission signal module documentation opened with the argument for
keeping signaling schemes out of core. That is a position rather than a
description of the code, and the same argument already stands in
docs/guide/permission-signals.md, so the module documentation now points
there and keeps to what core holds.
AGENTS.md asks for a descriptive message on every assertion, and the
tests added with the permission signal seam left 21 without one. Each
now states the rule it holds, so a failure names what broke rather than
printing two values that differ.
Brings the documentation and assertion message fixes up from
split/3-permissions. The merge has no conflicts.
Brings the documentation and assertion message fixes up from
split/4-client-resolve. The merge has no conflicts.
Brings the documentation and assertion message fixes up from
split/5-response-hook-docs. The merge has no conflicts.
Brings main at 066ea3c into split/1-ec-provider. Twelve files
conflicted and one more needed a change without a textual conflict.
Most resolutions keep both sides. Five needed a decision, recorded here
so that no behavior changes silently.

Edge Cookie generation, in ec/mod.rs. Main's IABTechLab#885 creates a row only
when no row holds the key, retries on a collision and binds the request
snapshot to the new row. This branch creates identifiers through the
selected provider. Both are kept. Each attempt asks the provider for a
candidate through the new EcContext::candidate_id, which runs the
reserved-header and identifier-bound checks, then creates the row with
create_if_absent under the provider's canonical key and binds the
snapshot to that key.

Orphan recovery, in ec/finalize.rs. Main's IABTechLab#885 rotates an orphaned
cookie by generating an HMAC identifier directly, which would give a
vendor-provider deployment a built-in identifier. Recovery now asks the
selected provider through candidate_id, keeps main's proof of absence
and retry limit, and does nothing when no provider is selected. Any
headers the provider asks for while creating the replacement are
applied to the response.

Withdrawal, in ec/finalize.rs. Main's IABTechLab#1113 hardening is kept whole,
being finalize_unusable_consent, the existence check inside
write_withdrawal_tombstone, the snapshot write-back and
log_tombstone_outcome. The tombstones are keyed by this branch's
withdrawal_kv_keys, the canonical keys of the cookie and the active
identifier, in place of withdrawal_ec_ids, and the write-back compares
against the active identifier's canonical key. EID ingestion on the
returning and generated paths uses main's collect-then-upsert form
under the canonical key.

Secret references, in config.rs. Main's IABTechLab#1036 resolves secret settings
from the secret store by the paths listed in secret_fields. This branch
makes the legacy ec.passphrase optional and adds the
[ec.providers.hmac] passphrase, which was not listed, so it would have
been read as a literal value. Both are now listed as optional paths and
checked as key references when the configuration is pushed, and the
integration fixture and the example configuration name the secret key
rather than a value.

Spin start-up, in the Spin adapter. Main's IABTechLab#1036 loads settings from
the config store with secret resolution, which fixes the same start-up
failure this branch's Spin commit fixed. Main's loader is taken and
SpinPlatformConfigStore, which nothing else uses, is removed.

The four adapters keep this branch's composition-root provider check,
and Axum, Cloudflare and Spin keep its error propagation from
build_ec_context, alongside main's compiled auction plan from IABTechLab#1016.

Tests. Main's collision-retry tests and its orphan-rotation tests built
their context with no provider selected, which main's code did not
need. They now select the built-in HMAC provider, as an HMAC deployment
does, because with no provider there is nothing to create or rotate. No
other test reaches identifier creation or orphan recovery without a
provider selected. Tests from each side were moved onto the other
side's interfaces, being this branch's six-parameter context helpers,
main's mutable context in ec_finalize_response, and the
[ec.providers.hmac] passphrase in main's two secret validation tests.
Main's IABTechLab#1016 builds the orchestrator and the integration registry from a
compiled auction plan, and limits IntegrationRegistry::new to core's own
tests. The merge of main moved the Axum adapter's state building onto the
plan and took main's imports, but missed the test helper
state_with_uninjected_provider, which still called build_orchestrator and
IntegrationRegistry::new, so the adapter's library tests stopped compiling.
The helper now builds both from the plan, the same way
build_state_with_settings does.
Brings upd/split/1-ec-provider at cb28ad3, which carries main at
066ea3c, into split/2-device-geo. Six files conflicted, all where this
branch's host signals meet main's compiled auction plan or main's
imports. Each resolution keeps both sides' changes, so nothing either
side does is dropped.

The Axum, Cloudflare and Spin adapters keep this branch's provider
calls, which pass the host signals (None in all three) as well as the
injected provider, with this branch's comments saying so. They then
build the orchestrator and the integration registry from main's
compiled auction plan, the way split/1 does.

The Fastly imports keep what each side still uses. app.rs keeps main's
StoreName alongside this branch's build_geo_provider and
FastlyHostSignals. main.rs drops the config_store_name import, because
main replaced its only use with the runtime store names, and keeps this
branch's device provider import. platform.rs keeps main's
BackendNamingPolicy and drops GeoInfo and PlatformGeo, which this branch
moved into the trusted-server-geo-fastly crate.
Main's IABTechLab#1036 resolves secret settings from the secret store by the
paths TrustedServerAppConfig::secret_fields lists, and push validation
skips the validators on those paths because they hold key names.
Merging main registered the [ec.providers.hmac] passphrase, but not this
branch's [ec.providers.host-signals] passphrase. A key name there failed
the passphrase length check when the configuration was pushed, and a
key name long enough to pass would have been used unresolved as the
HMAC key.

The path is now listed as an optional secret and checked as a key name.
EdgeZero matches secret paths against validation error keys verbatim,
and the derived validation keyed the block's errors by the Rust field
name host_signals, so EcProviders now validates each built-in block
under the key the configuration uses. New tests cover pushing a
host-signals key name, resolving one from the mapped store, and still
rejecting a short resolved value.
Brings upd/split/2-device-geo at 0832f00, which carries main at
066ea3c through split/1, into split/3-permissions. Eleven files
conflicted, and CLAUDE.md changed type. Most resolutions keep both
sides. Four needed a decision, recorded here so that no behavior changes
silently.

Withdrawal, in ec/finalize.rs. Main's IABTechLab#1113 moved the path for a request
whose Edge Cookie is not permitted into finalize_unusable_consent, and
tells that path whether the request withdraws with ec_consent_withdrawn,
which looks for an explicit withdrawal signal in the consent context.
This branch decides withdrawal in the permission model, where the signal
providers answer it when the permissions are assembled, and reads the
answer with storage_withdrawn. Main's hardened path is kept whole and is
given storage_withdrawn as that answer. Of the shipped schemes only a
TCF record refusing storage withdraws, and only where the storage
baseline is not granted. On main, has_explicit_ec_withdrawal also treats
a Global Privacy Control, GPP or US Privacy sale opt-out in a US state as
a withdrawal. On this branch those opt-outs suppress without
withdrawing, as the permission model spec records, so the Edge Cookie
headers are stripped and the cookie is kept.

The identifier sent to auction partners, in auction/endpoints.rs and
publisher.rs. Main's IABTechLab#885 makes the identifier an owned value so the
identity-graph snapshot can be written back to the context, and on the
navigation path keeps the active identifier unfiltered for the snapshot
preload and finalization. This branch forwards the identifier only when
sharing is permitted, being storage plus personalized-ad selection, the
same pair that gates EIDs. Both are kept. The snapshot preload still
reads the active identifier, and only the forwarded copy is gated by
sharing.

Settings validation, in settings.rs. Main's IABTechLab#1117 removed the warning
about disabled creative rewriting, because request-time settings loading
logged it on every request. The warning stays removed. This branch's log
of the permission baseline sits in the same function and would repeat
for the same reason, so it now logs at debug level rather than info.

The template cache harness. Main rewrote the configuration edits with a
replace_once helper that stops on a missing target. This branch's
switch to the platform geo provider, which gives the harness a US state
jurisdiction now that default_country is retired, is written the same
way. The harness fastly.toml gets main's local backend and secret stores
as well as this branch's Viceroy geolocation answer.

CLAUDE.md is a symlink to AGENTS.md on main (IABTechLab#923). The 80 lines this
branch added to CLAUDE.md are applied to AGENTS.md instead, and
CLAUDE.md stays the symlink.

The four adapters build main's compiled auction plan (IABTechLab#1016) and then
this branch's permission signal providers. The example configuration
keeps this branch's [permission_signal] notes and main's asset proxy
example.

Tests. Main's endpoint tests from IABTechLab#885 and IABTechLab#1016 called the test helper
with a jurisdiction, where this branch's helper takes the permission
gate. They now build a non-regulated context with the gate open, which
is what their jurisdiction gave them on main. Main's withdrawal tests
from IABTechLab#885 and IABTechLab#1113 used a Global Privacy Control opt-out as the
withdrawal. They now use a TCF record refusing storage and state the
withdrawal answer, as this branch's withdrawal tests do because core
links no provider, and what they check, the tombstone and cookie
handling, is unchanged. This branch's own opt-out test, which now passes
main's mutable context, keeps the case where an opt-out leaves the
cookie in place. Main's asset proxy test from IABTechLab#742 sets this branch's
permissions_script field to None, and its settings acknowledge
single-jurisdiction operation, which this branch requires of an Edge
Cookie provider with no geo provider.
The Windows notes said CI runs the adapter tests on both ubuntu-latest
and windows-latest. No job in this branch's test workflow runs on
Windows, so the sentence now says ubuntu-latest.

The notes also say that trusted-server-cli does not build on a Windows
host, because its dependency edgezero-adapter-fastly uses a standard
library feature that is unstable on Windows, so its tests and the
template cache harness, which builds it, run in WSL too.
Brings upd/split/3-permissions at 3c9f4b0, which carries main at
066ea3c through split/1, split/2 and split/3, into
split/4-client-resolve. Two files conflicted. Each resolution keeps both
sides' changes.

ec/finalize.rs takes main's imports for identity-graph writes and EID
collection, and keeps this branch's expire_ec_resolved_marker beside
them. This branch's two resolved-marker tests from ee07418 sit beside
main's orphan-recovery tests from IABTechLab#885, and now pass main's mutable
context to ec_finalize_response.

docs/guide/api-reference.md keeps this branch's section for the
POST /_ts/api/v1/ec/resolve endpoint and drops the POST /third-party/ad
section, which main's IABTechLab#1016 removed.
Brings upd/split/4-client-resolve at 69e7d57, which carries main at
066ea3c through split/1 to split/4, into split/5-response-hook-docs.
Five files conflicted, all in the documentation set and the example
configuration, and all for one reason. This branch documents split/1's
move of the Edge Cookie passphrase under [ec.providers.hmac] behind
provider = "hmac", still as a plaintext value, while main's IABTechLab#1036 made
the passphrase the name of a secret-store key. Every resolution keeps
this branch's layout with main's key-name model.

In configuration.md, ec-setup-guide.md, error-reference.md and
fastly.md, the TOML examples select provider = "hmac" with
passphrase = "ec_passphrase" under [ec.providers.hmac], and the
environment overrides use TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE
with the key name. Three examples merged cleanly with the plaintext
placeholder still under [ec.providers.hmac] and take the key name too.
The [ec.providers.hmac] reference table gains main's note that the
passphrase is a key name, and its passphrase row says so. The security,
staging and placeholder bullets are main's, because this branch's
bullets described plaintext values in environment variables. The
validation bullets keep this branch's provider bullet and take main's
three, naming providers.hmac.passphrase.

trusted-server.example.toml keeps this branch's host-signal provider
block with a key-name placeholder, because that passphrase is also
resolved from the secret store (a75571d on split/2), and takes main's
partner comment.
The example configuration said the host-signal provider creates the
identifier from the host's TLS JA4 and HTTP/2 signals instead of the
client IP. HostSignalProvider::generate hashes the JA4 and HTTP/2
signals together with the client IP, as the provider's own
documentation says, so the comment now says so.
The configuration guide said a failed geo lookup at request time does
not fall back to default_country, a setting that no longer exists. The
sentence now says what a failed lookup does without naming it.
Christian Pavilonis's review thread on provider response effects asked
core to validate them against the managed ts- cookies, the x-ts-
namespace and framing headers. The check is in place, but several
comments said a rejected effect "fails the request", which is not what
happens. EcContext::generate_if_needed returns the error, and its only
two callers outside tests, the publisher fallback in the Fastly adapter
and IntegrationRegistry::handle_proxy, log it and serve the response
without an Edge Cookie. The reserved_response_effect and
apply_provider_response_headers docs, the comment in
EcContext::candidate_id and the reserved-surface test now say that.
The candidate_id comment no longer implies the rejection matches the
identifier-bounds check in every respect, because that check runs after
the provider's headers are captured.

A new test, a_rejected_provider_effect_never_reaches_the_finalized_response,
proves a rejected header never reaches the response. For each reserved
effect it lets generation fail, runs EC finalization on the same
context, and checks the response carries no forged ts-ec cookie, no
x-ts-ec header and no transfer-encoding. It failed when the header
capture in candidate_id was moved ahead of the reserved check, and
passes with the code as it is. The test helper now hands back the
context even when generation fails, so the test can finalize on it.

The review thread is
IABTechLab#1043 (comment)
Christian Pavilonis's review thread on partner paths asked for
validation and KV normalization to be dispatched by provider code.
Validation already was, but several paths still read or wrote identity
graph rows under the identifier as issued, while generation stores each
row under the owning provider's canonical form. For a provider whose
canonical form differs from the cookie value those paths found no row.

- Pull sync validated the identifier but kept only the raw value. It
  looked the request snapshot up under that value, while generation
  binds the snapshot to the canonical key and every read EC
  finalization makes uses that key, so it skipped every partner. Its
  revalidation read and write-back used the raw value too.
- The admin lookup answered 404 for a row that exists.
- The /auction, publisher navigation and /_ts/page-bids preloads loaded
  a miss, and resolve_auction_eids matched the snapshot by the raw
  identifier, so auctions carried no server-side EIDs.
- The navigation preload also replaced the snapshot generation had just
  bound to the canonical key with that miss, so a newly created
  identifier got no ts-ec cookie on a navigation with no EID cookies to
  ingest.

Pull sync now carries the canonical key and uses it for the request
snapshot lookup, the revalidation read and the write-back. Partners
still receive the identifier as issued, and the pull rate limit key
still hashes the issued identifier, which leaves rate limiting
unchanged for every provider. The admin lookup reads under the key,
reports the requested identifier as ec_id, and adds the key it read as
kv_key, which the API reference now describes. The three preloads load
under EcContext::ec_kv_key and resolve_auction_eids looks the entry up
under the key. EcContext::accepts_id lost its only caller and is
removed.

For the built-in HMAC provider the canonical key is the identifier
itself for every identifier read-back accepts, so these paths behave
as before for HMAC cookies. Apart from the new kv_key field, the one
change an HMAC deployment can see is that an admin lookup given an
identifier with an uppercase hash now finds the row stored under the
lowercase key instead of answering 404.

Each path has a test using CanonicalizingProvider, whose identifier
t0ca~MiXeD.CaseId is stored under t0ca~mixed.caseid, and all seven new
tests failed before the fix. The shared constants for that identifier
now live beside the provider, so the identify, finalization and new
tests use one definition. The known-gap note on EcContext::kv_key_for,
which cited commit 343ac3e from outside this branch, and the matching
note on AcceptedProviders now state which paths key rows through the
canonical form.

The review thread is
IABTechLab#1043 (comment)
Some comments added for the two review threads on provider response
effects and canonical keying claimed more than the code does, and one
changed comparison had no test that depended on it.

The AcceptedProviders doc said pull sync, batch sync and the admin
lookup all read and write rows, but the admin lookup only reads. The
comment in the test
a_rejected_provider_effect_never_reaches_the_finalized_response said a
rejected header kept anywhere on the context would reach the browser,
when EC finalization applies only the response headers the context
holds. The admin lookup docs and the API reference entry for kv_key
named the owning provider as the source of the row key, which does not
hold for a deployment with no provider selected, where the built-in
HMAC identifier format supplies the key. Three other comments now say
precisely what they mean. The EcContext::kv_key_for doc names the
function each reference points at, the EcContext::candidate_id comment
no longer credits an unnamed caller with serving the response, and the
dispatch_pull_sync doc names ec_hash as the input to the rate limit key.

Before replacing the snapshot that generation bound, the publisher
navigation preload compares that snapshot with its fresh read. The
comparison is keyed by the canonical key, yet the navigation tests for
a provider whose canonical key differs from the cookie value passed
with it keyed by the identifier as issued, because their stores
returned the row on the first read. The test for a newly created
identifier's cookie now also runs against a store whose first point
read misses the row generation just wrote. With the comparison keyed
by the identifier as issued, the preload replaced the snapshot with
that miss, EC finalization skipped the cookie and the test failed.
With the canonical key the test passes.
jwrosewell and others added 8 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.
# Conflicts:
#	crates/trusted-server-core/src/auction/endpoints.rs
#	crates/trusted-server-core/src/ec/finalize.rs
#	crates/trusted-server-core/src/test_support.rs
# Conflicts:
#	crates/trusted-server-adapter-fastly/src/main.rs
#	crates/trusted-server-core/src/test_support.rs
main's new EID persistence gate is the permission model's sharing check. EIDs are written to the identity graph only when the storage and personalised-ad permissions are both set, the pair that already gates the bid request, so allows_eid_persistence and its parity test are removed and the tests that described Purpose 4 denial state the permissions instead.
main's rule that partner EIDs are written to the identity graph only with consent for personalised ads is stated in the guides as the storage and personalised-ad permissions, the pair the permission model already uses for sharing.
…d run the Cloudflare auction test with no demand modules

The build script's TRUSTED_SERVER__GIT_VERSION reaches test processes and the strict loader refused it as an unknown field. Cloudflare runs one demand source at a time, so the fixture's two demand modules stopped worker startup.
@jwrosewell

Copy link
Copy Markdown
Collaborator

Moved into this repository as #1349, 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.

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

3 participants