Skip to content

Open the integration seam so a vendor module can live outside core - #1094

Open
jwrosewell wants to merge 369 commits into
IABTechLab:mainfrom
jwrosewell:split/7-integration-seam-impl
Open

jwrosewell wants to merge 369 commits into
IABTechLab:mainfrom
jwrosewell:split/7-integration-seam-impl

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Add the integration seam and one selection convention for every type

Updated on 8 October 2026. Every integration module has moved out of
trusted-server-core.
The sixteen modules that sat in core's
integrations folder live in fifteen crates under
crates/<type>/<vendor>/, each naming its maintainers, and core is 9.5
percent smaller than main, or 12.8 percent without comments, with the
seams and the permission model that main does not have. The section
"The integration modules move out of trusted-server-core" below says
what moved, what core gained to let the modules go and what the moves
found. The design in #1084 says the same from the same day. The note that
follows is the earlier record of changes to this text.

This description was updated on 7 October 2026, and on 8 October for the merge of main at 182fdf4. The branch now carries
the refreshed module documentation set (#1047 at 3c7ef00) and, through it,
main at 182fdf4. Since the last version of this text:

  1. Everything an operator selects is a module, and [integration] is
    gone.
    A page integration is selected in the section of its type:
    [cmp] module = "didomi", [tag] modules = ["google-tag-manager"], [ad-tag] modules = ["google", "google.diagnostics"], [bot-protection] module = "datadome",
    [identity] module = "lockr", [audience] module = "permutive",
    [framework] module = "nextjs", [auction] modules = ["prebid"]
    and [proxy] modules = ["js_asset_proxy"], with its settings in the
    table at its name, so main's [integrations.prebid] is
    [auction.prebid].
    Every top-level table core does not read itself is the section of a
    module type, a section that selects nothing, a table its section does
    not select and a name no module in the deployment supplies are each
    refused at startup, and an [integrations] or [integration] table is
    refused with directions.
    The auction's tables select the same way as every other type:
    [demand] modules lists the demand sources, [ad-server] module names
    the ad server, and [auction.bidders.<code>] module names the demand
    source a browser bidder code is sent to. The word provider has left the
    configuration. The generic table takes its key from its selection type,
    modules where several run and module where one does, refuses a
    provider key with the key to write instead, and a configuration still
    carrying [adserver] is refused with its new name. Through Add the module documentation set and finish the decomposition #1047 the
    branch carries the module name rule, under which a module from a crate
    is named by its folder below crates/, so the permission signals are
    selected with [permission-signal] modules as gpc, gpp, us-privacy
    and tcf, and [ec] module, [geo] module and [device] module resolve
    a written name against the modules they have. A module's name is
    <type>.<name>, which is its crate's path under crates/ where it has
    a crate of its own (the permission signals, the seam probe) and a
    constant in core for an integration that has not moved out yet, so its
    section and table stay the same when it moves. A [demand] or
    [ad-server] name stays snake_case, because it may be a label of the
    operator's own.
  2. main's twenty commits since the series forked are inside the seam's
    shape.
    A Prebid Server stored request is sent only when the slot's
    intent allows the fallback, an implementation marks an impression it has
    no demand for as omitted and the driver leaves it out, the reader's and
    the request's origin requirement are told apart, and the build script
    keeps both of its jobs, the migration guard's generated source list and
    the manifest's default config store id.
  3. The pages that still showed [auction.providers], mediator and
    [integrations], which this branch refuses at startup, show the tables
    it reads.
    The error reference, the ad serving overview, the auction
    testing guide, TESTING.md, the CLI's README, the API reference's
    registration predicates and the ad server mock guide are rewritten, and
    the configuration reference loses the [integration.adserver_mock] and
    [integration.aps] sections for tables that no longer exist.
  4. ts prebid server inspect reads [demand]. It still parsed
    [auction.providers.<id>] with a profile and [integrations.prebid]
    with an enabled key, so on a file written for this branch it found no
    Prebid Server demand and said so without error. It now reads the
    [demand.<name>] tables whose implementation line is
    auction.prebid-server and the
    [auction.prebid] table, and its report says what is selected rather
    than echoing a flag that is gone.
  5. The seam's Edge Cookie files that the lower pull requests refreshed in
    their synchronous form are back in the seam's asynchronous shape, with
    the seam's own tests that read the configuration store through the
    services restored.
  6. The first CI run on 0d5755f caught what no local gate here can reach:
    the template cache harness still switched Prebid on with
    provider = ["prebid"], which this branch reads as a settings table for
    an integration called provider. The harness selects Prebid with
    modules = ["prebid"] in [auction] now, and the configuration it
    generates loads through the core crate's own reader and passes deploy
    validation, checked on this head's template and script.
  7. The seam probe lives under crates/testing/ and names itself from
    that folder, so [testing] modules = ["seam-probe"] selects it, which
    is the shape a vendor crate takes. The audit generator writes each
    detected module's section, the bundle and inspect commands read
    [auction.prebid], and an environment overlay names a section as
    written, so TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED
    is TRUSTED_SERVER__AD-TAG__GOOGLE__GAM_ATTRIBUTION_ENABLED.
  8. The auction implementations are named by module path. The four
    this repository ships are auction-protocol.openrtb,
    auction.prebid-server, auction.aps and ad-server.mock, in place of
    openrtb, prebid_server, aps and adserver_mock. A
    [demand.<name>] table names its implementation on an implementation
    line by that path, because demand is not the type an implementation
    is named under, and [ad-server] module = "mock" resolves within its
    own type. When the build has no such implementation the refusal lists
    the ones it has, as the section would write them. APS keeps aps as its
    bidder and response name on the wire. The same change corrects the
    auction table's validation messages, which still named the provider
    key after the key became modules and module.
  9. Messages and guides name a setting by the section it is read
    from.
    A read of the whole tree for what the section change left
    behind found operators being sent to settings under the removed
    table. Prebid's bidder parameter validation, the Next.js payload
    warning, every ts prebid client message, the external bundle build
    script and the browser console error for a bidder with no adapter now
    name auction.prebid and framework.nextjs, and ts prebid server inspect reports its browser source section as auction.prebid. The
    configuration reference drops the enabled row from seven module
    tables, because a module's table refuses that key, documents modules
    in [proxy] and [auction], and names include_adserver_response.
    The integration inventory says what the settings write to select each
    integration, .env.example names each overlay by its section, and the
    changelog describes the move from main's [integrations]. The
    guides said each page integration section is named by a type folder
    under crates/, which holds only for a module with a crate of its
    own, so they now say that an integration still in core carries the
    name its crate will have. Two tests gain meaning. An implementation
    named in a page integration's section is refused, and the environment
    overlay test sets the bidder route's module leaf where it still set
    provider, which no longer exists.
  10. Comments say what the code does and why, with the history taken
    out
    , on this branch and on the five below it, and the Edge Cookie
    guide from Add the module documentation set and finish the decomposition #1047 describes the permission model as it is. The branch
    also carries Add a pluggable Edge Cookie module seam with the built-in HMAC module #1043's statement of what normalize_id_for_kv decides,
    with its fixture test, the fixture taking the asynchronous shape the
    trait has here. The context query parameter example names
    [ad-server.mock].
  11. The integration guide says how an integration ships outside core.
    A section written for this branch in August was lost when the guide
    was rewritten on main, so the seam had no developer documentation
    beyond its tests. It is back, for the selection model the branch has
    now, covering what a vendor crate provides, what a registration can
    declare, how each adapter composes it in, and two traps a vendor will
    hit.
  12. A registration names the Edge Cookie, geo and device modules it
    supplies.
    [ec] module, [geo] module and [device] module
    picked a registration's module by its integration id, a hand-written
    name where every other selection reads a module name. Each of the
    three is now declared under a name, the path under crates/ of the
    crate the module lives in, and the selectors read a written name
    the way every section does, so a geo module from
    crates/geo/example is [geo] module = "example". One
    registration can supply a module of each type, each under the name
    of its own crate. [ec] module also resolves the implementation a
    labelled block names, where it looked for the label itself and so
    never reached a registered module. The probe's three modules are
    selected as testing.seam-probe. The same commit takes a paragraph
    and a heading that appeared twice out of the configuration
    reference.
  13. A demand source or an ad server from a crate outside core is known
    when the settings load.
    The settings are validated as they load,
    and that validation compiled the auction plan with the built-in
    implementations alone, so a deployment selecting an implementation
    one of its own builders supplies was refused before the adapter built
    the state that knows those builders, and deploy validation given the
    same builders refused it too. get_settings_from_config_store_with,
    settings_from_config_blob_with and
    validate_settings_for_runtime_with carry the builders through the
    load, where each builder's own validate function now runs as well,
    and deploy validation compiles the plan with the builders it was
    given. The seam probe supplies an ad server, so the auction seam is
    reached from a crate core does not know, and four helpers an auction
    crate outside core needs are public. Four tests in core and two on
    the Axum adapter assert both halves, that the path without the
    builder refuses the name and the path with it accepts, and a fifth
    in core shows a builder's own rejection surfacing from the
    validation the load runs.
  14. On Fastly, the device module [device] selects classifies each
    request.
    The Fastly entry point built a request's device signals
    with build_device_module, which knows only builtin and fastly,
    so a device module a registration supplied was resolved, put in the
    request's services and never asked to classify anything, and the
    built-in User-Agent module answered in its place. The entry point
    now hands the module the registry resolved to
    derive_device_signals, a registered module is shown the
    User-Agent and the cookies, and two tests in the adapter show it
    classifying a request and the built-in module answering when
    nothing is registered. Fastly is the one adapter that classifies a
    request, on main as here.
  15. The seam probe supplies a demand source as well. It sends the
    standard request and reads every answer as no bid, which is enough to
    show that everything a demand source needs is reachable from a crate
    outside core. One Axum test starts the adapter and runs deploy
    validation with it selected, and both refuse the same settings
    without the probe's builder.
  16. The Fastly adapter is a library, and the seam's round trip runs on
    it.
    The adapter was a binary only and its
    build_state_with_registrations was private to the crate, so a
    Fastly deployment that shipped a module crate had to edit the
    adapter. src/main.rs moved to src/lib.rs, where main became
    run, and run_with(Vec<IntegrationBuilder>) records the builders a
    deployment offers before any request is served. The settings load
    validates against them and the state build composes them with the
    built-in ones. The binary is one line that calls run, the Wasm
    artifact keeps its name, and fastly.toml still builds
    --bin trusted-server-adapter-fastly. Ten tests in
    src/app/seam_probe_tests.rs drive the seam probe through this
    adapter's own router under Viceroy, which is the round trip the
    design's acceptance items 1 and 2 ask for on Fastly, and the tenth
    shows the probe's device module classifying a request.
  17. The identity graph's comments describe device records. Through
    Add a pluggable Edge Cookie module seam with the built-in HMAC module #1043, the comment on the count of Edge Cookies that share a network
    no longer reads a low count as an individual or household, and the
    partner identifiers are described as each partner's identifier for
    the same browser on the same device. Comments only.

The commits since the last version are 8fcb54a (the merge of #1047 at ecb5fb7 and main at 7a0ecb4), b8d451b (the seam says module where an operator selects a capability), 79c1a9f (the documentation for the demand, ad server and integration tables), cccce58 (ts prebid server inspect reads the demand table), d8e09a9 (the merge of #1047 at 30fbc6d, which brings the inspector lockfile fix made on #1046), 0d5755f (the local harness names prebid in [integration] module), 060b442 (the merge of #1047 at 93af3ea, which brings the module name rule), 22fa839 (the auction's tables select with modules and module), 90f6690 (page integrations are selected in the section of their type), ad734fd (the audit generator borrows the detected integration's name, which the host-target clippy in CI caught, the CLI not being buildable on the Windows machine the local checks run on), d3e2366 (an auction implementation is named by its module path), 35eb0ea (messages and guides name a setting by the section it is read from), 1579133 (the merge of #1047 at f220687, which brings the lower branches' comment and guide wording and #1043's test of the key contract), 6b335b7 (comments say what the code does), 0810bbe (the integration guide says how an integration ships outside core), 2b5ca44 (a registration names the Edge Cookie, geo and device modules it supplies), 5a2e8f6 (a demand source or ad server from outside core is known when the settings load), 1eabe04 (on Fastly, the device module [device] selects classifies each request), 812c90c (the seam probe supplies a demand source), afe895e (the merge of the Fastly library target, whose own commits are 6c7e0d6, b98dc7a and b5ef822), 7a186db (the merge of #1047 at 1f31ba5, which brings #1043's wording of the identity graph's comments), d2e68d0 (the seam probe's notes name both adapters whose tests use it) and, on 8 October, a90a141 (the merge of #1047 at 3c7ef00, which brings main at 182fdf4). The move of the modules is 31 commits above that, merged at 49bdf21.

Stacks on the module documentation set (#1047), the last of the five pull
requests that decompose #838, so this pull request sits on the whole module
series. It also depends directly on the EdgeCookieModule and DeviceModule
traits from the Edge Cookie module seam (#1043) and the device and location
seam (#1044), whose modules a registration can now carry, and on the
permission model (#1045), where an unset [geo] module makes no host geo
call. The stack has six pull requests (#1043, #1044, #1045, #1046, #1047,
#1094), each targeting main, with this one sixth.
Compare split/5-response-hook-docs with split/7-integration-seam-impl
to 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

On main,
trusted-server-core
carries 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 and then moves every integration
module through them. An integration ships in its own crate with its Rust, its
configuration type, its deploy rules and its tests, it can carry its browser
script too, and an adapter composes that crate into a deployment at startup
without core naming the vendor. No existing integration changes behavior, the
same modules compose to the same served bytes and ?v= hash, and the modules
a stock build ships register through the same path a vendor crate uses. One browser module's own bytes change, because the Prebid
module's console error for a bidder with no adapter names
[auction.prebid.bundle.modules].

What is now composable:

Seam How a crate outside core reaches it
Integration registration IntegrationBuilder::new(id, source, build, validate), passed to an adapter's routes_with_registrations, which builds the registry with IntegrationRegistry::with_plan_and_registrations
Browser script .with_js_module(CarriedJsModule { source, sha256 }) on the registration
Deploy validation the builder's validate function, run by validate_settings_for_deploy_with
The settings load get_settings_from_config_store_with and settings_from_config_blob_with, which validate against the builders a deployment composes and run each builder's validate function
Request preparation .with_request_preparer(...), run by IntegrationRegistry::prepare_request
Response finalizing .with_response_finalizer(...), run by IntegrationRegistry::finalize_response with what the module's request hooks left for the request
A module's secret settings .with_secret_settings(...), looked up as the settings load when a section selects the module
The auction plan .with_plan_registration(...) and .with_plan_validator(...), for a module whose page support follows what the plan selects
Identity, location and device .with_ec_module(name, ...), .with_geo_module(name, ...) and .with_device_module(name, ...), each selected by that name in [ec] module, [geo] module and [device] module
Page integration selection .with_module_name("<type>.<name>") on the builder, selected in [<type>] with module or modules
Auction demand .with_demand(...), selected by [demand] modules
The ad server .with_adserver(...), selected by [ad-server] module

One convention for every type

Everything a deployment can switch on is a module, selected the same way: the
Edge Cookie identity, the location and device lookups, the permission signals,
the demand sources in an auction, the ad server that picks the winner, and the
page integrations.

[<type>]
module = "<name>"            # modules = [...] where several run

[<type>.<name>]              # only when the selected name has settings
setting = "value"

The types are ec, geo, device, permission-signal, demand and
ad-server, and the section of each page integration's type, cmp, tag,
ad-tag, bot-protection, identity, audience and framework, with
auction and proxy selecting modules beside their own settings. A type
that runs one takes module, a string, a type that runs several takes
modules, a list, and a name with nothing to set needs no table at all. A table's name is the implementation it configures,
unless the table carries an implementation line naming the implementation
by its module path. A demand table always carries one, because demand is
not the type its implementations are named under, which is how two Prebid
Servers run side by side under names a deployment chooses for itself. A name
is parts joined by ., each of lower case letters, digits, _ or -. A
module from a crate is named by its folder below crates/, so it may be
written in full, as permission-signal.gpc, or with its section's type left
off. Core's own modules such as hmac take bare names, and
a [demand] or [ad-server] name is snake_case because it may be a label
of the operator's own.

Five things stop a deployment rather than being tolerated:

  • A [<type>.<name>] table that its type's selector does not name.
  • A module type's section that selects nothing, which is also what a
    misspelt table of Trusted Server's own meets.
  • A selector entry, or an implementation line, naming an implementation this
    build does not have. The message lists the ones it does have.
  • A setting the selected name does not know, because every module rejects keys
    it does not recognize.
  • A demand or ad-server endpoint that is not HTTPS. Plain HTTP is allowed
    only to 127.0.0.1, ::1 or localhost, so a local test stack runs without
    certificates and nothing leaves the machine unencrypted.

A configuration written for the previous spelling is told the new one rather
than half read: a provider key in [demand] or [ad-server] is refused with
the key to write, an [adserver] table is refused with its new name, and an
[integration] table is refused with where modules are selected now.

The rules the auction tables and the module sections share live in one
place,
crates/trusted-server-core/src/provider_table.rs,
whose SectionModules reads a section's module or modules and the
tables of the modules it selects, and TypeSections in
settings.rs
reads every top-level table core does not read itself as one. 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 selects modules the same way

The ad server, plain OpenRTB, Prebid Server and APS are implementations that
a crate registers through .with_demand(...) and .with_adserver(...) on its
builder, selected by [demand] modules and [ad-server] module. The four
that ship here are auction-protocol.openrtb, auction.prebid-server,
auction.aps and ad-server.mock, in
crates/auction-protocol/openrtb,
crates/auction/prebid-server,
crates/auction/aps
and
crates/ad-server/mock,
and core's own list offers none.

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.rs
resolves each selected name to a registered implementation, so
crates/trusted-server-core/src/auction/profile.rs and its fixed list of three
profiles are deleted.

main's Prebid Server changes since the fork sit inside that split. A stored
request is sent only when the slot's intent allows the fallback, an
implementation that has no demand for an impression marks it omitted, the
driver leaves those impressions out and makes no request when none is left,
and the parse state keeps only the slots that were sent, so a response cannot
be read against an impression that never went.

[auction] keeps the settings that belong to the auction itself, being
enabled, the whole-auction timeout and creative handling, with
[auction.bidders.<code>] module naming the demand source a browser bidder
code is sent to.

The module interfaces are asynchronous, and modules get the services

Before this change every module trait method was synchronous while every
platform service was asynchronous, and a module was handed no services at
all, so a module 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
module interfaces (EdgeCookieModule::generate, DeviceModule::detect and
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 module for a
replacement identifier.

The traits keep their Send + Sync bound and use #[async_trait(?Send)], as
PlatformHttpClient in this repository already does, so a module stays safe
to share while its future stays on one thread.

Two tests in
crates/trusted-server-core/src/ec/mod.rs,
a_module_reads_a_platform_service_through_the_services_it_is_given and
a_geo_module_reads_a_platform_service_through_the_services_it_is_given,
drive a module 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 implementation supplies, serialized flat so the
response a page receives is byte for byte what it was, and ApsRendererV1
moves into APS's crate. DataDome's cache and origin-path marker becomes
a neutral PersonalizedResponse request extension that any integration may
set, so core acts on the marker without knowing which integration asked for it.
main's split between the reader's origin requirement, decided before the
response is stripped, and the request's, decided after, is carried into that
neutral marker rather than keyed on one vendor's tag.

One design decision made while stacking this on #1045

Two designs for [geo] met when this branch moved onto the stack, and they
disagreed 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 to
DisabledGeo, "platform" is the explicit opt-in to the adapter's own lookup,
and any other value names a module that declared a geo module.

Where to start

  1. 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_module_generates_an_identifier_with_the_modules_prefix shows the
    module's own Edge Cookie module ran, because its identifier starts
    seam-probe-, which the built-in HMAC module cannot produce, and
    ec_selector_naming_a_module_starts_the_adapter starts the Axum router with
    that module selected.
  2. crates/testing/seam-probe/src/lib.rs,
    the fixture those tests drive. It is a workspace member and a dev-dependency
    of the Axum and Fastly adapters only, so no production build reaches it. It names its
    module from its folder, testing.seam-probe, and one registration carries
    a browser module, a proxy route, a request preparer, geo, Edge Cookie and
    device modules and its own settings table. Its builder also registers a
    demand source and an ad server, which [demand] and [ad-server] select.
  3. crates/trusted-server-core/src/provider_table.rs,
    the table [demand] and [ad-server] share, which takes modules or
    module from its selection type, and
    auction/plan.rs,
    which resolves [demand] and [ad-server] names to registered
    implementations and reports an unknown one by listing the ones this build
    has.
  4. crates/trusted-server-core/src/integrations/registry.rs,
    with_plan_and_registrations. Building the registry refuses a duplicate id,
    naming both sources, runs a builder when a section selects its module and
    refuses a selection no module answers to, listing what the section could
    select. It checks the SHA-256 of a carried browser module, and resolves
    [ec] module, [geo] module and [device] module against the names
    the running registrations declared their modules under.
  5. crates/trusted-server-core/src/tsjs_bundle.rs,
    new. Composition of the served script moves out of trusted-server-js and
    into 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 file
    compare the composed bytes and hash against
    trusted_server_js::concatenate_modules and
    trusted_server_js::concatenated_hash, which is the evidence that
    composing in core moves no ?v= value.
  6. 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] modules does not
    select is caught before a push.
  7. 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_contract pins the
    serialized form and renderer_bid_id_key_matches_the_serialized_form pins
    the one field the publisher reads by key, both in APS's crate,
    crates/auction/aps/src/lib.rs.

crates/trusted-server-core/build.rs
carries two jobs. 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
rather than a test. The list is now generated from the source tree, so a file
that leaves core leaves the guard with nobody editing anything. The second job
is main's, writing the manifest's default config store id into the build.

The integration modules move out of trusted-server-core

The sixteen integration modules that sat in crates/trusted-server-core/src/integrations/ now live in fifteen crates of their own under crates/<type>/<vendor>/, which is the layout #784 asks for. Each is selected and configured exactly as before. Each carries its own Rust, settings type, deploy rules and tests, and names its maintainers in its manifest (#1100). Core names none of them.

The move is 31 commits, one per module, per extension point, per removal or per fault found, so each can be read on its own.

What an operator sees

Nothing changes in a configuration. [cmp] module = "didomi", [auction] modules = ["prebid"], a [demand.<name>] table with implementation = "auction.prebid-server" and every module's own table read as they did, and the routes, the browser module names and the wire names (aps, adserver_mock, gpt) are the same.

The modules

Module Crate Tests in the crate
cmp.osano crates/cmp/osano 6
identity.lockr crates/identity/lockr 11
audience.permutive crates/audience/permutive 9
cmp.sourcepoint crates/cmp/sourcepoint 57
cmp.didomi crates/cmp/didomi 33
tag.google-tag-manager crates/tag/google-tag-manager 90
testing.testlight crates/testing/testlight 11
framework.nextjs crates/framework/nextjs 115
ad-tag.google and ad-tag.google.diagnostics crates/ad-tag/google 53
bot-protection.datadome crates/bot-protection/datadome 67
ad-server.mock crates/ad-server/mock 23
auction.aps crates/auction/aps 63
auction.prebid crates/auction/prebid 65
auction.prebid-server crates/auction/prebid-server 141
auction-protocol.openrtb crates/auction-protocol/openrtb 5

A module is named by its crate folder, and a test in each crate holds its MODULE constant to that folder.

crates/trusted-server-modules lists the modules a stock build ships, in the order their hooks run. Every adapter builds its state and loads its settings with that list followed by the builders a deployment added, and the ts tool registers it for deploy validation. Offering a module does not run it, because the registry builds a module only when a section of the settings selects it.

What stays in core's integrations folder is the registry, the builder a module registers with and the JavaScript asset proxy, which is core's own. Core's built-in list offers no auction implementation.

Maintainers

Every crate outside core names who maintains it in its manifest, the way Prebid.js requires a named maintainer of every adapter.

[package.metadata.maintainers]
owner = "Trusted Server maintainers"
status = "seeking vendor owner"
Status Meaning Crates
vendor owned The vendor has adopted the crate 1, permission-signal/mtm (51Degrees)
seeking vendor owner The Trusted Server maintainers hold the crate for its vendor 18, being the twelve vendor crates in the table above, the two Fastly crates and the four other permission signal crates
project owned The crate is the project's own 5, being the mock ad server, Testlight, the plain OpenRTB demand, the seam probe and the stock list

A test in crates/trusted-server-modules reads all 24 manifests and fails for one with no declaration, no owner or an unknown status. The integration guide states the convention.

What core gained to let them go

Three extension points, each vendor neutral, each its own commit with its own tests.

  1. A module acts on one request without core naming it (ccdae38). A request preparer or filter leaves a value on the request under its integration id. The page path carries the value to the module's own hooks in the document and to a response finalizer the builder declares. A request that carries one keeps to the origin path and its HTML is private, so it is never served from or stored as a shared template. The Google Publisher Tags diagnostics module and DataDome needed this.
  2. A module declares its own secret settings (4087052). A builder lists the settings in its own table that hold the name of a secret, each with a rule for whether the table as written puts the setting to use. As the settings load, a setting in use in a selected module's table is looked up and the others are cleared, and deploy validation checks that each one in use names a key. Core's settings code named DataDome's two secrets in three places before this.
  3. A module registers and validates against the auction plan (d31e511). A builder can register from the compiled plan, with its hooks ahead of every module a section selects, and can check its settings against the plan at deploy validation and as the settings load. The registry called Prebid and APS by name before this.

Core also offers its auction test helpers under its test-utils feature, so an implementation's crate tests itself against core's request builder, response reader and orchestrator. About fifteen small items of core's auction code are public for an implementation outside core (bid admission, the notification policy, a slot's stored request intent, the plan's bidder routes), and the reader of an ordinary OpenRTB response sits in core's own OpenRTB driver for any implementation to use.

Two places where core read a vendor's own data are closed by the moves. The
page's bid map read the APS renderer's bid id by APS's names, and a renderer
descriptor now carries the name of the field that holds its bid id, stated by
the implementation that builds it, which the bid map asks for. DataDome's
marker for leaving its browser tag out of a page was a named type in the page
path, and it is DataDome's own request state now.

Tests

A module's tests went with it. Where core held a test of a vendor's own behavior, the test moved to the vendor's crate and runs through core's public interface. Where core used a module as its example of a kind of module, the test stayed and runs on a stand-in compiled for core's tests only. There are seven, being four page modules (one that streams, one that tags a page, one that acts on a single request, one with a deferred browser module), two demand implementations (a plain one, and one that serves stored requests) and an ad server.

Core's shared test settings selected Prebid, so every test of core and of every other module ran with Prebid in it. They select no auction module now.

integrations/prebid.rs was 9,994 lines holding the page integration, the Prebid Server request and response code and a provider kept for the test build. Its shipped code falls into two parts with nothing shared, so 27648c0 divides the file inside core without changing behavior, and the two halves then move in 39f1240 and dd7f232. The test-only provider and its combined configuration are kept and divided the same way. The Prebid Server, APS and plain OpenRTB request goldens, two each, are carried byte for byte.

One test is not carried. register_rejects_invalid_bid_param_override_rule checked that the test-only page registration refused a bad override rule, and that registration no longer holds override rules (the provider's own test of the refusal stays). The ten tests of the unused ad server model in models.rs went with it. Compared by name, every other test core had before the move is in core or in a module's crate after it, 60 of them under a name that says what the test covers now, such as a_bot_protection_block_is_refused_by_the_status_check for a_datadome_block_is_refused_by_the_status_check.

Size

Every .rs file under crates/trusted-server-core/src, tests included, because a module's tests leave core with the module:

Core Files Lines Lines without comments
main 123 158,102 143,231
The seam implementation before the move 135 181,034 160,480
With the sixteen modules out 112 143,148 124,922

Against main, core is 9.5 percent smaller with comments and 12.8 percent smaller without, and that is with the module seams and the permission model that main does not have. A comment line is a line that holds only a comment, and blank lines are counted in both columns. The sixteen modules are 41,581 lines in their crates, 38,735 without comments.

What the moves found

  • The ts audit generator named moved modules through core. It asks the stock list where a module is selected now (e08ef73).
  • A template cache test depended on the diagnostics module's request preparer rewriting the Cookie header of every request. A stock build still does that, and a build without the Google crate forwards cookies as they arrived.
  • A scheduling test asserted that DataDome's tag was absent from a document with no <head> for a tag to be written into, so it could not fail. It asserts something that can.
  • A DataDome selected by its full name (module = "bot-protection.datadome") did not have its secrets looked up, because the lookup read the short name alone. The second extension point reads both.
  • The Fastly adapter's test settings select Prebid and a Prebid Server source, and two of its test helpers compiled the auction plan from core's built-in modules. They take the stock list, as the adapter's entry point does (2e60184).
  • Deploy validation reads the builders a process registered. The integration test crate's Viceroy configuration generator, its test configuration and two of the ts tool's integration tests validate settings in a process of their own and registered none, so the first CI run of the move refused the fixture's Prebid Server source in the integration workflow and in the tool's browser tests. Each registers the stock list first, as the tool does (9ce9e3e). One test of the generator's own loads settings from the blob it built, as an adapter does at startup, and it passes the stock list to the loader (190ea05).

Why this rather than what main does today

Core names vendors it has no business knowing.
crates/trusted-server-core/src/auction/plan.rs on main
holds MOCK_MEDIATOR_ID = "adserver_mock" and refuses any other ad server. It
special-cases the profile ids prebid-server and aps by name when it
compiles a plan, and
crates/trusted-server-core/src/auction/profile.rs on main
holds the three profiles a deployment may choose, being standard,
prebid-server and aps, as a fixed list with a closed enum behind it. A
vendor 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 = true inside its own block. A demand source is switched on by a
key appearing in [auction.providers]. A permission signal is switched on by
its name appearing in a sources list. An Edge Cookie module is switched on
by a selector naming a nested [ec.providers.<name>] block. Two of those four
are on main today. The other two arrived inside this stack, in the Edge
Cookie module 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 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:

  • A table left behind after a module is switched off is caught rather than
    quietly ignored. On main an [integrations.datadome] block that outlives
    its 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 main the entry is what switches the demand
    source on, so a stale one is a live bidder nobody meant to keep. Now a table
    has to be named by its type's selector line or the deployment does not
    start.
  • A misspelled setting fails instead of being dropped. Every module rejects
    keys it does not know, so timeout_millis where the setting is timeout_ms
    stops 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.
  • Switching what runs is a one-line change that reads plainly in review.
    modules = ["pbs_main"] becoming modules = ["pbs_main", "aps_main"] says
    what happened. On main the same intent is a new nested block, a profile
    string, a profile_config object and a protocol key, and a reviewer has to
    reconstruct the outcome from four places.
  • An endpoint cannot quietly be plain HTTP. Demand and ad server endpoints must
    be HTTPS, or HTTP to a loopback host for a local test stack.
  • An old file is not half accepted. A configuration still carrying
    [auction.providers], [auction] mediator, [adserver], [integration]
    or a provider key is refused, and the error names the table or key the
    setting moved to
    instead of leaving the operator to search for it.

For the people who write modules, a module plugs in through one
registration and inherits every check above without writing any of them. Core
names nothing a crate outside it supplies, so shipping a demand source, an ad
server, an identity module, a geo module or a device module means publishing a
crate, not editing core and waiting for a core release. The seam probe shows
this from the outside, because it is a crate core does not know about. Through
the real router or the real startup path it reaches a proxy route, a carried
browser module, a request preparer, a geo module, an Edge Cookie module, a
demand source and an ad server. Its device module is resolved by
[device] module, and the Fastly adapter's own tests show a registered device
module classifying a request.

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

Previous Now
[integrations.<id>] with enabled = true, and [integration] module the module in the section of its type, [cmp] module = "didomi", [ad-tag] modules = ["google"] and so on, with [<type>.<name>] only for settings
[ec.providers.<name>] [ec.<name>]
[permission_signal] sources [permission-signal] modules
host-signals, client-fixed, gpp-sale-opt-out, gpp_sale_opt_out, us_privacy host_signals, client_fixed, gpp, us-privacy
[auction.providers.<id>] with protocol, profile and profile_config [demand] modules and [demand.<name>], with implementation and the settings flat in the table
profile = "standard" implementation = "auction-protocol.openrtb"
profile = "prebid-server" and profile = "aps" implementation = "auction.prebid-server" and implementation = "auction.aps"
[auction] mediator = "adserver_mock" and [integrations.adserver_mock] [ad-server] module = "mock" and [ad-server.mock]
[integrations.aps] rendering_mode rendering_mode in the [demand.<name>] table of the auction.aps implementation
[debug.auction_html_comment_options] include_mediator_response include_adserver_response

The word mediator goes with those keys. It is "ad server" in prose and
ad-server in configuration, and the auction response metadata that read
parallel_mediation now reads parallel_adserver.

A file in the old shape is not read on a best-effort basis. [auction] providers and [auction] mediator are both refused with a message naming
where the setting moved to, and so are [adserver], [integration] and a
provider key.

ts prebid server inspect follows the file. It reads the [demand.<name>]
tables whose implementation line is auction.prebid-server, and the
[auction.prebid] table. Each demand source
it reports carries selected, which is whether [demand] modules names it,
and the report's own selected is whether [auction] modules selects
prebid, in place of the removed enabled_explicit.

How it was verified

The head this pull request shows now is 49bdf21b8, which merges the move of
the integration modules, 31 commits ending at f5a2858fa, into a90a141bc,
the head that carries main at 182fdf4. The tree of 49bdf21b8 is the tree of
f5a2858fa.

On f5a2858fa, on Windows, cargo fmt --all --check, the core suite (2,557
tests and doctests), the Fastly alias under Viceroy (3,565 across its test
binaries, the Fastly adapter's and every module crate's included), the Axum
alias (897 tests, which runs every module crate's tests natively), the
Cloudflare (56 tests) and Spin (90 tests) adapter suites, the cross-adapter
parity suite (17 tests), the seam-probe fixture's own tests (11), the Viceroy
configuration generator's own tests (8) and the 29 tests of the integration
target that need no container, Clippy with warnings denied on all four
adapters, the permissions inspector's 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 the audit generator, the bundle command, ts prebid server inspect, the environment overlay test and the tests of deploy validation were
changed by reading and are verified by CI alone, where the host-target Clippy
of the CLI and the CLI's own tests on Ubuntu and macOS pass. CI caught two
faults that way, a type error in the audit generator on the section change,
fixed in ad734fd, and the settings validated without the stock modules that
the section on the move records, fixed in 9ce9e3e and 190ea05. Four
browser script files changed with the move, being two comments and a test
fixture path that named a Rust file or the Google Publisher Tags bootstrap
script by its place in core. CI runs the browser script's lint, format check
and tests, and they were not run again locally.

On CI, every workflow passes on 49bdf21b8: Run Tests, Run Format, Integration Tests, Permissions Inspector and CodeQL Advanced. Run Tests passed on its second attempt. The first failed in one test of the ts tool, collects_gpt_slot_from_local_fixture, on a browser launch timeout, which #1256 describes. GitHub's code
scanning check is the one check that shows as failing, because CodeQL carries one open
alert on this branch and on no other in the stack,
alert 192
(rust/cleartext-logging, high, raised on 15 September), on the orchestrator's
log line that names the ad server, its logical budget and its transport
timeout. The move of the modules added no alert. The query reaches that line
by tracing a test helper's secret store into the runtime services the
transport timeout is canonicalized through, and the line logs an identifier
and three millisecond values, so the alert stands as reported, with the
reason in a comment at the line, rather than being worked around in the code.
The same log line is on main, with the word mediator where this branch says
ad server. Dismissing the alert needs security write access on this
repository.

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.

Core's auction engine still builds and reads OpenRTB itself. Only the
plain demand implementation moved, so OpenRTB as a translation at the edge of
the auction is a later change.

Vendor logic in core's own files is mostly untouched. What sat in an
integration module has moved. What sits in core's own files has not, for
example Prebid's identifier cookie handling, the Prebid slot parameters in the
creative opportunities settings and Google Ad Manager slot paths. Four small
pieces of it are removed, being the Prebid error variant (Prebid's crates
raise Integration, which answers the same), the unused models.rs and Gam
error variant, Prebid's typed request extensions, and two Prebid names in the
routing code.

Each vendor's browser script is still in crates/trusted-server-js, and
core TypeScript still imports APS directly (section 8, item 8).

auction.ts
and
types.ts
import and name the APS renderer, so moving APS's Rust did not move its
browser code.

The consent decoders are still in core. The TCF, GPP and US Privacy
decoders sit in crates/trusted-server-core/src/consent, and the permission
signal crates read the consent context core decoded.

The stock list is written by hand (section 3.1). The design discovers the
built-in integrations at build time from their directories. Core's own list
holds the JavaScript asset proxy alone, and the modules a stock build ships
are listed in
crates/trusted-server-modules,
written in hook order, because the order of that list is the order the page
hooks run in, and a generated list needs that order stated another way
first.

Page changes are still the four hook traits (section 3.7). The middleware
contract and the [[fetch]] and [[serve]] entries are designed in #1084
and are not in this pull request. They follow in a pull request of their
own.

A crate that supplies only an Edge Cookie, geo or device module does not
run from its selector alone (section 3.6).
[ec] module, [geo] module
and [device] module select a module by the name its registration declared
it under, and the module that supplies it has to be selected in a section as
well, because the registry builds only what a section selects. [ec],
[geo] and [device] are read by core and are not sections that run a
builder, so a crate that supplies only one of these modules cannot yet be run
from its selector alone. [geo] and [device] also hold no settings table
for a module. The first vendor identity, geo or device crate needs the
first, and a geo or device crate with settings of its own needs the second.

Nothing is refused for a missing host signal (section 3.6). A registration
declares no host-signal requirements, so a module that needs a signal the
running adapter does not expose is not turned back at startup.

No test checks the order of several hooks on one registration (section 6,
item 1).
The acceptance round trips run through the Axum adapter and, under
Viceroy, through the Fastly adapter, which is where items 1 and 2 ask for
them. 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.

A deployment's own module is not known to the stock CLI (section 8, items 1,
6 and 15).
ts config validate and ts config push validate against the
modules a stock build ships, so each of those modules' own rules runs on the
path an operator uses. A module from a crate a deployment added is not on that
list. Its validate function runs at startup for a deployment that loads its
settings with its builders, and wherever the deployment's own code calls
validate_settings_for_deploy_with, which is why the seam probe still repeats
its check inside its build function.
docs/guide/integration-guide.md
records the gap, and the operator path needs a decision, which is whether the
CLI is built per deployment with its vendor crates.

No auction runs through the probe's demand source or ad server (section 8,
item 10).
Both reach the plan, the settings load and deploy validation from
outside core, and neither is driven through a request. What an implementation
outside core does during an auction is held by the tests in the
implementations' own crates, which drive APS and Prebid Server through core's
request driver, response reader and orchestrator, and the plain OpenRTB demand
through the request driver.

No test loads settings from a config store with registered builders on
Fastly (section 8, item 7).
The Fastly adapter takes a module crate through
run_with, so no adapter is edited to compose one. The load with a
deployment's builders is tested in core, and the Fastly adapter passes the
registered builders to it in one call that no test drives. The test that
registers through the process-wide list also leaves the probe on offer for
the tests that run after it in the same binary, where it runs only if the
settings select its module, which none of them do.

A module can still be resolved more than once per request (section 8, item
3).
On POST /auction location is looked up to build the Edge Cookie context
and again in handle_auction, and the seam probe's proxy route calls its geo
module as well. This pull request adds no per-request module 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.

The ts audit detection patterns stay in the CLI. The
ts audit
command 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. What
the audit writes for a module it detects, being the section that selects the
module and its name there, comes from the stock list.

What the round trip does not exercise. The seam probe declares a proxy, a
carried browser module, a request preparer, and geo, Edge Cookie and device
modules. 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. The probe's device module classifies a request in the
Fastly adapter's tests, through the function the entry point calls for every
request, and no request sent to a router is classified by it.

@aram356

aram356 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

The sequencing discussion for this series is on #1084. This PR is superseded rather than rejected. The design in §3.6 is accepted and most of the provider work carries over onto the reordered base. See #1084.

@jwrosewell

Copy link
Copy Markdown
Collaborator Author

Sequencing note on the rc/202608 release branch and this stack.

While checking today's activity I found that rc/202608 (cut from main at d516a9e on 28 August, currently at 07dfc1c) carries 67 merged PRs, including #940, #823, #1016, #1036, #1039 and #1079, in 643 commits. main is an ancestor of it, so it can land on main as a fast-forward at any time.

A merge simulation of rc/202608 against this PR's head (b557d87) conflicts in 33 files, listed below. The same simulation against #1084's head (1800604) is clean, because that branch is specs only.

  • crates/trusted-server-adapter-axum/src/app.rs
  • crates/trusted-server-adapter-axum/tests/routes.rs
  • crates/trusted-server-adapter-cloudflare/src/app.rs
  • crates/trusted-server-adapter-fastly/src/app.rs
  • crates/trusted-server-adapter-fastly/src/main.rs
  • crates/trusted-server-adapter-fastly/src/platform.rs
  • crates/trusted-server-adapter-spin/src/app.rs
  • crates/trusted-server-adapter-spin/src/platform.rs
  • crates/trusted-server-core/src/auction/README.md
  • crates/trusted-server-core/src/auction/formats.rs
  • crates/trusted-server-core/src/auction/mod.rs
  • crates/trusted-server-core/src/auction/orchestrator.rs
  • crates/trusted-server-core/src/auction/test_support.rs
  • crates/trusted-server-core/src/config.rs
  • crates/trusted-server-core/src/config_payload.rs
  • crates/trusted-server-core/src/ec/device.rs
  • crates/trusted-server-core/src/integrations/aps.rs
  • crates/trusted-server-core/src/integrations/datadome/protection.rs
  • crates/trusted-server-core/src/integrations/mod.rs
  • crates/trusted-server-core/src/integrations/registry.rs
  • crates/trusted-server-core/src/platform/traits.rs
  • crates/trusted-server-core/src/proxy.rs
  • crates/trusted-server-core/src/publisher.rs
  • crates/trusted-server-core/src/response_privacy.rs
  • crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml
  • docs/guide/api-reference.md
  • docs/guide/auction-orchestration.md
  • docs/guide/configuration.md
  • docs/guide/ec-setup-guide.md
  • docs/guide/error-reference.md
  • docs/guide/fastly.md
  • scripts/template-cache-local-test.sh
  • trusted-server.example.toml

Reproduce:

git fetch upstream main rc/202608
git merge-tree --write-tree --name-only upstream/rc/202608 b557d87e4

The request is the one we made on #940. Please land this stack (#1043 to #1047, #1084 and this PR, all based on main) before rc/202608 merges to main, or if the release branch has to go first, say so here so we rebase once against a known base rather than chase one that moves as it takes more PRs. Every one of the 33 files is one we would have to re-resolve by hand, and the count rises with each PR merged into the release branch.

@jwrosewell
jwrosewell force-pushed the split/7-integration-seam-impl branch 7 times, most recently from 35584db to b64b45c Compare September 1, 2026 23:24
@jwrosewell
jwrosewell force-pushed the split/7-integration-seam-impl branch 2 times, most recently from c6dd589 to 54337f0 Compare September 2, 2026 16:32
Comment thread .github/workflows/inspector.yml Fixed
@jwrosewell
jwrosewell force-pushed the split/7-integration-seam-impl branch 3 times, most recently from 1bfb2e6 to 95be9fb Compare September 2, 2026 19:39
The `hb_adid` fallback in `build_bid_map_with_auction_id` wants one
field, the bid identifier, and reached it through
`BidRenderer::payload_as::<ApsRendererV1>`, which clones the entire
payload map and deserializes it into an owned struct. One of those
fields is `aaxResponse`, a base64 creative envelope capped at 256 KB, so
every bid on every page view that runs the ad stack copied that envelope
twice, once into the cloned `Value` and once into the owned `String`,
only to drop both.

`BidRenderer::payload_field` borrows one value out of the payload with
the same type-tag check `payload_as` applies, and the call site uses it
with a new `APS_RENDERER_BID_ID_KEY` naming the wire key. Nothing is
serialized differently: `payload_field` only reads, so the wire form is
untouched. `payload_as` stays for the callers that genuinely want the
whole descriptor, with a doc note pointing one-field readers at the new
accessor.

The one behavior difference is that `payload_field` does not check that
the rest of the payload deserializes, so a malformed payload that still
carries `bidId` now yields it. Every APS payload is built by
`from_typed` from an `ApsRendererV1`, so no production path reaches that
case, and the accessor documents it.

Evidence the output is unchanged:

- `renderer_bid_id_key_matches_the_serialized_form` (aps.rs) asserts the
  new accessor returns exactly what `payload_as` deserializes, and pins
  `APS_RENDERER_BID_ID_KEY` to the camelCase name `ApsRendererV1`
  actually serializes.
- `bid_map_prefers_the_renderer_bid_id_over_ad_id_and_the_openrtb_bid_id`
  (publisher.rs) gives every `hb_adid` source a different value and a
  200 KB envelope, so it passes only if the renderer field is the one
  read.
- `bid_map_ignores_a_renderer_bid_id_carried_under_another_type_tag`
  covers the tag check at the call site.
- The pre-existing `bid_map_exposes_aps_renderer_and_selected_bid_id`
  still passes untouched.
The comment on the carried-module hash check said the SHA-256 costs one
per request on Fastly and "once per process on the other adapters". That
is wrong on two hosts of three.

Reading the pinned EdgeZero adapters (v0.0.4, git checkout 9e661ae):
`edgezero_adapter_cloudflare::run_app` calls `A::build_app()` inside the
per-request entry point, and its own comment says "every Worker request
re-enters this function"; `edgezero_adapter_spin::run_app` does the same,
with the matching comment about `#[http_service]`. Fastly's `run_app`
also calls `A::build_app()` per request, and this repo's Fastly `main`
calls `TrustedServerApp::build_app_with_state()` per request in any case.
Only `edgezero_adapter_axum::run_app` calls `A::build_app()` once before
serving, and this repo's Axum `main` likewise calls
`TrustedServerApp::routes()` before handing the router to the dev server.
So the application is built once on Axum and per request everywhere else.

Corrected here:

- `integrations/registry.rs`, the carried-module hash comment, which is
  the comment the finding names.
- `docs/guide/configuration.md`, which said a provider-selector failure
  "shows there on every request rather than once" only because "the
  Fastly adapter builds the application per request". It shows on every
  request on Cloudflare Workers and Spin too.

`tsjs_bundle.rs`'s `compose_hash` doc carried the same claim and was
corrected in the previous commit.

Also corrected, though pre-existing rather than new on this branch: the
`AppState` doc comments in the Cloudflare and Spin adapters both said
"built once at startup and shared across all requests", which is false
for the same reason, and the Axum one now says why it is the exception.
Fastly's already said "once per Wasm instance" and is accurate.
`the_open_renderer_serializes_to_the_same_bytes_as_the_aps_variant_did`
compared `serde_json::to_string` against two literal strings whose keys
happen to be in alphabetical order. That order holds only while
`serde_json::Map` is a `BTreeMap`, which is only while the crate's
`preserve_order` feature is off.

The feature is reachable in this workspace. `trusted-server-cli` depends
on `edgezero-cli`, which depends on `handlebars`, which declares
`serde_json` with `features = ["preserve_order"]` unconditionally. Cargo
unifies features across everything built for one target, so any command
that builds the CLI and core together for the host target turns the map
into an `IndexMap` inside core. `cargo tree -p trusted-server-core
--target x86_64-pc-windows-msvc -e features -i serde_json` shows no
`preserve_order`; adding `-p trusted-server-cli` to the same command
shows three. A maintainer running `cargo test --workspace --target
<host>` gets the second build, and the test would fail there for a
reason that has nothing to do with the wire form.

Both sides now go through a `with_sorted_keys` helper before comparison,
so the key order is fixed by the test rather than by which map
`serde_json` was compiled with. The literals are unchanged, byte for
byte, and nothing is weakened: two JSON objects serialize to the same
sorted bytes only when they carry exactly the same keys with exactly the
same values, which is the promise the literals were there to make.
Fallout from rebasing this branch onto the five-PR stack rather than onto main,
where both sides had changed the same functions.

The Axum adapter's test helper still called `build_orchestrator`, which this
branch's import list replaced with `build_orchestrator_with_providers`. It now
calls the latter with no external providers, which is the same thing.

The Cloudflare adapter had a test calling `build_per_request_services` with the
old `(ctx, settings)` argument pair. That function now takes `(state, ctx)` so it
can apply a module's geo provider, and the test has no application state, so it
calls `build_runtime_services` directly, which is what it actually wanted.

Four doc comments still said an unset `[geo] provider` leaves the host's own
lookup in place. Reconciling the two geo designs settled that unset resolves
nothing and makes no host geo call, matching the permission model's default, and
that `"platform"` is the explicit opt-in to the host lookup. The comments now say
that on all four adapters.

Addresses: rebase of this branch onto split/5, conflicts resolved in the
composition roots and the geo selector
Two checks were written when the host lookup was the only geo provider there
could be, and both treat any other value as no geo at all.

validate_jurisdiction_acknowledgment asked whether the selector was
`"platform"`, so a deployment selecting a module's geo provider was told it had
none and made to set assume_single_jurisdiction to acknowledge something untrue.
It now asks the question the other way round: only an unset selector and the
explicit `none` resolve nothing, and everything else resolves a location.

build_geo_provider's doc said an unknown provider is rejected by
GeoConfig::validate_provider_selection, which is no longer where that happens.
The function returns DisabledGeo for a module id because it cannot see the
registry, and the adapter then substitutes the module's provider. The doc now
says that the value is the base the adapter starts from rather than what serves
the request, and that the registry is the layer that rejects a selector naming a
module with no geo provider.

The seam probe's test settings also predate the permission model, so they now
carry the required [geo] default_country. The two tests that select a geo
provider write their own [geo] table, so the fixture adds one only when the
caller has not.

Addresses: reconciling the geo selector across the permission model and the seam
Answers the architectural finding on IABTechLab#1043 rather than deferring it. Vendor
identity reached core through RuntimeServices injection while everything else a
module supplies was declared on its integration registration, so there were two
extension mechanisms and identity was on the second one.

A registration can now declare an Edge Cookie provider and a device provider the
same way it declares a geo provider. The registry resolves each against its
selector, warns when a module declares a capability the selector does not
choose, and the adapters apply all three to RuntimeServices in one place.
RuntimeServices gains a device slot, which it had no way to carry before, and
with_ec_provider and with_device_provider to match with_geo.

Identity and device differ from geo in one way that matters. Both have providers
built into core, so a selector naming a built-in is not an error at the registry,
and resolution returns None for those and lets core resolve them as before.

Two closed allowlists had to open, being the same fault in two more places. The
device selector accepted only `builtin` and `fastly`, so any module id was
rejected before the registry saw it. The jurisdiction check asked whether the geo
selector was `platform`, so a deployment selecting a module's geo provider was
told it had none. Neither could stand once a module can supply these.

Proven by the probe, which now declares all three capabilities from one
registration. Two tests select the module for identity and for device and assert
the resolved provider is the module's own. The probe's identity provider mints a
value the built-in HMAC provider cannot produce, so a passing assertion means the
module's provider ran rather than core's.

Addresses: Aram, `ec/provider.rs`, vendor identity should lean on the integration
system rather than a second extension mechanism
The test-only constructors of IntegrationRegistryInner still set only the geo
fields, so the Fastly target failed to compile while the Axum one did not,
because the two targets gate those constructors differently.

Addresses: build failure from the registration capability change
A setting that holds a secret holds the name of its key in a pushed
configuration and the secret once the settings load. Core named DataDome's
two such settings in three places: the list of secret paths, the step that
clears a reference nothing uses before the lookup, and the deploy check that
a reference in use names a key. A module with a secret could not leave core
while core listed its settings by name.

A builder now declares them with `with_secret_settings`, each as a path
inside the module's own table and a rule for when the table as written puts
it to use. Core does the three things from the builders it is given, for
any module. DataDome declares its server-side key as in use when protection
is on, and its test bypass credential when the bypass is on as well, which
are the rules core applied for it.

The settings load takes the declarations from the builders it already
validates against, so a module a deployment added has its secrets looked up
as a stock one does. A leaf listed twice is looked up once, because the
fixed list and the load can both name a module core itself offers, and a
second lookup would read the secret as the name of a key.

A module's table sits under the name its section selects it by, which may
be the short name or the full one. The paths are listed and cleared at both,
where the code this replaces looked only at `bot-protection.datadome`.
DataDome is the module `bot-protection.datadome`, and it now lives in
`crates/bot-protection/datadome` with its Protection API filter and its
protection scope. The stock list offers it in the place its hooks ran, and
the settings that select and configure it do not change. It leaves its tag
suppression for one request, and declares its two secret settings, through
the hooks any module has.

Its 59 tests went with it and eight were added from what core tested for it:
that a suppressed document keeps and rewrites the publisher's own tag, that
an ordinary one gets the client configuration, that the settings load looks
up both secrets when both are in use and clears each one that is not, the
deploy check of an invalid test bypass, and the refusal of a removed
setting. The stock list crate checks that a registered list puts DataDome's
two settings among the leaves a push treats as naming a key.

Core's tests that used DataDome use core's stand-in, or the neutral marker
they were about. The stand-in gains a request filter that leaves its mark
for a header, so core still tests state a filter leaves reaching the
document, which is the route DataDome takes. The scheduling test's origin
document gains a head. It asserted that DataDome's tag was absent from a
document that had no head for any tag to be written into, so it could not
fail, and it now asserts that what a module left on the request is written.
The tests of a personalized response say personalized where they said
suppressed, and no longer set DataDome's marker beside the neutral one.
Core's configuration tests use a module that exists only in the test.

Core's settings no longer remove DataDome's two deprecated secret store
selectors from its table as they are read. DataDome accepts and discards
both, as it did, so a configuration that carries one still loads, and a
pushed configuration now keeps them.
`ts audit` turns the integrations it finds on a page into the section that
selects each one's module and the name written there. It took each module's
name from `trusted_server_core::integrations`, so it stopped compiling when
the first module left core, and no local check showed that because the `ts`
tool cannot be built on the machine this branch was made on.

The stock list crate now answers the question by integration id from the
builders it already lists, with a test of every id the generator knows, and
the generator asks it. The generator names no module of a vendor's. Prebid
is the one exception, because it is registered through the auction plan and
no builder carries its name, and it stays in core for now.
Prebid and APS each register a page integration that depends on the
compiled auction plan, so the registry called both by name before it ran
any builder, recorded Prebid's id by hand, and listed Prebid's module name
among the ones a section may select. Deploy validation called Prebid's two
checks by name for the same reason. Neither module can leave core while
core does that.

A builder now registers from the plan with `with_plan_registration` and
checks its settings against the plan with `with_plan_validator`. A
registration from the plan is made whether or not a section selects the
module, because the function decides from the plan and the settings whether
the module runs, and its hooks go ahead of every module a section selects,
which is where Prebid's and APS's ran. Prebid is a builder like any other,
selected in `[auction]`, and APS's implementation builder carries its
registration. The registry and the deploy check name neither.

The page path read the identifier a renderer picks its bid by with APS's
type tag and APS's payload key. The code that builds a renderer descriptor
now states which payload key holds that identifier, with `picking_bid_by`,
and the page path asks the descriptor. A descriptor built without the
statement has no such identifier whatever its payload holds, so a key of the
same name under another renderer's tag is still not read, which a test
pins. The statement is no part of what is sent to a page.
The demonstration ad server is the implementation `ad-server.mock`, and it
now lives in `crates/ad-server/mock`. The stock list offers it, so
`[ad-server] module = "mock"` selects it as before and its settings do not
change. Its 22 tests went with it.

Core's tests of the ad server seam selected the mock because it was the one
ad server there was. They select a stand-in compiled for core's tests only,
`ad-server.fixture`, which reads an endpoint and a timeout, refuses any
other setting and answers every request with no bid. That covers the plan
compiler's tests of how a name resolves within `[ad-server]` and how a
table's settings are checked, the registry's refusal of an auction
implementation named in a section, and three tests that built a mock
provider only because they needed a provider.

One orchestrator test ran APS through the mock ad server and checked that
the winner kept its seat, its bidder name and its renderer. It is a test of
the two together, so it is in the stock list crate, where both are on
offer, and it drives the orchestrator through its public interface.

The bid dimension helpers the mock ad server admits a bid with are public,
so an auction implementation in a crate of its own applies the rules core's
response reader applies.
The Amazon Publisher Services demand implementation is `auction.aps`, and
it now lives in `crates/auction/aps`. The stock list offers it, so a
`[demand.<name>]` table with `implementation = "auction.aps"` selects it as
before and its settings do not change. Its builder registers the page
support the APS renderer needs when the auction plan selects an APS source.

Its 38 tests went with it, and 24 more came from core, where APS had been
the example the engine was tested with:

- three tests of the renderer descriptor APS writes,
- eight that run an APS source through the orchestrator and the response
  reader,
- four of the plan compiler and the page registration,
- six of the request APS builds, with both golden requests carried byte for
  byte,
- three of the deploy check of APS's own settings.

Those tests run against core's engine from APS's crate, so core offers what
they need under its `test-utils` feature. That is the helpers that build an
auction context, a demand table and a plan, two drivers that build a
request for a plan's first source and read a response as that source, and
the backend double in the platform test doubles.

Core's own tests of routing, the plan compiler, the request builder, the
response format and the page's bid map use core's plain OpenRTB source, or
a renderer descriptor of the test's own tagged `example`, in place of APS.

`ERROR_TYPE_HTTP_STATUS` and `ignored_bidder_params_count` are public, so
an implementation in a crate of its own reports a failed call and counts
ignored bidder parameters the way core does.
`integrations/prebid.rs` held three things: the page integration, the
request, response and bidder parameter override code the Prebid Server
demand runs, and the provider and combined configuration kept for the test
build. The shipped code falls into two parts with nothing shared between
them, so the file is divided along that line and nothing it does changes.

The page integration stays in `prebid.rs`. The override rules and their
engine, the transport headers, the response reader and the upstream error
text join the demand in `prebid_server.rs`, with the test-only provider
that the tests compare the demand with. Neither file refers to the other.

The combined test configuration is divided the same way, so each file
keeps the fields its own code reads. The page's is `LegacyPrebidConfig`,
the browser settings and the bidder list a plan would supply. The demand's
keeps the name `LegacyPrebidServerConfig`.

Of the file's 178 tests, 57 stay with the page integration and 120 go
with the demand. One is not carried:
`register_rejects_invalid_bid_param_override_rule` checked that the page
registration built from the combined configuration refused a bad override
rule, and that registration no longer compiles override rules. The
provider's own test of the same refusal stays. `validate_config_for_startup`
was test-only and nothing called it, so it is gone.
The Prebid page integration is the module `auction.prebid`, and it now
lives in `crates/auction/prebid`. `[auction] modules = ["prebid"]` selects
it as before and its settings do not change. The stock list offers it
first, so what it registers from the auction plan still runs ahead of
APS's page support and of every module a section selects.

Its 57 tests went with it, and seven came from core, where they checked
rules that are Prebid's own: the removed `server_url` setting is refused,
the two deploy rules for the external bundle, deploy and runtime
validation both reaching Prebid's table, a bidder owned by the browser and
by the server being refused only while the auction is on, and the shim
loading deferred with the bundle route registered. The two bidder tests
ran through the configuration blob in core and call runtime validation
with Prebid's builder here, which is the step of the blob load they test.

Core's shared test settings selected Prebid, so every test of core and of
every other module ran with Prebid in it. They now select no auction
module, and Prebid's tests select Prebid themselves. Core's tests that
used Prebid as the one deferred browser module there was, or as the
integration whose settings reach a page template, run on a stand-in
compiled for core's tests only, `testing.deferred-fixture`, which carries
a deferred module and writes its two settings into `<head>`. The settings
tests of a leftover `enabled` key and of a module named twice use the
example module they already had.

`AuctionPlan::browser_bidder_codes` and `AuctionPlan::with_enabled` are
public, so a page module in a crate of its own reads the plan's bidder
routes and a test switches a compiled plan off.

The `ts` audit generator asks the stock list where Prebid is selected, as
it does for every other module, and the `ts prebid server inspect` tests
read Prebid's configuration type from Prebid's crate.
The Prebid Server demand implementation is `auction.prebid-server`, and it
now lives in `crates/auction/prebid-server` with its request extensions,
its bidder parameter overrides, its transport headers, its response reader
and the provider kept for its tests. The stock list offers it, so a
`[demand.<name>]` table with `implementation = "auction.prebid-server"`
selects it as before and its settings do not change.

Its 124 tests went with it. Sixteen more are in its crate, run through
core's driver from outside core:

- eight that core held for it, which are the body consent by source and
  forwarding mode, the debug query fragment, the ordered overrides with the
  stored request as fallback, each impression paired with its slot's
  parameters, the stored intent applied after overrides, empty parameters
  falling back to a stored request, the exact request golden, and two
  sources run through the orchestrator,
- eight for the Prebid Server half of a test core keeps, which are the
  consent matrix, the language tag, a disabled empty candidate, the signed
  request golden, the unsigned request identity, the endpoint completed
  through the plan, the refusal of all-eligible routing and the default
  timeout.

Both request goldens are carried byte for byte.

Core's auction tests used Prebid Server as the implementation that departs
from the plain one. They run on a stand-in compiled for core's tests only,
`auction.fixture`, which serves stored requests, forbids all-eligible
routing, takes bidder parameters, keeps the unsigned request identity and
completes an endpoint that names a host alone. Twenty-nine tests of
routing, the plan, the formats and the driver pass on it with the name
alone changed, and six read the stand-in's extension or its endpoint where
they read Prebid Server's. Their names, labels and messages say what they
test.

`ResponseAdmissionDiagnostics`, `build_bid_dimension_index`,
`apply_notification_policy` and `ProviderSlotInput::allows_stored_fallback`
are public, so an implementation in a crate of its own admits bids, applies
the notification policy and reads a slot's stored request intent the way
core does. Two test drivers are added under `test-utils`,
`route_to_first_source` and `routed_transport`.

Prebid's test of the browser injection compiles its plan with Prebid
Server's builder, the `ts` tool reads the implementation's name from its
crate, and the stock list states the two rules its order carries.
…list

The adapter's test settings select Prebid and a Prebid Server demand
source, as a deployment does. Two test helpers compiled the auction plan,
and one of them built the registry, from core's built-in modules alone,
which held while both lived in core. With Prebid and the Prebid Server
demand in crates of their own the helpers failed, and the adapter's test
binary stopped at the first of them.

Both helpers now take the stock list's builders, which is what the
adapter's own entry point does. The two commits before this one left the
Fastly tests failing, and they pass again here (3,572 in the Fastly build).
The integration guide, the integrations overview, the configuration guides
and the onboarding page still described an integration as code inside
`trusted-server-core`, or as "still in core" with the name its crate would
have. Every page integration now has a crate of its own, so they say that:
an integration is a crate under `crates/<type>/<vendor>/` here or in a
repository of the vendor's, and a module's name is that crate's path.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The Prebid crate's two tests of a bidder that runs on both sides name the
plain implementation and hand its builder to the validation they call.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants