Open the integration seam so a vendor module can live outside core - #1094
jwrosewell wants to merge 208 commits into
Conversation
|
Sequencing note on the While checking today's activity I found that A merge simulation of
Reproduce: The request is the one we made on #940. Please land this stack (#1043 to #1047, #1084 and this PR, all based on |
35584db to
b64b45c
Compare
…ider
First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.
- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
The deprecated [ec] passphrase form still starts for one release
cycle: it maps to provider = "hmac" with a deprecation warning, and a
configuration carrying both forms is rejected. provider = "none"
spells explicit statelessness. A configured block that is not the
selected provider is rejected at startup, as is a block with no
selector.
- Global identifier bounds enforced by core at mint, read-back, and
cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
cap. An identifier outside the bounds is rejected loudly, never
rewritten, so the cookie value and the identity-graph key can never
silently diverge.
- The identity graph is keyed by the provider's canonical form of the
identifier (normalize_id_for_kv), so equivalent representations of
one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
access to the client IP, headers (including cookies), URL path, and
query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
provider, so a vendor provider lives in its own crate and core never
names it. A selected provider the adapter does not inject fails the
request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
proceeding stateless.
Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.
Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.
The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.
Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.
is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
CodeQL's cleartext-logging query treats a call whose name contains "passphrase" as a sensitive source, and because the method mutates the Settings it belongs to, every later log line that prints anything from Settings (store names, timeouts, header names) is reported as writing a secret to a log. The passphrase itself is a Redacted<String> and none of the flagged lines prints it. The method now describes what it does, migrate_legacy_ec_layout, and its behavior is unchanged.
A reviewer raised a P1 against the pluggable Edge Cookie provider work: three of the four adapters broke the provider contract that an unavailable required service or an uninjected provider stops the request. The Axum, Cloudflare and Spin adapters each read the Edge Cookie context with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`, logged a warning and continued with `EcContext::default()`. A deployment whose selected provider could not be built therefore came up and served every request with no identity, silently. The Fastly adapter already kept the report and answered with an error response. `build_ec_context` on the three adapters now returns `Result<EcContext, Report<TrustedServerError>>` and every call site propagates it to that adapter's own `http_error`, the same helper Fastly uses, so all four answer with the same status and shape. The design this implements has the composition root check a selected provider's needs once at startup rather than per request, so `ensure_provider_available` was added to `ec/provider.rs` and is called from `build_state_with_settings` on all four adapters (Fastly included, so the rule is uniform). Building a provider reads no request data, so a selection an adapter can never supply now fails when application state is built, and the three adapters answer every route from their existing `startup_error_router` instead of coming up. Statelessness, meaning no `[ec] provider` selector or the explicit `"none"`, still passes and still serves. The widening question was checked rather than assumed. `read_from_request_with_geo` can only fail from two places: the provider build, and a `Cookie` header that is not valid UTF-8. A malformed cookie value is dropped with a warning by `request_ec_id_if_allowed`, consent parsing returns a value rather than a `Result`, and the geo lookup is already swallowed by the adapter before the call, so no ordinary parse problem reaches the error path and none is turned into a failed request. Tests: each of the three adapters gains a route test proving an uninjected provider fails at startup, and an in-crate test proving `build_ec_context` returns the error rather than a default context. Core gains a test that the startup check rejects an uninjected provider and still allows statelessness both ways. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1)
`Settings::finalize_deserialized` runs derive validation before `Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase` field carries no `#[validate]` attribute of its own, so the advertised 32-byte minimum was only enforced on the new `[ec.providers.hmac]` location. A configuration still on the old form could start with `passphrase = "short"`, or with an empty value, and mint identifiers from keying material the new location rejects. The migration now calls `Ec::validate_passphrase` on the value it is about to move, before it logs the deprecation warning and writes the `[ec.providers.hmac]` block, and reports a configuration error naming the minimum length and the new location. Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives `Settings::from_toml` with the `[ec]` section rewritten to the deprecated form and proves a short value and an empty value are both rejected, and that a passphrase of adequate length still migrates to `provider = "hmac"` with the passphrase in the hmac block. Removing the new check makes that test fail, so it tests the fix rather than the surrounding code. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench)
The provider spec (section 6) says `deny_unknown_fields` is set on both built-in provider config structs, but `HmacProviderConfig` carried no such attribute, so `[ec.providers.hmac] typo_key = "x"` was accepted silently. An operator who mistypes a key gets a deployment that starts and quietly uses the default for the setting they meant to change. `HmacProviderConfig` now sets `#[serde(deny_unknown_fields)]`, matching `Ec` itself and the rest of the settings tree. The struct is a plain field of `EcProviders` rather than a flattened one, so the attribute does not collide with the `#[serde(flatten)]` vendor map alongside it. Tests: `an_unknown_key_in_the_hmac_provider_block_is_rejected` adds an unknown key to the block in the crate test configuration and proves `Settings::from_toml` fails and names the key. Removing the attribute makes that test fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:726 (wrench)
`build_provider`'s `"hmac"` arm mapped over `ec.providers.hmac`, so a deployment that selected `provider = "hmac"` with no `[ec.providers.hmac]` block got `Ok(None)` and ran stateless under a selector that says it has an identity provider. Every other unbuildable selection in the same match already errors. The arm now returns `TrustedServerError::EdgeCookie` naming the missing block, which the startup check `ensure_provider_available` turns into a failed application state on every adapter. `Ec::validate_provider_selection` rejects that pair before settings reach the composition root, so nothing routes through the new arm today. It is the drift guard for the case where the two checks stop agreeing, which is exactly the shape of the defect being fixed, so it is worth keeping rather than leaving the silent branch in place. Tests: `selecting_hmac_without_its_block_fails_loudly` builds the `Ec` programmatically, bypassing settings validation to reach the seam, and proves the error names the missing block. The doc comment's `# Errors` section is corrected in the same commit, since it still claimed no built-in construction can fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:304 (refactor)
The error raised when a provider mints an identifier outside the identifier bounds was written across two source lines without the trailing backslash that joins them, so the 22 spaces of source indentation became part of the literal and the logged message read "...bytes, or outside the cookie-safe alphabet". The continuation is restored, so the message reads as one sentence. The whole of ec/mod.rs was scanned for the same fault, matching every string literal and stripping real continuations before looking for runs of more than one space or a newline inside a literal. This message was the only one. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:444 (nitpick)
The paragraph written for the `EdgeCookieProvider` trait sat at the top of `ProviderCode`'s doc block, so rustdoc rendered it as part of that struct's documentation and the trait itself had no doc comment at all. A vendor implementer opening the trait saw nothing, and a reader of `ProviderCode` saw two subjects run together. The paragraph moves onto the trait and `ProviderCode` keeps only the registry text that belongs to it. The moved sentence was also stale: it said a provider returns `Ok(None)` from `generate`, but `generate` returns a `GeneratedEdgeCookie` and signals "no identifier this request" through its `id` field. The sentence now describes the actual return, with an intra-doc link to the field. `cargo doc --no-deps` reports no warning against either item. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:177 (nitpick)
`ec::get_ec_id` had no callers anywhere in the workspace, and this
branch loosened its filter to accept any well-formed `{code}~` value
with no ownership check against the selected provider. A future caller
picking it up would adopt another provider's identifiers, which
`EcContext` deliberately treats as absent.
The no-callers claim was checked across every crate in the workspace
(the four adapters, the CLI, core, the integration tests, openrtb) plus
benches, tests and docs. The only matches are for a different,
crate-private `edge_cookie::get_ec_id`, which reads the `x-ts-ec` header
as well as the cookie and is what `proxy.rs` and the testlight
integration call.
Deleted rather than realigned, for two reasons. The workspace sets
`publish = false`, so `trusted-server-core` is not distributed and
nothing outside this repository depends on the symbol. And aligning the
filter would mean calling `provider_owns_id`, which needs a
`&dyn EdgeCookieProvider` that a function taking only `&Request` cannot
obtain, so it would have meant changing the signature of a function with
no callers. `EcContext::read_from_request` already performs the
provider-aware read that production uses.
`parse_ec_from_request`, `is_valid_ec_id` and `log_id` all keep other
callers in the module, so nothing else becomes dead. The core README
line that advertised the helper is removed in the same commit.
Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:137 (nitpick)
The `ec/provider.rs` module doc said a provider's constructor takes the services it needs, naming `RequestInfo` as the example, and its opening sentence was garbled where two half-sentences had been spliced together. `RequestInfo` is not a constructor argument. It is borrowed per call as the `request_info` parameter of `EdgeCookieProvider::generate`, so the first thing a vendor implementer read contradicted the trait they were about to implement. `evidence.rs` carried the same claim in its own words, that a constructor takes services as `Arc<dyn Trait>` supplied per request. Nothing in the workspace passes `RequestInfo` that way. Every use site is a `&dyn RequestInfo` argument. Both module docs now describe the real shape, which is construction once at startup from configuration or adapter injection, then borrowed request evidence on every call with nothing retained. The `evidence.rs` title changes to match, and its pointer to the borrowed view `BorrowedRequestInfo` is named alongside `OwnedRequestInfo`. Documentation only, no behavior change. `cargo doc --no-deps` reports no warning against either module. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:4 (nitpick)
The keys `"hmac"` and `"none"` were spelled as bare string literals at
four places: `Ec::validate_provider_selection`, `build_provider`,
`provider_owns_id`'s `provider.id() == "hmac"` check, and a private
`HMAC_PROVIDER_CODE` in `ec/generation.rs`. Nothing tied them together,
so a fifth built-in provider would add a fifth spelling and a typo in
any one of them would compile.
`EcProviderSelection { None, Hmac, Vendor(String) }` now holds the
vocabulary in `ec/provider.rs`, with `NONE_KEY` and `HMAC_KEY` as the
only places those two words are written. Vendor keys are open-ended, so
the catch-all `Vendor` variant takes any other key and
`#[serde(from = "String", into = "String")]` gives the enum an
infallible conversion in each direction rather than a hand-written
visitor. `HMAC_PROVIDER_CODE` moves next to it as a `ProviderCode`
const, built from `HMAC_KEY`, and `generation.rs` uses that instead of
its own copy. `HmacProvider::id` and `HmacProvider::code` return the
same two constants.
`Ec::provider` becomes `Option<EcProviderSelection>`, so the two
validation paths and `build_provider` match on variants rather than
comparing strings, and `Option` still distinguishes an absent selector
from an explicit `"none"` exactly as before.
The configuration surface is unchanged. The selector reads and writes
the same string, so an existing `trusted-server.toml` parses to the same
choice and a config push writes the same key back.
Tests: `the_selector_round_trips_through_serialization` parses `none`,
`hmac` and an arbitrary vendor key from TOML, checks each maps to its
variant, and checks each serializes back to the same string.
`each_selection_builds_what_its_string_key_built_before` proves the
three selections still build what they built before, which is nothing
for `none`, the built-in provider with the built-in code for `hmac`, and
the adapter-injected provider of that id for a vendor key.
Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs (refactor)
A provider's response headers were inserted into the outbound response without any check on what they set. A provider could return `Set-Cookie: ts-ec=...`, including on a request where it minted no identifier at all, and so write the managed identity cookie without going through core's identifier validation or its requirement that a minted identifier have an identity-graph row. It could also overwrite an `x-ts-*` header or a framing header. Core now defends by reserving its own namespace rather than banning `Set-Cookie`, because providers legitimately need cookies of their own. `reserved_response_effect` in `ec/provider.rs` classifies one header and rejects three things: a `Set-Cookie` naming a cookie in the `ts-` prefix core manages (`ts-ec`, `ts-eids`, `ts-tester`), a header in the `x-ts-` namespace core emits and strips, and a message framing or hop-by-hop header (RFC 7230 6.1 plus `content-length`, the same set each adapter's `is_hop_by_hop_response_header` uses). Everything else, a provider's own cookie included, passes through unchanged. The cookie name is read from the raw header bytes so a value that is not valid UTF-8 cannot smuggle a managed name past the check. A rejected effect fails the request rather than being dropped with a log. The check sits in `EcContext::generate_with_provider`, the only place provider headers are captured, next to the identifier-bounds check that already fails the request when a provider mints outside the cookie-safe alphabet. Both are the same kind of fault, a provider breaking its contract, and this branch has already decided that identity problems stop the request rather than serving without identity. Finalization cannot fail a request in any case, since it returns no result. Tests cover the classifier directly (managed cookie, reserved header, framing header, a non-UTF-8 `Set-Cookie`, and the allowed cases), and cover both halves through the organic generate path: a provider setting `ts-ec` with no identifier fails the request, and a provider setting its own `acme-evidence` cookie mints normally and has that cookie reach the response alongside core's own `ts-ec`. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/finalize.rs:57 (P2)
Brings upd/split/3-permissions at a2a1dfc into split/4-client-resolve. That renames `config/permissions/vanilla.yaml` to `config/permissions/sample.yaml` and says in the file, in the guide and on the constant that the sample is for testing and evaluation only, is not a production policy and is not legal advice. The merge had no conflicts. `trusted-server.example.toml` auto-merged, because this branch changes other parts of the same file. Tests. `cargo fmt --all --check` is clean and the core suite passes 2,885 tests.
configuration guide Brings upd/split/4-client-resolve at e17090b into split/5-response-hook-docs, which carries split/3's a2a1dfc renaming `config/permissions/vanilla.yaml` to `config/permissions/sample.yaml`. This branch adds the permissions paragraph in `docs/guide/configuration.md`, which split/3 does not have, so the merge could not correct it. That sentence now names the new path and says the sample is for testing and evaluation only and is neither a production policy nor legal advice, matching the file header and the permission model guide. The merge had no conflicts. `trusted-server.example.toml` auto-merged. Tests. `cargo fmt --all --check` is clean and the core suite passes 2,885 tests. The docs Prettier configuration sets `proseWrap: preserve`, so the edited sentence cannot change the formatting of the file.
…split/5 Brings upd/split/5-response-hook-docs at 672cc60, which carries main at 066ea3c and the permission signal work through split/1 to split/5, into split/7-integration-seam-impl. CLAUDE.md and 22 files conflicted. The auction follows main. Main's IABTechLab#1016 replaced the auction provider table this branch had opened with a compiled auction plan, which the orchestrator and the integration registry share. This branch no longer changes the auction, so AuctionProviderBuilder, its two function types, build_orchestrator_with_providers, the public AuctionOrchestrator::register_provider, the seam probe's auction provider and the test that composed it are gone. compile_auction_plan, build_orchestrator_with_plan, AuctionOrchestrator::from_plan and the test-only register_provider stand as main has them, and the auction module, its README and the auction guide take main's text. The generalized BidRenderer stays, with the APS renderer types in integrations/aps.rs and an unchanged serialized form. - The registry. with_plan gains with_plan_and_registrations(settings, plan, extra). Prebid and APS register from the plan first, as on main, and then the builders run with the duplicate id and preparer handling. The Prebid and APS ids are recorded with the builders, so an outside builder claiming either is still refused naming both sources, as it was when they were builders. new and with_registrations are test-only, as new is on main. - BUILT_IN_BUILDERS holds main's twelve integrations in main's order, with js_asset_proxy first, through IntegrationBuilder::new. aps and prebid leave it, because main makes them plan providers. - Validation follows main's validate_settings_for_deploy and validate_settings_for_runtime, with the builder loop in place of main's list of named integrations. Deploy validation leaves placeholder secret values to runtime validation, as main does since IABTechLab#1036, because secret fields hold key names at deploy time. Prebid, APS and adserver_mock are validated by name, as on main, and the new validation_reaches_every_plan_backed_integration plants a bad block for each, at deploy and at runtime. It failed on adserver_mock before that check was restored. js_asset_proxy validates through its builder and still checks a disabled block. DataDome's deploy validator had the same body as its startup validator and no caller left, so it goes, and so does adserver_mock::validate. - Identity. This branch's asynchronous EdgeCookieProvider::generate, which takes RuntimeServices, meets main's orphaned identifier recovery in finalize. ec_finalize_response and the two recovery functions are asynchronous and take the services, and the Fastly entry point blocks on the finalize as it does on its other asynchronous steps. The geo lookup main added in Didomi is asynchronous and takes the services. - The adapters. Axum, Cloudflare, Fastly and Spin compile the plan, check it for their target, build the orchestrator from it, and build the registry with their registrations over the same plan, so the Edge Cookie provider a module supplies still comes from the registry. - CLAUDE.md is a symlink to AGENTS.md again, where this branch had committed the plain file a Windows checkout writes. - The integration guide no longer describes an auction provider builder. Tests. The merged tree passes cargo fmt, Clippy with warnings denied on the Fastly, Axum, Cloudflare and Spin adapters on their native and wasm targets and on the CLI, codegen and parity crates, and the release wasm builds for Fastly and Spin. Core passes 2,937 tests natively and 2,930 under Viceroy with 7 ignored, and 18 doctests. The Fastly adapter passes 192 tests under Viceroy, Axum 72 including 9 seam probe round trips, Cloudflare 47, Spin 89, the seam probe crate 8 and a doctest, the parity suite 13, the CLI 87 on Linux and the codegen crate 7, and the benchmark smoke run passes.
The Axum adapter checked the [ec] provider selection before it built the integration registry and gave the check no module provider, so a deployment selecting a module's Edge Cookie provider was refused at startup with "Edge Cookie provider `seam_probe` is selected but this deployment's adapter does not provide it", although the request path takes that provider from the registry. The Cloudflare, Fastly and Spin adapters already build the registry first. The check now runs after the registry is built and is given the registry's provider. ec_selector_naming_a_module_starts_the_adapter starts the Axum router with the seam probe's provider selected, and it failed with that error before the change. The selector tests beside it build the registry directly, so none of them reached the adapter's check.
Every name an operator types into configuration is snake_case, so the demonstration provider is now selected with `[ec] provider = "client_fixed"`. The key constant, the two startup messages that spell the key, the page script's log lines, the cargo feature comment, the provider table in AGENTS.md and the resolve endpoint in the API reference all follow. This commit changes only that one name. The cargo feature that compiles the provider in keeps the name `client-fixed-demo`, because a cargo feature name is not a configuration value. The tsjs module id `ec_client_fixed` is unchanged as well, being already snake_case and never compared against the key. The old name is refused when settings load, which every adapter does through `settings_from_config_blob` before it serves a request. The message names `client_fixed` and says what to write, so a deployment still configured with `client-fixed` stops at startup rather than being told to add a provider block that this provider does not need. A block written under the old name does not make the old name acceptable either, because the name is refused before any block is looked for. That is a breaking change, accepted for a major release. Tests. `cargo fmt --all -- --check` is clean and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` passes. The core suite passes 2,886 tests and 8 doc-tests, with and without the `client-fixed-demo` feature, the new test among them. The Axum adapter passes 62 tests, `cargo clippy-axum` is clean, and `cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1` builds. The page script's own vitest file passes 6 tests and ESLint is clean on it.
Every provider type selects the same way, with provider in its own section, and names its providers in snake_case. Permission signals now follow that, so [permission_signal] sources becomes [permission_signal] provider. The key stays an ordered list, and a configuration that leaves it out still runs every provider the adapter links, in the order the adapter offers them. The two hyphenated provider names become gpp_sale_opt_out and us_privacy, while gpc and tcf are unchanged. The crates report the new identifiers, and core's selection messages, the startup log, the module README, the guides, the example configuration and the permissions sample follow. sources is removed rather than accepted alongside provider, which breaks a configuration written against the branch that introduced it. A section carrying sources is refused when the settings are read, with a message naming provider, on each path a deployment reads settings through, being a TOML file, the TOML value ts config push reads, and the JSON config blob read at startup. Reading the section by hand rather than with deny_unknown_fields is what makes that message possible, because a derived refusal can only name sources by declaring it as a field, and would then offer it as a key it expects whenever it refused any other. No provider takes settings, so a [permission_signal.<name>] block stays an unknown field. Its refusal now says that a provider which gains settings will take them there. Tests cover the removed key on each of those paths, a settings block refused as an unknown field, a list surviving a config blob round trip, and, where the real crates are linked, the documented names selecting all four providers in order and an old hyphenated name being refused with the names available.
An integration used to carry its own `enabled` flag, with a default that differed between integrations, so whether one ran depended on the flag, on that default and on whether its block was present at all. It now runs when, and only when, `[integration] provider` names its id, which is the convention the other provider types already follow. The `[integrations]` table becomes `[integration]`, and a configuration still carrying the old name is refused with the move spelled out rather than with a bare unknown-field error. Every integration's `enabled` field, its default function, `IntegrationConfig::is_enabled` and the disabled-schema machinery go with it. A block is written only for an integration the list names: one written for an integration it does not name is refused, as is an id named twice and an `enabled` key left behind in a block. A named integration with no block of its own is read from an empty one, so an integration that takes no settings runs on its id alone and one that requires a setting reports the setting it is missing. The registry builds only the builders the list names, so an integration runs exactly when an operator names it, whatever its builder would otherwise make of the settings, and it refuses an id no builder in the deployment supplies, listing the ids it has. Deploy validation deliberately does not make that check, because a vendor crate the CLI never links may supply the id. The DataDome secret paths move with the table, from `integrations.datadome.*` to `integration.datadome.*`, keeping their optional segments so an integration that does not run is never asked for a secret.
`ts audit` used to flip an `enabled` key in the four active integration stubs the starter template carried. It now writes `[integration] provider` with the integrations it found and can configure on its own, appends the Google Tag Manager block with the container it read from the page, and appends the JavaScript asset proxy block with every asset it found set to `proxy = "disabled"` for review. It names that asset proxy only when it found at least one asset, because a block with no assets is refused. `ts prebid bundle` reads and writes `[integration.prebid]`, the integration-test fixture names the one integration that environment runs and drops the blocks for the ones it does not, and the template-cache harness names Prebid in the list rather than flipping a key. The environment override examples move to `TRUSTED_SERVER__INTEGRATION__<ID>__<SETTING>` and lose the `ENABLED` ones, because the provider list is an array and the overlay replaces only scalar leaves that already exist. The browser Prebid module's operator message points at `[integration.prebid.bundle]`.
Every name an operator types into configuration is snake_case, so the built-in host-signal provider is now selected with `[ec] provider = "host_signals"` and configured with `[ec.providers.host_signals]`. The key constant, the serde name of the typed block, the validation error key, the registered secret path `ec.providers.host_signals.passphrase`, the placeholder-secret report, the two startup messages that spell the key and the provider's own log line all follow. This commit changes only that one name. The `HostSignals` trait, the `host_signals` fields and arguments that carry it, and the prose that calls this the host-signal provider are unchanged, because a host capability is not a configuration value. The test secret-store key name `host-signals-passphrase-key` is unchanged for the same reason, being a name an operator picks for a secret rather than a provider name. The Rust field name and the configuration name are now the same word, so the `#[serde(rename)]` on the typed block goes and the field is declared the same way as `hmac` beside it. That also retires the reason the `EcProviders` validation keys were spelled out by hand, so the note above those keys is rewritten to say what still holds, which is that each key has to match the secret path `TrustedServerAppConfig::secret_fields` registers. The old name is refused when settings load, which every adapter does before it serves a request. The message names `host_signals` and says what to write. The refusal comes before any block is looked for, because a block left behind under the old name is captured as a vendor block, so the old selector would otherwise find that block, pass the settings check, and fail later in provider resolution with a message about an adapter that supplies no such provider. That is a breaking change, accepted for a major release. Tests. `cargo fmt --all -- --check` is clean and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` passes. The core suite passes 2,764 tests and 5 doc-tests with 4 ignored, the new test among them. The Axum adapter passes 43 tests across its three binaries, `cargo clippy-axum` is clean, and `cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1` builds.
The Edge Cookie provider blocks move from [ec.providers.<name>] to [ec.<name>], so identity follows the one convention every provider type uses, where [<type>] provider = "<name>" selects and [<type>.<name>] holds that provider's settings. The [ec.providers] table is gone, and a configuration still carrying it is rejected with the new location in the message. A block exists only when the provider has settings. The built-in hmac provider has a required passphrase, so selecting it still needs [ec.hmac], while a provider with no settings needs no block at all. Only the adapter that injects a provider knows whether that provider has settings, so core no longer demands a block for a name it does not supply itself. A block may name the implementation it configures with implementation = "<id>", which makes the block's own name a label of the operator's choosing. [ec] provider = "primary" with [ec.primary] holding implementation = "hmac" and a passphrase configures the built-in provider under a name that means something to the deployment. Everything that resolves the selection now reads the implementation rather than the label, covering the built-in lookup, the matching of a provider the adapter injects, the check that a selected implementation has the settings it needs, and the errors. An implementation this deployment cannot build fails startup naming the implementations it does have. The fixed [ec] keys stay reserved and cannot name a provider, every other key in the section has to be a table, and a key that is not one is reported as the unknown field it almost certainly is, so a typo such as ec_stor is still caught with a sensible message. Provider names and implementation ids are snake_case. A block the selector does not name still fails startup, as it did before. Secrets follow the blocks. TrustedServerAppConfig::secret_fields now lists ec.hmac.passphrase, and EdgeZero's path segments cannot say "whatever name the operator chose", so core reads the labeled blocks out of the configuration itself through the new ConfiguredSecretFields trait and resolves their passphrases from trusted_server_secrets in the same pass. Push-time validation, where those fields hold key names rather than secrets, no longer runs the passphrase value check against a labeled block's key name. The check itself is unchanged wherever settings are loaded with their secrets resolved. The legacy [ec] passphrase shim still works and now points at [ec.hmac].
Core named three vendors. The auction plan compiled a fixed list of
profiles, `standard`, `prebid-server` and `aps`, and would accept only
one ad server, the demonstration mock. A vendor could not ship a demand
source without editing core, which is the coupling the integration seam
exists to remove.
Demand and the ad server are now provider types, configured the way the
Edge Cookie, location, device and permission signal providers are:
[demand]
provider = ["pbs_main"]
[demand.pbs_main]
implementation = "prebid_server"
endpoint = "https://pbs.example/openrtb2/auction"
timeout_ms = 1500
[adserver]
provider = "adserver_mock"
[adserver.adserver_mock]
endpoint = "http://127.0.0.1:6767/adserver/mediate"
An integration builder registers an implementation, and a builder that
supplies only implementations is not a page integration a deployment can
name. `openrtb`, `prebid_server`, `aps` and `adserver_mock` register that
way, so core names no vendor and a vendor's own crate registers on the
same terms.
The shared `provider_table` module carries the rules every provider type
follows, so each type reports the same faults the same way. A table its
type's `provider` does not select refuses startup rather than sitting
unread. An implementation this build does not have refuses startup and
the message lists the ones it does have. Every implementation rejects a
setting it does not know, so a misspelt key fails rather than being
dropped. A demand or ad server endpoint must be HTTPS, or plain HTTP to
a loopback address, so a local test stack runs without certificates and
nothing leaves the machine unencrypted.
`auction::demand` is the seam itself. An implementation states the
standard `OpenRTB` field choices it makes, adds its own extensions,
adjusts its outbound headers and reads its own responses. The shared
driver builds every other field, enforces privacy, and applies the
notification policy and routing diagnostics the same way for all of
them, so an implementation can leave data out of what enforcement
approved and can never add data enforcement removed.
APS stops being an integration. It is selected in `[demand]`, its
`rendering_mode` sits in its own demand table, and the renderer
registers from the plan. Two APS sources that disagree on the rendering
mode refuse startup, because one page can be rendered only one way.
The word "mediator" is replaced by "ad server" throughout. The debug
option `include_mediator_response` becomes `include_adserver_response`
and the response metadata strategy `parallel_mediation` becomes
`parallel_adserver`.
Breaking changes an operator sees, each refused at startup with a
message naming the new home:
`[auction.providers.<id>]` -> `[demand] provider` and `[demand.<name>]`
`protocol` and `profile` -> `implementation`
`profile_config` fields -> the settings flat in the table
`[auction] mediator` -> `[adserver] provider`
`[integrations.aps]` -> the `aps` demand table
`[integrations.adserver_mock]` -> `[adserver.adserver_mock]`
Every guide that showed `[integrations.<id>] enabled = true` now shows the `[integration] provider` list beside the block, the per-integration `enabled` rows are gone from the settings tables, and the pages that told an operator to enable an integration say to name it. The integration guide's seam tables say that a build function runs only for a module the list names and that the CLI accepts an id it has never heard of, which the registry then refuses at startup, so a vendor reads both halves of that gap in one place. The creative processing page and the repository notes said the server concatenates the modules of the enabled integrations, and they now say the modules of the integrations that run. The CHANGELOG carries the migration the startup error points at, and the two entries in the same release that named the old table are brought with it.
The per-integration switch is gone, so the word goes with it. The registry query `integration_enabled` is now `integration_runs` over a `running_integration_ids` list, `gpt_diagnostics::is_enabled` is `runs`, and `validate_enabled_integrations` is `validate_integration_blocks`, which is what its own doc comment already said it does. Nothing outside these files used either name. The comments that called a module registered but not enabled now say that `[integration] provider` does not name it, which is what the errors beside them already told an operator, and the publisher cache tests name the new table in two comments and in one assertion message. The DataDome module doc showed `enabled = true` in its example block, which startup now refuses, so the example names the integration in `[integration] provider` instead. A few comment sentences in the template, the integration test fixture and two doc comments are joined with linking words rather than with a colon, to match the house style.
The configuration file spelled the same idea four different ways. An integration was switched on by `enabled = true` inside its own block, a demand source by a key in `[auction.providers]`, a permission signal by a `sources` list, and an Edge Cookie provider by a nested `[ec.providers.<name>]` block. Someone writing one of these files had four patterns to learn and no way to tell which fault a mistake would produce. The new page `docs/guide/configuration-rules.md` sets out the single rule, what Trusted Server checks and when, and why one syntax plus refusal at startup helps the people who run deployments and the people who write providers. It carries a complete worked example and a table from the previous layout to the new one. The rest of the documentation, the example configuration, the integration fixtures and the template-cache script follow the same convention, so nothing shows a reader a shape the code no longer accepts. Two claims are corrected rather than repeated. `ts config validate` does not run the Edge Cookie, location, device or permission signal selection checks, which live in the path only a running instance takes, so the page says plainly which checks happen at push time and which at startup, and that a passing validate is not proof that a change to those four will start. The auction orchestration guide no longer describes an ad server as a mediator.
Both halves of the provider convention land together, so the configuration file has one rule rather than two new ones. The integration half brings `[integration] provider`, which is the only switch an integration has. The demand half brings `[demand]` and `[adserver]`, and it takes APS and the ad server mock out of the integration table, because neither is a page integration. Where the two disagreed the resolution follows that split: the integration branch owns `[integration]`, its registry and its documentation pages, and the demand branch owns the ad server, APS and Prebid Server as implementations. Core reserves only the Prebid id now, since APS and the ad server mock reach the registry through the compiled plan rather than through `[integration] provider`, and the APS renderer registers from the demand sources the plan selected.
The adapter test still built an `[auction.providers]` entry, which the provider convention removed. It now selects an APS demand source the way a deployment does, so the test proves the renderer registers from the compiled plan rather than from a table that no longer exists.
Every adapter's startup test still built `[auction.providers]` entries, which the provider convention removed. Each now selects its sources the way a deployment does, so the tests still prove what they were written to prove: that the APS renderer route is registered from the compiled plan, and that Cloudflare and Spin refuse a plan with two demand sources because their HTTP clients run one request at a time. The Spin adapter gains serde_json as a development dependency, which is what writing a settings table in a test needs.
Brings the Edge Cookie provider block layout, where each provider has its own [ec.<name>] table and an optional implementation = "<id>" line, under the host-signal provider rename this branch already carried. Both changes are the same decision applied to different parts of one section, so the host-signal provider is now selected with [ec] provider = "host_signals" and configured in [ec.host_signals], not [ec.providers.host_signals]. Four files conflicted. crates/trusted-server-core/src/settings.rs. split/1 replaced the typed EcProviders struct with EcProviderBlocks, a map of the blocks written under [ec], in which only the hmac implementation keeps typed settings and everything else is held as the raw values an adapter reads. This branch had added host_signals as a second typed built-in beside hmac, so the resolution gives it the same standing in the new model rather than demoting it to raw values. EcProviderSettings gains a HostSignals variant, EcProviderBlock gains host_signals_settings, EcProviderBlocks gains host_signals_blocks beside hmac_blocks, read_provider_block reads a host_signals block into the typed config, and From<HostSignalsProviderConfig> builds the block. The block-required check in validate_provider_selection now covers both implementations built into core, because both take a passphrase. The placeholder-secret report names the block the operator wrote, as it already did for hmac. The refusal of the old host-signals spelling is kept and its message now sends the operator to [ec.host_signals]. That refusal moved to the top of validate_provider_selection and now covers a block left under the old name as well as the selector, because split/1's snake_case rule runs over the block names first and would otherwise refuse host-signals with the general rule instead of the message naming the spelling to write. The tests of both branches are kept, with this branch's host-signal test rewritten onto the new block layout. crates/trusted-server-core/src/ec/provider.rs. Both branches edited the resolution arms and the module documentation. build_provider keeps the host_signals parameter this branch added, because the host-signal provider needs a service only some hosts supply, and the host-signal arm of resolve_named_provider now reads its settings out of the block the selector names rather than a fixed field. BUILTIN_PROVIDER_KEYS keeps both entries under split/1's wording, which calls them implementation ids. The documentation keeps split/1's [ec.<name>] spelling together with this branch's statement that the host-signal provider is built per request. crates/trusted-server-core/src/config.rs. The registered secret path for the host-signal passphrase drops the removed providers segment and becomes ec.host_signals.passphrase. Push-time validation reads the host-signal blocks the same way it reads the hmac ones, and the reader that finds a built-in provider configured under a label of the operator's choosing now looks for either implementation, so a labeled host-signal block resolves its passphrase too. crates/trusted-server-core/src/config_payload.rs. Both branches added a key to the test secret store. Both are kept, being separate keys serving separate tests. Two follow-on fixes outside the conflicts. The tests this branch had written against the removed EcProviders type now go through a new select_host_signals_provider test helper beside select_hmac_provider, and the build_provider calls split/1 had reduced to two arguments take the host_signals argument again. Tests. cargo fmt --all -- --check is clean and cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings passes. The core suite passes 2,782 tests and 5 doc-tests with 4 ignored. The Axum adapter passes 43 tests across its four binaries, cargo clippy-axum is clean, and cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 builds.
…it/2 Brings up the Edge Cookie provider block layout from split/1, where each provider has its own [ec.<name>] table, together with split/2's rename of the host-signal provider to host_signals. Both are the same decision this branch applies to permission signals, which is that every provider name an operator types is snake_case and a provider type is a top-level table whose provider key selects what runs. Nothing conflicted. The permission signal work this branch carries sits in its own section and its own module, so the Edge Cookie section changes merged alongside it. One fix the merge needed. A test of the jurisdiction acknowledgment built its stateless case by deleting the hmac provider block from the test configuration by text, and it still named that block [ec.providers.hmac]. Under the new layout the block is [ec.hmac], so the deletion would have matched nothing and left a provider block configured with no selector, which the new layout rejects. The test now names the block it means. Tests. cargo fmt --all -- --check is clean and cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings passes. The core suite passes 2,874 tests and 8 doc-tests with 4 ignored. The Axum adapter passes 64 tests across its five binaries, cargo clippy-axum is clean, and cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 builds.
…rom split/3 Brings up the Edge Cookie provider block layout from split/1, the host_signals rename from split/2 and the permission signal provider key from split/3, under the client_fixed rename this branch already carried. All four are the same decision, so this branch's demonstration provider now sits in the same layout as the rest. Two files conflicted. crates/trusted-server-core/src/ec/provider.rs. Both branches added constants and resolution arms. The client_fixed constants and its resolution arm are kept, listed in BUILTIN_PROVIDER_KEYS beside the two providers that derive an identifier at the edge, under split/3's wording, which calls these implementation ids rather than names. The arm now matches on the implementation the selected block names rather than the selector itself, which is how the other arms read since the block layout landed, so the demonstration provider can be configured under a label as any other provider can. check_named_provider_configuration needed a real decision. This branch wrote it to answer two questions, which are whether the build compiles a name in and whether the name needs a settings block, the second by looking every name up in [ec.providers]. The block layout removed that lookup and moved the block question back into the settings, where it is now asked only of the implementations core knows take settings, because a provider an adapter injects has its block read by that adapter and core cannot say whether it needs one. Reinstating a block requirement for every name would undo that, so the function keeps only the question this branch added it for, which is whether the demonstration provider is compiled into this build, and the settings ask the block question. The function's own reasoning still holds for what it keeps, which is that only the resolution knows what is compiled out. The refusal of the old client-fixed spelling moved with it into the settings, beside the refusal of the old host-signals spelling, and both now run before anything else. They have to, because the block layout holds every provider name to snake_case and both old spellings break that rule, so leaving either refusal later meant an operator got the general rule instead of the message naming the spelling to write. Both refusals cover a block left behind under the old name as well as the selector, through one names_retired_provider helper. crates/trusted-server-core/src/settings.rs. The Ec struct keeps this branch's resolve_allowed_origins field and loses the providers field the block layout replaced. resolve_allowed_origins is a fixed key of the [ec] section, so it joins EC_SECTION_KEYS, without which the section would read it as a provider block. Four of this branch's tests followed the new layout. The one that proves a provider with no block validates now says why the settings no longer demand one, the old-spelling test builds its configuration through the [ec] section helper rather than by deleting the hmac block by text, the missing-block error names [ec.hmac], and the stale-block test builds its block through the shared test helper. Tests. cargo fmt --all -- --check is clean and cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings passes. The core suite passes 2,909 tests and 8 doc-tests with 4 ignored. The Axum adapter passes 64 tests across its five binaries, cargo clippy-axum is clean, and cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 builds.
…guides Brings up the Edge Cookie provider block layout from split/1, the host_signals rename from split/2, the permission signal provider key from split/3 and the client_fixed rename from split/4. All four are the same decision, which is that every provider name an operator types is snake_case, a provider type is a top-level table, and a provider key inside it selects what runs. Nothing conflicted. The response hook and the documentation set this branch carries sit beside the sections those renames touched. The documentation needed the work the clean merge did not do. These guides were written against the old layout, so they still told operators to write a table that is now refused and to select providers by spellings that now stop startup. The configuration guide, the Edge Cookie setup guide, the error reference, the Fastly guide and the key rotation guide all move from [ec.providers.hmac] to [ec.hmac], and the environment override for that passphrase becomes TRUSTED_SERVER__EC__HMAC__PASSPHRASE, which is the path the settings now hold it at. The configuration guide's [ec] reference needed more than a rename. Its rule that every selection must have a matching block is no longer what the code does, because a provider has a block only when it has settings, so it now says which providers need one and which do not, and it explains the implementation key that lets a block carry a label of the operator's choosing. Its provider list and its validation notes follow the snake_case names, and the resolve_allowed_origins key this stack added to the [ec] section is documented alongside the rest. The Edge Cookie guide named the host-signal and demonstration providers by their old spellings throughout, including in a diagram label, and now names them host_signals and client_fixed. The cargo feature that compiles the demonstration provider in keeps the name client-fixed-demo, because a cargo feature name is not a configuration value. The example configuration's commented-out host-signal block carried both the old table and the old name, and is now [ec.host_signals] selected by provider = "host_signals". Tests. cargo fmt --all -- --check is clean and cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings passes. The core suite passes 2,909 tests and 8 doc-tests with 4 ignored. The Axum adapter passes 64 tests across its five binaries, cargo clippy-axum is clean, and cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 builds.
This brings the rest of the stack onto the seam branch, so every provider type in the file now follows one rule rather than the demand and ad server pair following it alone. Where the two sides disagreed, each kept what it owns. The Edge Cookie side owns `[ec.<name>]`, the optional `implementation` line and the label-aware passphrase handling, so the secret paths become `ec.hmac.passphrase` and `ec.host_signals.passphrase` and a block written under a label of the operator's choosing has its key name read out of the configuration rather than from a fixed path. The seam side owns `[demand]`, `[adserver]` and `[integration]`, the ad server vocabulary and the corrected note that `ts config validate` does not run the provider selection checks. The adapters keep this branch's field name for the Edge Cookie provider they resolve per request, and take split/5's corrected selector name for the permission signals, which is `provider` rather than `sources`. Three test fixtures still wrote `[ec.providers.<name>]`, which startup now refuses, so they are restated in the new shape.
Four sentences in the guides joined their clauses with a semicolon, and one table row grew past its column. They now read as one sentence each, and the table is realigned, so the pages follow the house style the rest of the guide follows.
The seam promised that an implementation decides only its field policy, its own extensions, its outbound headers and how its responses are read, while the driver builds every standard field and enforces privacy. The two request hooks did not hold that promise structurally. augment_request received the whole OpenRTB request, so an implementation could rewrite any standard field the driver had built, and prepare_outbound received the whole outbound request, so it could replace the body. augment_request now receives the extension objects and nothing else, one for the request and one for each impression, each impression beside the routed slot it was built from. That removes the zip by position that Prebid Server used to pair impressions with slots, which a slot with no valid format could have misaligned. prepare_outbound now receives the outbound headers alone, and the driver captures the headers as they will be sent, after the implementation has added its own. The driver also refuses a request whose extension claims ext.trusted_server, because that object is the driver's signature and identity block and no implementation may write it. The plain OpenRTB implementation already refused it in its static extensions at startup, and now the driver holds the line for every implementation.
… the code does The configuration rules page promised that every provider rejects a setting it does not know, and nine of the thirteen page integrations did not. A misspelled key in a Didomi, Google Tag Manager, GPT, asset proxy, Lockr, Permutive, Sourcepoint, Testlight or Next.js block was ignored. Each of their settings types now refuses unknown keys, and a test walks every built-in page integration to prove a planted key fails deploy validation naming the integration and the key. The rules page and the CHANGELOG now say only what the code does. The push-time list names the refusal of an unselected block, of an enabled key left behind and of the removed [integrations] table, rather than a typo in a switched-off block being caught, which under the new selector cannot exist. The startup list says that a module declaring an identity or device provider no type selects is logged, which is what the registry does, rather than reported. The two overlapping CHANGELOG entries for the convention and for the integration selector become one, carrying what the second said that the first did not. The rest is the sweep before these changes go public: the mediator rename had left "a ad server", "ad server=none" and a mangled function name in comments, an environment example carried a stray separator, the Prebid table header in the example configuration had lost its line break, and the getting-started guide still sent readers to the [auction.providers] table this stack removed. Em dashes and clause-joining marks in lines this stack added are gone, the loopback endpoint test uses a documentation address rather than a private one, and the documentation tables are realigned.
# Conflicts: # docs/guide/configuration.md
The fixture selecting a vendor provider this adapter does not inject writes its settings in [ec.acme], as every provider block is written since the tables moved under [ec], and the comment above it still named the removed [ec.providers.<key>] table. The comment now says what the fixture does, in the words the other branches of this series already use.
…te's example Two audit tests counted the string [[integration.js_asset_proxy.assets]] in the draft configuration to prove how many asset entries the audit wrote. The draft is built on the example configuration, which now documents that array as a commented example, so the count included the example and the two tests failed on Linux, which is where CI runs them. The first test now reads the draft as TOML and counts the entries in the array the audit wrote, which is what it meant to check, and the second asserts that no uncommented line opens that array. Both stay silent about the example, because a commented line configures nothing.
… writes The environment overlay tests built their project by adding an [auction.providers.pbs-main] table and an example-bidder route naming it to the integration fixture, which is the layout this series removed. Every test that pushed or validated that project failed on Linux, where CI runs the CLI job, because [auction.providers] is refused with the move spelled out and the hyphenated name is not snake_case. The fixture already carries [demand.pbs_main] with its endpoint and the [auction.bidders.example-bidder] route that names pbs_main, so the project no longer adds a second copy under the old names. The endpoint overlay now addresses TRUSTED_SERVER__DEMAND__PBS_MAIN__ENDPOINT and the pushed envelope is read back from the demand table, and the legacy provider list test asserts the refusal names [demand] provider, which is where the message sends an operator, rather than the CHANGELOG, which it does not mention.
Three fixtures in the Fastly adapter's own tests still described the auction the old way, two with an [auction.providers.prebid] table and one with an empty providers map, which this series refuses with the move spelled out. Those tests run only under Viceroy on wasm32-wasip1, so no native gate reached them and the first of them crashed the CI job. The two Prebid fixtures now select a demand source named prebid whose implementation is prebid_server, with the same endpoint and timeout, and the empty map is gone because an empty demand list is the default. All 192 tests in the adapter's binary pass under Viceroy 0.17.0.
Stacks on the provider documentation set (#1047), the last of the five pull
requests that decompose #838, so this pull request sits on the whole provider
series. It also depends directly on the
EdgeCookieProviderandDeviceProvidertraits from the Edge Cookie provider seam (#1043) and thedevice and location seam (#1044), whose providers a registration can now carry,
and on the permission model (#1045), where an unset
[geo] providermakes nohost geo call. The stack has six pull requests
(#1043, #1044, #1045, #1046, #1047, #1094), each targeting
main, with this onesixth.
Compare
split/5-response-hook-docswithsplit/7-integration-seam-implto see only this pull request's change.
The design specs for the series are carried by the spec pull request (#1084),
which has no code. This pull request implements
2026-08-27-integration-provider-seam-design.md,where sections 3.1 to 3.6 define the seam and section 8 records what
implementing it found, so read #1084 first and this pull request as the answer
to it.
What this does and why
trusted-server-corecarries nine vendor integrations as core code because every place an
integration plugs in is closed. The builder table is private, browser
JavaScript is fixed at compile time, deploy validation names each vendor's
configuration type, the auction names its vendors in core, and two vendors
reach into core through named types. Every new vendor is therefore another core
change, most recently the LiveRamp module (#1054).
This pull request opens those places. An integration can now ship in its own
crate with its Rust, its browser script, its configuration type, its deploy
rules and its tests, and an adapter composes that crate into a deployment at
startup without core naming the vendor. No existing integration changes
behavior, the served script keeps its exact bytes and its
?v=hash, and thebuilt-in set still registers through the same path a vendor crate would use.
What is now composable:
IntegrationBuilder::new(id, source, build, validate), passed to an adapter'sroutes_with_registrations, which builds the registry withIntegrationRegistry::with_plan_and_registrations.with_js_module(CarriedJsModule { source, sha256 })on the registrationvalidate_settings_for_deploy_with.with_request_preparer(...), run byIntegrationRegistry::prepare_request.with_ec_provider(...),.with_geo_provider(...)and.with_device_provider(...), selected by[ec] provider,[geo] providerand[device] provider.with_demand(...), selected by[demand] provider.with_adserver(...), selected by[adserver] providerOne convention for every provider
Everything a deployment can switch on is a provider, and every provider type is
configured the same way.
The types are
ec,geo,device,permission_signal,demand,adserverand
integration. A type that runs one provider takes a string, a type thatruns several takes a list, and a provider with nothing to set needs no table at
all. A table's name is the implementation it configures, unless the table
carries an
implementationline, which is how two Prebid Servers run side byside under names a deployment chooses for itself. Every name a person types is
snake_case.
Four things stop a deployment rather than being tolerated:
[<type>.<name>]table that its type'sproviderdoes not name.providerentry, or animplementationline, naming an implementationthis build does not have. The message lists the ones it does have.
keys it does not recognize.
demandoradserverendpoint that is not HTTPS. Plain HTTP is allowedonly to
127.0.0.1,::1orlocalhost, so a local test stack runs withoutcertificates and nothing leaves the machine unencrypted.
The rules that hold for every type live in one place,
crates/trusted-server-core/src/provider_table.rs,so a new provider type inherits them instead of writing them again. The whole
convention, the checks and the move from the previous layout are written up for
operators in
docs/guide/configuration-rules.md.The auction side becomes provider types
The ad server, plain OpenRTB, Prebid Server and APS are now implementations
that an integration registers through
.with_demand(...)and.with_adserver(...)incrates/trusted-server-core/src/integrations/mod.rs,selected by
[demand] providerand[adserver] provider. The four that shiphere, in
openrtb.rs,prebid_server.rs,aps.rsand
adserver_mock.rs,go through the same registration a vendor crate uses.
What an implementation may decide is stated once, in
crates/trusted-server-core/src/auction/demand.rs.A shared OpenRTB driver builds every standard request field, sends the request
and enforces the privacy rules, and an implementation chooses only its field
policy, its own extensions, its outbound headers and how its responses are
read. The compiled plan in
crates/trusted-server-core/src/auction/plan.rsresolves each selected name to a registered implementation, so
crates/trusted-server-core/src/auction/profile.rsand its fixed list of threeprofiles are deleted.
[auction]keeps the settings that belong to the auction itself, beingenabled, the whole-auction timeout and creative handling, with[auction.bidders.<code>] providernaming the demand source a browser biddercode is sent to.
The provider interfaces are asynchronous, and providers get the services
Before this change every provider trait method was synchronous while every
platform service was asynchronous, and a provider was handed no services at
all, so a provider that needed to call a backend, read a key-value store or
fetch a secret could not be written. The methods that do the work on all three
provider interfaces (
EdgeCookieProvider::generate,DeviceProvider::detectand
PlatformGeo::lookup) are now asynchronous and take&RuntimeServices,and so is
resolve_from_client, which has to verify what the browser posted.Every implementation was converted, the built-in ones included. Finalizing the
Edge Cookie response is asynchronous too and takes the services, because
main's orphaned identifier recovery asks the selected provider for areplacement identifier.
The traits keep their
Send + Syncbound and use#[async_trait(?Send)], asPlatformHttpClientin this repository already does, so a provider stays safeto share while its future stays on one thread.
Two tests in
crates/trusted-server-core/src/ec/mod.rs,a_provider_reads_a_platform_service_through_the_services_it_is_givenanda_geo_provider_reads_a_platform_service_through_the_services_it_is_given,drive a provider that reads a value from the configuration store it is handed,
through the production path, and assert the value reaches the output. They
exist because once everything compiled and passed, nothing anywhere read the
services parameter, and a parameter no code exercises is not a working seam.
Two supporting changes
The bid renderer is no longer an enum with a single APS variant. It is a type
tag plus the payload the demand provider supplies, serialized flat so the
response a page receives is byte for byte what it was, and
ApsRendererV1moves into the APS integration. DataDome's cache and origin-path marker becomes
a neutral
PersonalizedResponserequest extension that any integration mayset, so core acts on the marker without knowing which integration asked for it.
One design decision made while stacking this on #1045
Two designs for
[geo]met when this branch moved onto the stack, and theydisagreed about what an unset selector means. The permission model (#1045)
treats unset as resolving nothing and making no host geo call, and this branch
had treated unset as leaving the host's own lookup in place. The permission
model's meaning is kept, because a deployment that has not asked for a host geo
lookup should not be making one. Unset and
"none"therefore both resolve toDisabledGeo,"platform"is the explicit opt-in to the adapter's own lookup,and any other value names a module that declared a location provider.
Where to start
crates/trusted-server-adapter-axum/tests/seam_probe.rs.Every seam is driven end to end from a crate core does not know about,
through the Axum adapter's real router. The tests turn on observable
outcomes, being the bytes served, the JSON a route returns and the error a
startup or deploy check produces, rather than on a function having been
called. Start here, because a seam is only proven by an implementation that
is not the built-in one.
ec_provider_generates_an_identifier_with_the_modules_prefixshows themodule's own Edge Cookie provider ran, because its identifier starts
seam-probe-, which the built-in HMAC provider cannot produce, andec_selector_naming_a_module_starts_the_adapterstarts the Axum router withthat provider selected.
crates/integrations/seam-probe/src/lib.rs,the fixture those tests drive. It is a workspace member and a dev-dependency
of the Axum adapter only, so no production build reaches it. One
registration carries a browser module, a proxy route, a request preparer,
location, Edge Cookie and device providers and its own configuration block.
crates/trusted-server-core/src/provider_table.rs,the table every provider type shares, and
auction/plan.rs,which resolves
[demand]and[adserver]names to registeredimplementations and reports an unknown one by listing the ones this build
has.
crates/trusted-server-core/src/integrations/registry.rs,with_plan_and_registrations. Building the registry refuses a duplicate id,naming both sources. It checks the SHA-256 of a carried browser module, and
resolves
[ec] provider,[geo] providerand[device] provideragainstthe modules that declared each capability.
crates/trusted-server-core/src/tsjs_bundle.rs,new. Composition of the served script moves out of
trusted-server-jsandinto core, keyed on content rather than on ids, because a module a vendor
crate carries is not in the compile-time map. The byte rule is unchanged,
being core first then each part joined by
;\n. The tests in that filecompare the composed bytes and hash against
trusted_server_js::concatenate_modulesandtrusted_server_js::concatenated_hash, which is the evidence that nocurrent
?v=value moves.crates/trusted-server-core/src/config.rs.The hand-written list of vendor configuration types is replaced by a loop
over the builders, and the auction plan is compiled and checked on the same
path, so a bidder route naming a demand source
[demand] providerdoes notselect is caught before a push.
crates/trusted-server-core/src/auction/types.rs,BidRenderer. This is the riskiest single change, because the wire shape{"type":"aps", ...}has to survive byte for byte.aps_renderer_serializes_to_versioned_camel_case_contractpins theserialized form and
renderer_bid_id_key_matches_the_serialized_formpinsthe one field the publisher reads by key.
crates/trusted-server-core/build.rsis also new. The migration guard used to list core source files by hand with
include_str!, so a vendor moving out of core would break the build ratherthan a test. The list is now generated from the source tree, so a file that
leaves core leaves the guard with nobody editing anything.
Why this rather than what
maindoes todayCore names vendors it has no business knowing.
crates/trusted-server-core/src/auction/plan.rsonmainholds
MOCK_MEDIATOR_ID = "adserver_mock"and refuses any other ad server. Itspecial-cases the profile ids
prebid-serverandapsby name when itcompiles a plan, and
crates/trusted-server-core/src/auction/profile.rsonmainholds the three profiles a deployment may choose, being
standard,prebid-serverandaps, as a fixed list with a closed enum behind it. Avendor cannot add a demand source or an ad server without a pull request
against core, which is the cost this series exists to remove.
The same idea is spelled four different ways. An integration is switched on
by
enabled = trueinside its own block. A demand source is switched on by akey appearing in
[auction.providers]. A permission signal is switched on byits name appearing in a
sourceslist. An Edge Cookie provider is switched onby
[ec] providernaming a nested[ec.providers.<name>]block. Two of thosefour are on
maintoday. The other two arrived inside this stack, in the EdgeCookie provider seam (#1043) and the permission model (#1045), which shows how
quickly a fifth spelling appears when no rule says what a spelling looks like.
One convention now covers all of them, and the next provider type gets the same
one without anyone deciding again.
For the people who run a deployment, one convention plus refusal at startup
changes what a mistake costs:
quietly ignored. On
mainan[integrations.datadome]block that outlivesits integration keeps every setting it had, and nothing tells the next reader
whether those settings are live. A leftover
[auction.providers.pbs-old]entry is worse, because on
mainthe entry is what switches the demandsource on, so a stale one is a live bidder nobody meant to keep. Now a table
has to be named by a
providerline or the deployment does not start.keys it does not know, so
timeout_milliswhere the setting istimeout_msstops the deployment rather than silently leaving the default in place, which
is the kind of fault that otherwise turns up much later in a latency graph.
provider = ["pbs_main"]becomingprovider = ["pbs_main", "aps_main"]sayswhat happened. On
mainthe same intent is a new nested block, aprofilestring, a
profile_configobject and aprotocolkey, and a reviewer has toreconstruct the outcome from four places.
be HTTPS, or HTTP to a loopback host for a local test stack.
[auction.providers]or[auction] mediatoris refused, and the error namesthe table the setting moved to instead of leaving the operator to search for
it.
For the people who write providers, a provider plugs in through one
registration and inherits every check above without writing any of them. Core
code names no vendor, so shipping a demand source, an ad server, an identity
provider, a location provider or a device provider means publishing a crate,
not editing core and waiting for a core release. The seam probe proves this
from the outside, because it is a crate core does not know about and it reaches
every one of those seams through the real router.
What this costs. These are breaking changes, and the auction keys get no
deprecation period. That is a deliberate choice for a major release, taken
because the alternative is carrying two spellings of every selector
indefinitely, and because a configuration refused with a message naming the fix
is cheaper to migrate than one that half works.
Moving from the previous layout
[integrations.<id>]withenabled = true<id>in[integration] provider, and[integration.<id>]only for settings[ec.providers.<name>][ec.<name>][permission_signal] sources[permission_signal] providerhost-signals,client-fixed,gpp-sale-opt-out,us-privacyhost_signals,client_fixed,gpp_sale_opt_out,us_privacy[auction.providers.<id>]withprotocol,profileandprofile_config[demand] providerand[demand.<name>], withimplementationand the settings flat in the tableprofile = "standard"implementation = "openrtb"[auction] mediator = "adserver_mock"and[integrations.adserver_mock][adserver] provider = "adserver_mock"and[adserver.adserver_mock][integrations.aps] rendering_moderendering_modein the[demand.<name>]table of theapsprovider[debug.auction_html_comment_options] include_mediator_responseinclude_adserver_responseThe word mediator goes with those keys. It is "ad server" in prose and
adserverin configuration, and the auction response metadata that readparallel_mediationnow readsparallel_adserver.A file in the old shape is not read on a best-effort basis.
[auction] providersand[auction] mediatorare both refused with a message namingwhere the setting moved to.
How it was verified
The head this pull request shows now is
ede61f685. The gates below were runlocally on
6a7cbb91d, and the four commits after it change only test codeand one comment, each verified by the gate that reaches it.
On Windows,
cargo fmt --all --check, Clippy with warnings denied on theFastly, Axum, Cloudflare and Spin adapters on their native and wasm targets
and on the codegen and parity crates, the core suite (2,982 tests and 18
doctests), the Axum, Cloudflare and Spin adapter suites, the seam-probe
fixture's tests, the cross-adapter parity suite, the Fastly release wasm
build, and the docs Prettier check, ESLint and VitePress build with the
pinned Prettier all pass. The CLI crate does not build on a Windows host,
because a dependency uses an unstable Windows feature, so its tests and its
Clippy job ran under WSL, where they pass at 80 unit tests and 7 environment
overlay tests. The Fastly adapter's own tests run only under Viceroy on
wasm32-wasip1, where they pass at 192, and the device, geo, OpenRTB and
JavaScript crates pass under Viceroy as well.
Three of those runs found fixtures the native gates could not reach and that
this pull request had left on the old layout: two
ts audittests counted acommented example the template now carries, the CLI environment overlay test
still built its project on
[auction.providers], and three Fastly fixturesdid the same. Each is fixed in a commit of its own on this head.
On CI, every check passes on
ede61f685except CodeQL:cargo test,
the Axum tests on Ubuntu,
the CLI tests,
format-docs,
the integration tests
and
vitest.
CodeQL
reports one high alert on a log line in the orchestrator that names the ad
server and its timeouts. The query reaches that line by tracing a test
helper's secret store into the log, and the line logs neither request data
nor a secret, so the alert stands as reported rather than being suppressed.
The CI workflow runs the seam-probe fixture crate's own tests, but no clippy
alias or workflow step names that crate, so its source is not linted in CI.
What is not in this change
The design in #1084 is wider than this pull request. Every part of it this pull
request does not deliver is listed here.
No implementation has physically moved out of core yet. The auction, Edge
Cookie, location and device seams are open and the built-in implementations go
through them, but they still ship inside
crates/trusted-server-core.Each vendor moves in its own pull request after this one, which is the point of
doing the seam once.
Nothing is refused for a missing host signal (section 3.6). A registration
declares no host-signal requirements, so a provider that needs a signal the
running adapter does not expose is not turned back at startup.
The acceptance round trips run on Axum, not Fastly (section 6, items 1 and
2). Both items ask for the round trip on the Fastly adapter, and the tests
here run through the Axum adapter, because the Fastly adapter cannot yet take a
vendor crate (below). Item 1 also asks that a module's hooks run in the right
order, and the tests show the preparer runs before routing and exactly once,
but no test checks the order of several hooks on one registration.
There is no finalize hook (section 3.5). The prepare half is done, and
IntegrationRegistry::prepare_requestreplaced the directgpt_diagnostics::prepare_requestcalls in all four adapters. The finalizehalf is not. The registration builder has no finalize method, and the GPT
diagnostics decision is still a named type in
html_processor.rsand
publisher.rs,so a GPT move needs that hook first.
A module's own deploy rules still run nowhere an operator can reach (section
8, items 1 and 6).
ts config validateandts config pushgo throughTrustedServerAppConfig, which callsvalidate_settings_for_deploywith noextra builders, so only core's rules run on the path an operator uses. The
registry calls a builder's build function and not its validate function, so a
vendor whose checks live in
validatehas them enforced on no path unless thedeployment's own code calls
validate_settings_for_deploy_withwith thatbuilder. The seam probe works around this by repeating its check inside its
build function, which every vendor would have to copy.
docs/guide/integration-guide.mdrecords the gap, and it needs a decision, either the CLI is built per
deployment with its vendor crates, or the registry runs
validatewhen itbuilds.
A Fastly deployment still cannot take a vendor crate (section 8, item 7).
The Axum, Cloudflare and Spin adapters expose
build_state_with_registrationsand
routes_with_registrationspublicly. The Fastly adapter'sbuild_state_with_registrationsispub(crate)and the crate has no librarytarget, so composing a module into a Fastly deployment means editing that
adapter. Fastly is the primary deployment target, so this gap decides whether
the seam is usable in production or only in the dev server, and closing it
should come before the first vendor is asked to use the seam.
A provider can still be resolved more than once per request (section 8, item
3). On
POST /auctionlocation is looked up to build the Edge Cookie contextand again in
handle_auction, and the seam probe's proxy route calls itslocation provider as well. This pull request adds no per-request provider
context, so a module sharing one backend across identity, location and device
has nowhere to hang a single call per request.
Request preparers still cover different routes on each host (section 8, item
5). Replacing the vendor-named call with the registry call did not make the
covered routes the same. Axum runs preparers once before routing, Cloudflare in
its shared handler wrapper and its fallback dispatch, Fastly after its early
admin dispatch and in its fallback, and Spin only in the auction handler, the
page-bids handler and the fallback. A module that strips its own reserved query
or cookie is therefore protected on a different set of routes depending on the
host.
One core reader still reaches into an APS payload (section 8, item 4). The
hb_adidfallback inpublisher.rsreads the APS renderer's bid id. It goes through the neutral accessor
BidRenderer::payload_field, which reads one field instead of cloning thewhole payload, but
publisher.rsstill importsAPS_RENDERER_TYPEandAPS_RENDERER_BID_ID_KEY, so the APS move needs a neutral answer for thatfallback and not only the renderer contract.
Core TypeScript still imports APS directly (section 8, item 8).
auction.tsand
types.tsimport and name the APS renderer, so generalizing the Rust renderer does not by
itself move APS out.
DataDome is still named in core outside its own integration.
publisher.rsreads
DataDomeClientTagSuppressedandhtml_processor.rsimports it, so that DataDome's head injection can leave its client-side tag
out. That marker decides nothing about caching or the origin path any more,
which the neutral
PersonalizedResponsemarker now does, and it moves out withthe DataDome integration, as the code comment at the read site in
publisher.rssays.The
ts auditvendor table is unchanged. Thets auditcommand keeps its own vendor detection patterns outside the registry, and how
the CLI learns a vendor's detection pattern from a crate is still open.
One deliberate deviation from the spec. Section 4 says the source-file
guard should drop the nine vendors' files. This pull request instead generates
the whole guard list from the source tree through the new
crates/trusted-server-core/build.rs,so a file joins or leaves the guard on its own and no vendor move has to touch
it. The goal is the same and the mechanism is different.
What the round trip does not exercise. The seam probe declares a proxy, a
carried browser module, a request preparer, and location, Edge Cookie and
device providers. It declares no head injector, attribute rewriter, script
rewriter, HTML post processor or request filter, and it uses neither the
deferred nor the standalone script delivery flag, so the round trip does not
exercise those from outside core.