Skip to content

Add the provider documentation set and finish the decomposition - #1047

Open
jwrosewell wants to merge 64 commits into
IABTechLab:mainfrom
jwrosewell:split/5-response-hook-docs
Open

Add the provider documentation set and finish the decomposition#1047
jwrosewell wants to merge 64 commits into
IABTechLab:mainfrom
jwrosewell:split/5-response-hook-docs

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fifth and final PR of the stack decomposing #838 as requested in the #986 review. Stacks on #1046. Compare split/5-response-hook-docs to split/4-client-resolve to see only this PR's change.

Specs carried by this PR:

What this PR does

  • The IntegrationResponseMutator response-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.
  • Documentation for the provider model: the 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 joining the docs navigation.
  • The example configuration documents every provider selector in one place, with the [geo] baseline explained and the single-jurisdiction acknowledgment shown commented next to the key it concerns.
  • The Axum and Cloudflare native test jobs also run on 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.

@jwrosewell
jwrosewell force-pushed the split/5-response-hook-docs branch 3 times, most recently from 7ebce99 to 700c913 Compare August 25, 2026 10:51
@jwrosewell jwrosewell changed the title Add the integration response-header hook and the provider documentation set Add the provider documentation set and finish the decomposition Aug 25, 2026
@jwrosewell
jwrosewell force-pushed the split/5-response-hook-docs branch 4 times, most recently from c17a7ea to 5b63f48 Compare August 27, 2026 05:37
@jwrosewell
jwrosewell force-pushed the split/5-response-hook-docs branch from 5b63f48 to 0bab4c0 Compare August 27, 2026 15:10
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
jwrosewell force-pushed the split/5-response-hook-docs branch from 0bab4c0 to 3cfe393 Compare August 27, 2026 15:33
…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
jwrosewell force-pushed the split/5-response-hook-docs branch from 3cfe393 to 45acb97 Compare August 31, 2026 12:50
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.
@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.

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.

2 participants