Add the provider documentation set and finish the decomposition - #1047
Open
jwrosewell wants to merge 64 commits into
Open
Add the provider documentation set and finish the decomposition#1047jwrosewell wants to merge 64 commits into
jwrosewell wants to merge 64 commits into
Conversation
jwrosewell
force-pushed
the
split/5-response-hook-docs
branch
3 times, most recently
from
August 25, 2026 10:51
7ebce99 to
700c913
Compare
jwrosewell
force-pushed
the
split/5-response-hook-docs
branch
4 times, most recently
from
August 27, 2026 05:37
c17a7ea to
5b63f48
Compare
jwrosewell
force-pushed
the
split/5-response-hook-docs
branch
from
August 27, 2026 15:10
5b63f48 to
0bab4c0
Compare
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Aug 27, 2026
The five-PR series (IABTechLab#1043 to IABTechLab#1047) opens the identity, device and geo seams. The nine vendor integrations already in core sit behind the integration registry instead, which is a private table, so none of them can move out until that table is opened. This spec defines the one core change that opens it: public registration builders with a second input on IntegrationRegistry, browser JavaScript carried on the registration, startup validation as a hook, the same treatment for auction providers and the bid renderer contract, and neutral replacements for the two places where a vendor reaches into core. It then sets out the migration of all nine existing integrations, one PR each. The change is complete in itself: after it, no vendor move needs a core change. Written against the series' tree with the file and line references for every claim about the current code. Documentation only.
jwrosewell
force-pushed
the
split/5-response-hook-docs
branch
from
August 27, 2026 15:33
0bab4c0 to
3cfe393
Compare
…ider
First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.
- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
The deprecated [ec] passphrase form still starts for one release
cycle: it maps to provider = "hmac" with a deprecation warning, and a
configuration carrying both forms is rejected. provider = "none"
spells explicit statelessness. A configured block that is not the
selected provider is rejected at startup, as is a block with no
selector.
- Global identifier bounds enforced by core at mint, read-back, and
cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
cap. An identifier outside the bounds is rejected loudly, never
rewritten, so the cookie value and the identity-graph key can never
silently diverge.
- The identity graph is keyed by the provider's canonical form of the
identifier (normalize_id_for_kv), so equivalent representations of
one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
access to the client IP, headers (including cookies), URL path, and
query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
provider, so a vendor provider lives in its own crate and core never
names it. A selected provider the adapter does not inject fails the
request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
proceeding stateless.
Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.
Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.
The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.
Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.
is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
CodeQL's cleartext-logging query treats a call whose name contains "passphrase" as a sensitive source, and because the method mutates the Settings it belongs to, every later log line that prints anything from Settings (store names, timeouts, header names) is reported as writing a secret to a log. The passphrase itself is a Redacted<String> and none of the flagged lines prints it. The method now describes what it does, migrate_legacy_ec_layout, and its behavior is unchanged.
A reviewer raised a P1 against the pluggable Edge Cookie provider work: three of the four adapters broke the provider contract that an unavailable required service or an uninjected provider stops the request. The Axum, Cloudflare and Spin adapters each read the Edge Cookie context with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`, logged a warning and continued with `EcContext::default()`. A deployment whose selected provider could not be built therefore came up and served every request with no identity, silently. The Fastly adapter already kept the report and answered with an error response. `build_ec_context` on the three adapters now returns `Result<EcContext, Report<TrustedServerError>>` and every call site propagates it to that adapter's own `http_error`, the same helper Fastly uses, so all four answer with the same status and shape. The design this implements has the composition root check a selected provider's needs once at startup rather than per request, so `ensure_provider_available` was added to `ec/provider.rs` and is called from `build_state_with_settings` on all four adapters (Fastly included, so the rule is uniform). Building a provider reads no request data, so a selection an adapter can never supply now fails when application state is built, and the three adapters answer every route from their existing `startup_error_router` instead of coming up. Statelessness, meaning no `[ec] provider` selector or the explicit `"none"`, still passes and still serves. The widening question was checked rather than assumed. `read_from_request_with_geo` can only fail from two places: the provider build, and a `Cookie` header that is not valid UTF-8. A malformed cookie value is dropped with a warning by `request_ec_id_if_allowed`, consent parsing returns a value rather than a `Result`, and the geo lookup is already swallowed by the adapter before the call, so no ordinary parse problem reaches the error path and none is turned into a failed request. Tests: each of the three adapters gains a route test proving an uninjected provider fails at startup, and an in-crate test proving `build_ec_context` returns the error rather than a default context. Core gains a test that the startup check rejects an uninjected provider and still allows statelessness both ways. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1)
`Settings::finalize_deserialized` runs derive validation before `Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase` field carries no `#[validate]` attribute of its own, so the advertised 32-byte minimum was only enforced on the new `[ec.providers.hmac]` location. A configuration still on the old form could start with `passphrase = "short"`, or with an empty value, and mint identifiers from keying material the new location rejects. The migration now calls `Ec::validate_passphrase` on the value it is about to move, before it logs the deprecation warning and writes the `[ec.providers.hmac]` block, and reports a configuration error naming the minimum length and the new location. Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives `Settings::from_toml` with the `[ec]` section rewritten to the deprecated form and proves a short value and an empty value are both rejected, and that a passphrase of adequate length still migrates to `provider = "hmac"` with the passphrase in the hmac block. Removing the new check makes that test fail, so it tests the fix rather than the surrounding code. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench)
The provider spec (section 6) says `deny_unknown_fields` is set on both built-in provider config structs, but `HmacProviderConfig` carried no such attribute, so `[ec.providers.hmac] typo_key = "x"` was accepted silently. An operator who mistypes a key gets a deployment that starts and quietly uses the default for the setting they meant to change. `HmacProviderConfig` now sets `#[serde(deny_unknown_fields)]`, matching `Ec` itself and the rest of the settings tree. The struct is a plain field of `EcProviders` rather than a flattened one, so the attribute does not collide with the `#[serde(flatten)]` vendor map alongside it. Tests: `an_unknown_key_in_the_hmac_provider_block_is_rejected` adds an unknown key to the block in the crate test configuration and proves `Settings::from_toml` fails and names the key. Removing the attribute makes that test fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:726 (wrench)
`build_provider`'s `"hmac"` arm mapped over `ec.providers.hmac`, so a deployment that selected `provider = "hmac"` with no `[ec.providers.hmac]` block got `Ok(None)` and ran stateless under a selector that says it has an identity provider. Every other unbuildable selection in the same match already errors. The arm now returns `TrustedServerError::EdgeCookie` naming the missing block, which the startup check `ensure_provider_available` turns into a failed application state on every adapter. `Ec::validate_provider_selection` rejects that pair before settings reach the composition root, so nothing routes through the new arm today. It is the drift guard for the case where the two checks stop agreeing, which is exactly the shape of the defect being fixed, so it is worth keeping rather than leaving the silent branch in place. Tests: `selecting_hmac_without_its_block_fails_loudly` builds the `Ec` programmatically, bypassing settings validation to reach the seam, and proves the error names the missing block. The doc comment's `# Errors` section is corrected in the same commit, since it still claimed no built-in construction can fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:304 (refactor)
The error raised when a provider mints an identifier outside the identifier bounds was written across two source lines without the trailing backslash that joins them, so the 22 spaces of source indentation became part of the literal and the logged message read "...bytes, or outside the cookie-safe alphabet". The continuation is restored, so the message reads as one sentence. The whole of ec/mod.rs was scanned for the same fault, matching every string literal and stripping real continuations before looking for runs of more than one space or a newline inside a literal. This message was the only one. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:444 (nitpick)
The paragraph written for the `EdgeCookieProvider` trait sat at the top of `ProviderCode`'s doc block, so rustdoc rendered it as part of that struct's documentation and the trait itself had no doc comment at all. A vendor implementer opening the trait saw nothing, and a reader of `ProviderCode` saw two subjects run together. The paragraph moves onto the trait and `ProviderCode` keeps only the registry text that belongs to it. The moved sentence was also stale: it said a provider returns `Ok(None)` from `generate`, but `generate` returns a `GeneratedEdgeCookie` and signals "no identifier this request" through its `id` field. The sentence now describes the actual return, with an intra-doc link to the field. `cargo doc --no-deps` reports no warning against either item. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:177 (nitpick)
`ec::get_ec_id` had no callers anywhere in the workspace, and this
branch loosened its filter to accept any well-formed `{code}~` value
with no ownership check against the selected provider. A future caller
picking it up would adopt another provider's identifiers, which
`EcContext` deliberately treats as absent.
The no-callers claim was checked across every crate in the workspace
(the four adapters, the CLI, core, the integration tests, openrtb) plus
benches, tests and docs. The only matches are for a different,
crate-private `edge_cookie::get_ec_id`, which reads the `x-ts-ec` header
as well as the cookie and is what `proxy.rs` and the testlight
integration call.
Deleted rather than realigned, for two reasons. The workspace sets
`publish = false`, so `trusted-server-core` is not distributed and
nothing outside this repository depends on the symbol. And aligning the
filter would mean calling `provider_owns_id`, which needs a
`&dyn EdgeCookieProvider` that a function taking only `&Request` cannot
obtain, so it would have meant changing the signature of a function with
no callers. `EcContext::read_from_request` already performs the
provider-aware read that production uses.
`parse_ec_from_request`, `is_valid_ec_id` and `log_id` all keep other
callers in the module, so nothing else becomes dead. The core README
line that advertised the helper is removed in the same commit.
Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:137 (nitpick)
The `ec/provider.rs` module doc said a provider's constructor takes the services it needs, naming `RequestInfo` as the example, and its opening sentence was garbled where two half-sentences had been spliced together. `RequestInfo` is not a constructor argument. It is borrowed per call as the `request_info` parameter of `EdgeCookieProvider::generate`, so the first thing a vendor implementer read contradicted the trait they were about to implement. `evidence.rs` carried the same claim in its own words, that a constructor takes services as `Arc<dyn Trait>` supplied per request. Nothing in the workspace passes `RequestInfo` that way. Every use site is a `&dyn RequestInfo` argument. Both module docs now describe the real shape, which is construction once at startup from configuration or adapter injection, then borrowed request evidence on every call with nothing retained. The `evidence.rs` title changes to match, and its pointer to the borrowed view `BorrowedRequestInfo` is named alongside `OwnedRequestInfo`. Documentation only, no behavior change. `cargo doc --no-deps` reports no warning against either module. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:4 (nitpick)
The keys `"hmac"` and `"none"` were spelled as bare string literals at
four places: `Ec::validate_provider_selection`, `build_provider`,
`provider_owns_id`'s `provider.id() == "hmac"` check, and a private
`HMAC_PROVIDER_CODE` in `ec/generation.rs`. Nothing tied them together,
so a fifth built-in provider would add a fifth spelling and a typo in
any one of them would compile.
`EcProviderSelection { None, Hmac, Vendor(String) }` now holds the
vocabulary in `ec/provider.rs`, with `NONE_KEY` and `HMAC_KEY` as the
only places those two words are written. Vendor keys are open-ended, so
the catch-all `Vendor` variant takes any other key and
`#[serde(from = "String", into = "String")]` gives the enum an
infallible conversion in each direction rather than a hand-written
visitor. `HMAC_PROVIDER_CODE` moves next to it as a `ProviderCode`
const, built from `HMAC_KEY`, and `generation.rs` uses that instead of
its own copy. `HmacProvider::id` and `HmacProvider::code` return the
same two constants.
`Ec::provider` becomes `Option<EcProviderSelection>`, so the two
validation paths and `build_provider` match on variants rather than
comparing strings, and `Option` still distinguishes an absent selector
from an explicit `"none"` exactly as before.
The configuration surface is unchanged. The selector reads and writes
the same string, so an existing `trusted-server.toml` parses to the same
choice and a config push writes the same key back.
Tests: `the_selector_round_trips_through_serialization` parses `none`,
`hmac` and an arbitrary vendor key from TOML, checks each maps to its
variant, and checks each serializes back to the same string.
`each_selection_builds_what_its_string_key_built_before` proves the
three selections still build what they built before, which is nothing
for `none`, the built-in provider with the built-in code for `hmac`, and
the adapter-injected provider of that id for a vendor key.
Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs (refactor)
A provider's response headers were inserted into the outbound response without any check on what they set. A provider could return `Set-Cookie: ts-ec=...`, including on a request where it minted no identifier at all, and so write the managed identity cookie without going through core's identifier validation or its requirement that a minted identifier have an identity-graph row. It could also overwrite an `x-ts-*` header or a framing header. Core now defends by reserving its own namespace rather than banning `Set-Cookie`, because providers legitimately need cookies of their own. `reserved_response_effect` in `ec/provider.rs` classifies one header and rejects three things: a `Set-Cookie` naming a cookie in the `ts-` prefix core manages (`ts-ec`, `ts-eids`, `ts-tester`), a header in the `x-ts-` namespace core emits and strips, and a message framing or hop-by-hop header (RFC 7230 6.1 plus `content-length`, the same set each adapter's `is_hop_by_hop_response_header` uses). Everything else, a provider's own cookie included, passes through unchanged. The cookie name is read from the raw header bytes so a value that is not valid UTF-8 cannot smuggle a managed name past the check. A rejected effect fails the request rather than being dropped with a log. The check sits in `EcContext::generate_with_provider`, the only place provider headers are captured, next to the identifier-bounds check that already fails the request when a provider mints outside the cookie-safe alphabet. Both are the same kind of fault, a provider breaking its contract, and this branch has already decided that identity problems stop the request rather than serving without identity. Finalization cannot fail a request in any case, since it returns no result. Tests cover the classifier directly (managed cookie, reserved header, framing header, a non-UTF-8 `Set-Cookie`, and the allowed cases), and cover both halves through the organic generate path: a provider setting `ts-ec` with no identifier fails the request, and a provider setting its own `acme-evidence` cookie mints normally and has that cookie reach the response alongside core's own `ts-ec`. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/finalize.rs:57 (P2)
`is_valid_ec_id` is the built-in HMAC grammar and rejects every other provider code, yet pull sync, batch sync, and the admin lookup all called it directly. A deployment running a non-HMAC provider therefore minted and read identifiers on the organic path that these three paths skipped or rejected. PR IABTechLab#1044's `hs00~` host-signal provider makes that concrete. The check is now split in two, in `AcceptedProviders` in `ec/provider.rs`. The global cookie bounds, the length cap and the cookie-safe alphabet in `ec_id_has_only_allowed_chars`, apply to every identifier whoever minted it. The rest is dispatched by the `{code}~` prefix to the provider that owns that code, which canonicalizes its own value part and decides whether the canonical form is one of its own. Dispatch is on the code alone, before any provider inspects a value, so an identifier a partner echoed back in a different case still reaches its own provider to be canonicalized rather than being rejected first. KV normalization goes the same way through `canonical_kv_key`, so a row is always keyed by the owning provider's canonical form. A code no configured provider reads is rejected. The set of accepted providers is the deployment's active provider. `legacy_providers`, the design's list of readers that never mint, is not implemented on this branch (the key is rejected as unknown, see section 6.1 of the pluggable-providers design), so `AcceptedProviders::active` fills the reader list with the one active provider. The list is the seam: configured legacy readers are pushed alongside it and neither `accepts` nor `canonical_kv_key` changes. With no provider selected at all the deployment is stateless, and the built-in grammar stays the fallback, matching what `EcContext::accepts_id` has always done. Wiring: `EcContext::accepts_id` now goes through `AcceptedProviders`, so pull sync validates through it; `handle_batch_sync` and `handle_admin_ec_lookup` take the selected provider, which the Fastly adapter builds at both call sites. Tests cover a non-HMAC identifier accepted in pull sync, batch sync, and the admin lookup; a code neither active nor configured rejected in batch sync and the admin lookup, including one in the built-in HMAC shape; KV normalization dispatched to the owning provider (the built-in lowercases its hash segment, an opaque provider keys verbatim); and the global bounds rejecting before any provider is consulted. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/generation.rs:207 (P2)
`EcContext::generate_if_needed` failed the request whenever the host could not determine a client IP, before the selected provider was asked anything. `RequestInfo::client_ip` already defines the empty string as the unavailable state and providers are meant to read only the evidence they need, so the generic check blocked every header-, cookie-, query- and client-derived provider that can work without an IP. The requirement moves into `HmacProvider`, whose only input is the client IP. With none it fails rather than hashing the empty string into an identifier every visitor on that host would share. That failure propagates out of `generate_if_needed` exactly as the old check did, so a provider that genuinely needs the IP and cannot get it still fails the request rather than quietly minting nothing, matching this branch's decision to stop rather than serve without identity. Providers that read other evidence now receive the documented empty value and run. `HmacProvider` is the only provider on this branch that reads the client IP; the injected vendor seam leaves the decision to each vendor crate. Tests cover both answers: a provider deriving identity from the request query and cookies mints on a host with no client IP, and the built-in HMAC provider refuses on the same host with no identifier committed. A `noop_services_with_ec_provider_without_client_ip` test helper models that host. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:376 (P2)
The permissions module doc said device and geo providers are refused when their required permissions are not set; only the Edge Cookie provider is gated today, and the built-in device and geo providers require none. It also said only two purposes are resolved against signals, while every TCF purpose is. An intra-doc link pointed at a function that does not exist. The spec and guide now say that publisher navigation and page-bids user.id ride the sharing pair, that a malformed permissions.yaml fails at settings load, that there are three opt-out-over-TCF pinning tests, that a US-style opt-out revokes whether or not a TCF record is present, and that the US has one country rule.
This branch makes `[geo] default_country` required whenever an Edge Cookie provider is configured, and the shared test config in crates/trusted-server-core/src/test_support.rs gained it. Five inline fixtures that build Settings directly did not, so they failed validation at `Settings::from_toml` and panicked: - crates/trusted-server-adapter-cloudflare/src/app.rs and crates/trusted-server-adapter-spin/src/app.rs (`UNINJECTED_PROVIDER_TOML`, used by `build_ec_context_fails_when_the_selected_provider_is_unavailable`) - crates/trusted-server-adapter-cloudflare/tests/routes.rs and crates/trusted-server-adapter-spin/tests/routes.rs (`selecting_a_provider_this_adapter_cannot_supply_fails_at_startup`) - crates/trusted-server-adapter-fastly/src/app.rs, in `dispatch_edge_authenticated_esi_request_stores_then_hits_template`, which configures the deprecated `[ec] passphrase`; that migrates to the hmac provider, so it needs the default country too Each fixture gets `default_country = "FR"` and `assume_single_jurisdiction = true`, the same pair and reasoning as the shared test config: FR is the gdpr-eu baseline where every permission requires a signal, and no geo provider is selected in these tests. Verified with `cargo test-cloudflare`, `cargo test-spin`, `cargo test-fastly` and `cargo test-axum`, all of which failed before this change. Produced with AI assistance; needs human review.
The permission model spec promised that a failed geo lookup resolves every permission to the requires-signal floor and never the deployer's `[geo] default_country`, calling it the fail-closed handling of an outage. The rule is implemented correctly, but nothing could reach it. Every `PlatformGeo` implementation in the workspace returns `Ok` on every path, and `PlatformError::Geo` has no construction site at all, so the only production route to `GeoStatus::Failed` (the `Err` arm in `read_from_request_resolving_geo`) is dead. The only test of the state built the enum by hand. Checking whether a host lookup can fail settles which half to change. Fastly's `geo_lookup` is declared `Option<Geo>`, and the SDK collapses every hostcall status, buffer-size and deserialization failure into `None` before the caller sees it. The Cloudflare provider reads request headers, where every parse failure degrades to empty. Axum and Spin have no host geo service. `DisabledGeo`, the default unless `[geo] provider` is `"platform"`, resolves nothing by construction. So no adapter shipped today can surface a failure, and a real geo outage arrives as `Ok(None)`, which is `NoLocation`, and falls through to the default country. With a default that grants storage, that is fail-open where the spec claimed fail-closed. Rather than invent a failure the hosts cannot have, I left the mechanism alone and made the docs say what actually happens. The `Result` on `PlatformGeo::lookup` is the contract for a provider that does its own fallible lookup, which is exactly what a vendor crate under `crates/geo/` would be, so the floor is a live rule for a provider that can fail and an unreachable one for the providers shipped now. Both the spec and the operator guide now say so plainly, and both tell a deployer the thing they will act on: whatever the default country grants is what a geo outage grants, so choose it on that basis. The new test drives the failure through the seam rather than constructing the status, using a `PlatformGeo` that returns `PlatformError::Geo` and a default country that grants storage, so it can only pass if the failure reaches the floor. `build_services_with_geo` is the injection point it needed.
The operator guide said the core runs a provider only when every permission that provider requires is set, written generically enough to cover all three provider kinds. Only the Edge Cookie provider is actually gated that way. The single production read of required_permissions is in ec/mod.rs. Device and geo providers declare a permission set through the same method and nothing checks it, so the reads in ec/device.rs and platform/mod.rs are both inside test modules. Three sentences said the general thing. Each is now scoped to the Edge Cookie provider, and the guide states plainly that a device or geo declaration is recorded rather than enforced, so an operator does not rely on it to keep a provider from running. Addresses: docs/guide/permission-model.md, claim without code behind it
The permission model is easy to read as though the core decides which vendor may see which signal. It does not, and writing code on that assumption works against the architecture. Every provider and every integration sees all the evidence available for a request, host signals included. The core stays neutral between vendors, so it never withholds a signal from one and not another. What a vendor may do with what it sees is the governed part, decided by the permissions it declares and the permission model sets. Access is universal, use is gated. Recorded in CLAUDE.md, which every contributor and coding agent reads, and in the operator guide, which states the same thing for someone configuring a deployment. Both say the practical consequence plainly: if a use needs controlling, express it as a permission rather than by hiding the signal. Addresses: the permission model's neutrality between vendors, previously implied but never stated
The permission model reads as though the permissions were invented here. They were not. They are the IAB Tech Lab Privacy Taxonomy Data Uses, with the IAB TCF Europe purposes mapped onto them where no Data Use exists yet, and that was recorded only in doc comments inside the code. Someone reading permissions.yaml, which is the file an operator edits to change policy, had no way of knowing where the names came from. It says so now, and the operator guide carries a short section on the same point, because a declaration an auditor can check against a published taxonomy is worth considerably more than one they can only check against us. Three clause-joining colons in the surrounding guide prose are fixed in the same pass. Addresses: permissions.yaml and docs/guide/permission-model.md, an unattributed vocabulary
ProviderCode::new returns an Option since it stopped panicking on a malformed code, and one test provider on this branch still used it as though it returned the code directly, so the test target did not compile here even though it compiles further up the chain where a later commit had already moved it. That is worth noting beyond the one line. cargo check does not build test code, so a per-branch check passes while the test target is broken, and the fault only shows at whichever branch someone happens to run the tests on. Each pull request in this chain has to stand on its own, so it is fixed where it breaks. Addresses: crates/trusted-server-core/src/ec/provider.rs:1752, test target not compiling on this branch
Fourth slice of the PR 838 decomposition. A client-cycle Edge Cookie provider defers at the edge and lets the page derive the identifier in the browser; the page posts it to POST /_ts/api/v1/ec/resolve and the provider verifies it before the edge mints it as the Edge Cookie: - EdgeCookieProvider gains resolve_from_client with a no-op default, so server-side providers are untouched. ClientResolveInput carries the posted payload plus the request's resolved permissions and consent. - The resolve endpoint enforces the same rules as organic generation and several of its own. The permission gate applies unchanged. The request must carry an Origin on the publisher's domain (an identity-setting POST must not be drivable from a foreign page) and a text/plain or application/json body. A minted identifier must fit the identifier bounds (400), must not silently replace a different identity already on the request (409), and is persisted to the identity graph before the cookie is set, keyed by the provider's canonical form, so withdrawal reaches a client-set identity the same way it reaches an edge-minted one. With no graph available nothing is minted, matching the organic rule against phantom cookies; a graph write failure is 503. Every response carries Cache-Control: no-store. - The Edge Cookie stays HttpOnly. A non-HttpOnly companion marker cookie (ts-ecr=1, no identity content) tells the page script a resolve succeeded, so it does not re-post on every page view; the marker expires together with the Edge Cookie on withdrawal. A Rust test asserts the marker name and the demo's fixed word stay in sync with the page script source. - The client-fixed demonstration provider (fixed shared word, verify-before-mint) is compiled only behind the trusted-server-core client-fixed-demo cargo feature. Production builds reject the selection at startup: a fixed shared word is not an identity. - The Fastly adapter routes the endpoint and passes the same bot-gated identity graph as generation, so unrecognized clients cannot mint through resolve either. The other adapters deliberately do not route it yet, matching identify and batch-sync, which need the same platform KV wiring those adapters lack. The design spec for this slice lives at docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md, the 2026-07-31 threat-model draft revised to the implemented state, with the deferred reservation design retained verbatim as the bar for the first vendor scheme.
The spec used em dashes throughout; they are replaced with plain punctuation. The resolve handler's doc claimed every response carries Cache-Control: no-store, but a provider or configuration error leaves through the error path to the adapter's own response, so the doc now says every response the handler builds.
`ec/provider.rs` documents that core checks every provider response header against its reserved surface before applying it, so a provider may set its own cookies and headers but cannot reach into the surface core manages. The organic mint path honours that in `EcContext::generate_with_provider`. The resolve endpoint applied provider headers with no check at all, on both the 204 path where the provider mints nothing and the 200 path where it does. That let a browser-side provider set the managed `ts-ec` cookie directly and bypass the identifier bounds check, the conflict check and the row-before-cookie rule that sit a few lines below it. Removing the new check and re-running the tests shows the 204 response going out as `204` with `set-cookie: ts-ec=forged-value; Path=/` and no identity-graph row behind it, which is an external reviewer's own finding on this stack, answered on the Edge Cookie provider branch and reintroduced here. His words were that a provider can set the managed cookie "including when it returns no identifier", which is exactly that path. The check is the same call the organic path makes, placed immediately after `resolve_from_client` and before the identifier is read, so one check covers both paths for the same reason the organic one sits there: a provider can return headers with no identifier at all. A breach fails the request rather than producing a status, matching the organic path and the module's existing rule that a provider error propagates to the adapter's error response, because a provider reaching into the reserved surface has broken its contract rather than sent a bad request. Tested on both paths: a managed `ts-ec` cookie with nothing minted, and an `x-ts-ec` header on the minted path.
The two sites in the resolve handler had the same defect the EC finalization loop had: `HeaderMap::insert` replaces every existing value for a header name, so a provider setting more than one cookie kept only the last, and a provider setting a header this handler already wrote silently replaced it. On this path the replaced header is core's own rather than an origin's, and the one that matters is `Cache-Control: no-store`. Every response the handler builds carries it, because an identity response must never be cached, and an inserting write let a provider drop it. Removing the fix and re-running the new test shows both: the provider's first cookie gone, and `no-store` replaced by the provider's `max-age=600`. Both sites now go through the shared `apply_provider_response_headers`, which carries the rule and is the same helper EC finalization uses, so the two paths cannot drift.
`ProviderCode::new` is fallible and returns `Option<ProviderCode>`, but this test provider passed its result straight back from `code()`, which returns `ProviderCode`. The test module therefore did not compile. Nothing caught it because the only checks run against this branch were `cargo check`, which builds the library and not the `#[cfg(test)]` modules, so the branch looked healthy while its own tests could not be built. The macro is the established form for a fixed code and is already used by the neighbouring test provider in this same file. It resolves the code in a `const` block, so a malformed code fails the build rather than the request, and there is no `Option` left to unwrap. Addresses: crates/trusted-server-core/src/ec/resolve.rs, where the `ResolveHeaderProvider` test double could not compile.
Fifth and final slice of the PR 838 decomposition: - Documentation for the provider model: configuration reference for the [ec], [device], and [geo] sections (including the required default_country, the assume_single_jurisdiction acknowledgment, and the requires-signal floor on a failed lookup), the Edge Cookie guide rewritten around providers and the permission model (including the narrow withdrawal semantics, the hardened resolve endpoint, and the resolved-marker cookie), setup and error-reference updates, and the permission-model guide joins the docs navigation. - The example configuration documents every provider selector in one place, with the [geo] baseline explained and acknowledged. - The HostSignals trait moves to the top of the evidence module with its service framing documented. - The Fastly EC lifecycle integration-test job joins the test workflow. The earlier draft of this slice carried an IntegrationResponseMutator response-header hook. It shipped with no consumer, so it is not included: the hook returns together with the first integration that needs it. Two specs land with this slice: the migration and rollout spec (docs/superpowers/specs/2026-07-30-provider-migration-rollout-design.md), its sign-off ledger kept intact with each row the series implements marked with its PR, and the response-header hook spec (docs/superpowers/specs/2026-07-30-integration-response-header-hook-design.md), retained as the design bar for the hook when its first consumer arrives.
The rollout spec cited commit SHAs from a rebuilt branch, credited the series with a passphrase tightening that pre-dates it, said graph rows were keyed verbatim when the pre-series normalization already lowercased them, attributed the Fastly-only identity endpoints to PR 1046, and claimed a CLI regression test for the provider overrides that does not exist. The hook spec said the fastly device provider is startup-rejected (it ships opt-in), that integrations cannot touch response headers (request filters can name them) and that the wasm target builds with panic = abort (it has no unwinding at all). The configuration guide now lists every Edge Cookie and geo provider value, the API reference documents each resolve response and the coded identifier form, and the setup guide shows the hmac~ prefix a minted cookie carries.
The no-client-IP test now derives identity from the request query and cookies through `EvidenceCapturingProvider`, which left `NoClientIpProvider` with no remaining caller. It is deleted rather than left looking like a live fixture, and the branch builds without the dead-code warning that denied clippy. That test's comment claimed the provider receives the documented unavailable value, the empty string, but the rewrite no longer observes what the provider was handed, so nothing proved it. The comment now says only what the test asserts, which is that a provider reading other evidence still mints when the host can determine no client IP. Restoring the stronger assertion would mean recording the client IP the provider saw, which is a change to the fixture rather than to this cleanup. The `http::HeaderMap` import in the device tests was unused, because the one place needing the type names it in full. Addresses: crates/trusted-server-core/src/ec/mod.rs and crates/trusted-server-core/src/ec/device.rs, which carried a dead test provider, a comment asserting more than the test proved, and an unused import.
The rewrite that replaced the old no-client-IP fixture left its comment claiming the provider receives the documented unavailable value while nothing asserted it, so the contract went unchecked and the comment said more than the test knew. The shared evidence-capturing provider now records the client IP it was given alongside the query parameter and cookie it already recorded, and the test asserts the empty string reaches it. A provider cannot decide how to behave on a host that cannot determine a client IP without knowing what absence looks like, so that value is part of the contract rather than an implementation detail. Verified by falsifying the expectation and watching the assertion report the real value it observed rather than passing regardless. Addresses: crates/trusted-server-core/src/ec/mod.rs, a comment claiming more than its test proved
The core library is exercised only through the WebAssembly adapter targets, which build with panic=abort. Their harness stops at the first failing test and reports every later one as never run, so a red build shows one failure when there may be several, and the ones it hides are invisible until the first is fixed and the suite is run again. This adds a host-target run of the same crate to the job that already runs the CLI and codegen tests natively, so every failure is reported at once. It costs one more compilation of a crate the job has already built for other targets. Addresses: .github/workflows/test.yml, core failures hidden behind panic=abort
jwrosewell
force-pushed
the
split/5-response-hook-docs
branch
from
August 31, 2026 12:50
3cfe393 to
45acb97
Compare
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Aug 31, 2026
The five-PR series (IABTechLab#1043 to IABTechLab#1047) opens the identity, device and geo seams. The nine vendor integrations already in core sit behind the integration registry instead, which is a private table, so none of them can move out until that table is opened. This spec defines the one core change that opens it: public registration builders with a second input on IntegrationRegistry, browser JavaScript carried on the registration, startup validation as a hook, the same treatment for auction providers and the bid renderer contract, and neutral replacements for the two places where a vendor reaches into core. It then sets out the migration of all nine existing integrations, one PR each. The change is complete in itself: after it, no vendor move needs a core change. Written against the series' tree with the file and line references for every claim about the current code. Documentation only.
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Aug 31, 2026
The review of IABTechLab#1043 asked that spec changes land before the code that implements them, so a divergence is a decision taken in review rather than a ratification of something already merged. PRs IABTechLab#1043 to IABTechLab#1047 each carried the design document for their own step, and IABTechLab#1043 carried a 607-line spec describing device providers, geo providers, the permission model and the browser resolve endpoint, none of which is in that PR. Move all six series documents here, so this PR carries the complete normative set and no code: - 2026-07-30-pluggable-providers-design.md (from IABTechLab#1043) - provider-code-registry.md (from IABTechLab#1043) - 2026-07-30-permission-model-design.md (from IABTechLab#1045) - 2026-07-30-client-cycle-ec-resolve-design.md (from IABTechLab#1046, later revised by IABTechLab#1047) - 2026-07-30-integration-response-header-hook-design.md (from IABTechLab#1047) - 2026-07-30-provider-migration-rollout-design.md (from IABTechLab#1047) Each file is taken verbatim at the tip of the stack, so the later revisions are preserved: the provider-switching continuity section, the geo requires-signal floor, and the code-envelope paragraph IABTechLab#1047 added to the client-cycle spec. The revision-record tables are unchanged. No document's substance was edited. The only edits are to this spec's own status line, which said the PR adds one document and that the series specs land with IABTechLab#1047, and a revision-record row recording the move.
Collaborator
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fifth and final PR of the stack decomposing #838 as requested in the #986 review. Stacks on #1046. Compare
split/5-response-hook-docstosplit/4-client-resolveto see only this PR's change.Specs carried by this PR:
What this PR does
IntegrationResponseMutatorresponse-header hook from the earlier draft of this PR is removed. It had no consumer, which is the spec set's own rule against speculative surface. It returns together with the first integration that needs it, and its spec stays in the tree as that design's starting bar.[ec],[device], and[geo]sections (including the requireddefault_country, theassume_single_jurisdictionacknowledgment, and the requires-signal floor on a failed lookup), the Edge Cookie guide rewritten around providers and the permission model (including the narrow withdrawal semantics, the hardened resolve endpoint, and the resolved-marker cookie), setup and error-reference updates, and the permission-model guide joining the docs navigation.[geo]baseline explained and the single-jurisdiction acknowledgment shown commented next to the key it concerns.windows-latest.What happens to #838
Once these five PRs merge, #838 is closed. It stays open as a draft reference for the review period only.
How it was verified
Full local gate on this branch, all clean.
cargo test-fastly,cargo test-axum,cargo test-cloudflare,cargo test-spin, the integration parity suite, docs prettier check,cargo fmt --check, and all six per-target clippy aliases.References #777 and #778. Decomposes #838. Spec baseline from #986.
Produced with AI assistance under James Rosewell's direction, and flagged here so reviewers know to apply the usual scrutiny.