Skip to content

Add the permission model with the Privacy Taxonomy vocabulary - #1045

Open
jwrosewell wants to merge 51 commits into
IABTechLab:mainfrom
jwrosewell:split/3-permissions
Open

Add the permission model with the Privacy Taxonomy vocabulary#1045
jwrosewell wants to merge 51 commits into
IABTechLab:mainfrom
jwrosewell:split/3-permissions

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Third of five stacked PRs decomposing #838 as requested in the #986 review. Stacks on #1044. Compare split/3-permissions to split/2-device-geo to see only this PR's change.

Spec: docs/superpowers/specs/2026-07-30-permission-model-design.md, the Tech Lab 2026-07-31 draft revised to match this implementation, with a revision-record table listing every divergence and why. Reader-facing documentation lands with it at docs/guide/permission-model.md.

What this PR does

Permissions become the primitive that gates identity features, and consent is one of several ways a permission is established (a country baseline, an opt-out signal, and configuration are others).

  • permissions.rs resolves a per-request permission state from the country and region baseline in permissions.yaml, augmented by the session's signals (TCF, GPP, GPC, US Privacy). Permission names follow the IAB Privacy Taxonomy Data Uses.
  • Signal precedence is fixed in code, most restrictive first. A US-style opt-out suppresses the Data Uses the policy revokes even when a TCF record consents, because an explicit opt-out is never overridden by another signal. A consent record that is present but cannot be decoded blocks baseline grants (fail-closed) instead of degrading to the no-signal baseline. Only then does a TCF record decide its mapped Data Uses. Pinning tests cover each opt-out source against a consenting TCF record.
  • Destructive withdrawal is narrow. Only a TCF record refusing storage in a jurisdiction whose baseline did not grant it expires the cookie and writes the identity-graph tombstone. Opt-outs suppress use (headers stripped, nothing egressed) but never destroy an issued identifier, so lifting the opt-out restores the identity.
  • Sharing beyond the edge (bidstream user.id, the identify response, partner pull-sync) requires storage plus personalised-ad selection, the same pair that gates bidstream EIDs, so a storage-only grant keeps first-party use while withholding partner sharing.
  • The Edge Cookie gate moves from raw consent to the permission model. A provider declares required_permissions() and core executes it only when every one is set.
  • [geo] default_country becomes required and is validated against permissions.yaml at startup, so there is always a defined permission baseline. A failed geo lookup is distinct from an unmatched country. It resolves at the requires-signal floor instead of the deployer default and is logged at error level. Geolocation is now off by default, and a deployment that runs an Edge Cookie provider with no geo provider must set [geo] assume_single_jurisdiction = true, acknowledging that every request is treated as the default jurisdiction.
  • permissions.yaml rules use an explicit per-permission acquisition map (granted, requires_signal, denied). Unknown keys in a rule and duplicate rule keys differing only by case are rejected at parse. An EU-27 plus EEA coverage test locks the gdpr-eu mapping.

How it was verified

Full local gate on this branch, all clean, including the reinstated opt-out precedence pinning tests and per-trigger withdrawal units. cargo test-fastly, cargo test-axum, cargo test-cloudflare, cargo test-spin, the integration parity suite, cargo fmt --check, and all six per-target clippy aliases.

Framing

Privacy is a spectrum and technology is neutral. This model encodes no jurisdiction's law. The deployer brings the policy in permissions.yaml and configuration, decides their own baselines, and the code makes those decisions inspectable and enforced. Trust comes from that flexibility being respected, not from constraint.

References #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.

…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)
The mint-rejection fix restored one collapsed continuation in ec/mod.rs and
said the rest of that file was clean, which it was. The same fault exists in
four more places on this branch, so fixing only the reported one leaves the
pattern half addressed.

Each was written across two source lines without the trailing backslash, so the
source indentation became a run of spaces inside the message:

  ec/admin.rs:373                the invalid-EC-ID response an operator sees
  ec/finalize.rs:125             the skipped-response-write log line
  ec/provider.rs:635             the missing-client-IP error from the HMAC provider
  ec/pull_sync.rs:72             the skipped-dispatch log line
  integrations/testlight.rs:196  the no-recognized-EC-ID error

The continuation is restored in each, so every message reads as one sentence.
The whole of trusted-server-core was scanned for the same shape, matching runs
of five or more spaces inside a string literal. The only remaining matches are
TOML fixtures in settings.rs tests, where the embedded newlines are deliberate.

Addresses: ec/mod.rs:444 follow-up, the same fault outside the file first reported
The commit that rewrote the module docs to match the trait signature left two
item-level doc comments in the same file still describing constructor
injection, so the claim that nothing passes evidence by constructor was
contradicted three declarations further down.

IdentityInput's doc said request data reaches a provider "through the services
injected into its constructor". EdgeCookieProvider::generate's doc said the
identifier is derived "from the provider's injected services". Neither matches
the signature, which takes request_info: &dyn RequestInfo as a parameter and
reads evidence from it. The built-in HMAC provider does exactly that at
ec/provider.rs:632.

Both now describe the parameter the evidence actually arrives on. The crate was
searched for the same wording; the only other mention is in ec/mod.rs on a
test-only helper, where it correctly describes how the provider itself is
constructed rather than how request evidence reaches it.

Addresses: ec/provider.rs:4 follow-up, item docs still describing constructor injection
`ProviderCode::new` is public and validated its argument with `assert!`,
so any caller outside this workspace could take down a live request by
passing a code that was not exactly four characters of [a-z0-9]. The doc
comment claimed the panic "never" fires on a request path, which held
only for as long as every caller happened to pass a literal, and nothing
enforced that. A vendor Edge Cookie provider is exactly the caller the
claim could not cover.

`new` now returns `Option<ProviderCode>`, so it cannot panic whatever it
is given, and a caller outside core has to handle a malformed code. The
compile-time guarantee the codes in this workspace relied on moves into
a new `provider_code!` macro, which runs the same check inside a `const`
block, so a bad literal fails the build and the value it yields needs no
unwrapping. Every code in the workspace, the built-in HMAC code
included, now goes through the macro.

Addresses: crates/trusted-server-core/src/ec/provider.rs, where
`ProviderCode::new` could panic at run time while its documentation said
it could not.
`resolve_named_provider` looked for a built-in provider before the one
the adapter injects, so a vendor provider whose id is `hmac` was dropped
in favour of core's own and nothing said so. Nothing reserved the name
and nothing warned, which left an operator with a configured vendor
provider that never ran and no way to see why.

This is not only a missing warning. Once this work merges, IAB Tech Lab
is itself a vendor shipping an HMAC provider while core still ships one,
so two suppliers really can arrive under one name in a single
deployment, and there is no correct way to pick between them.

`build_provider` now refuses that pair through `ensure_no_name_collision`
and the error names both claimants, core and the deployment's adapter,
along with the contested name. The check runs before the selector is
read, so selecting a different provider does not hide the clash, and
because the adapters call it through `ensure_provider_available` while
they build application state, an operator is told at startup rather than
on the first request that happens to select the name.

Addresses: crates/trusted-server-core/src/ec/provider.rs, where
`resolve_named_provider` silently preferred the built-in `hmac` provider
over an injected one of the same name.
`EC_RESPONSE_HEADERS` in the EC finalization module and the first four
entries of `INTERNAL_HEADERS` in the constants module were the same four
header names written out twice, in two files, with nothing keeping them
in step. The two lists do different jobs, one is stripped from a
response the request may not carry an identity on and the other is never
forwarded to a third party, but every Edge Cookie output header has to
be in both, so adding a fifth to one and forgetting the other would send
Edge Cookie output to an origin that should never see it.

`EC_RESPONSE_HEADERS` now lives once, in the constants module, and
`INTERNAL_HEADERS` is assembled from it and the remaining internal names
while the crate is compiled, so the Edge Cookie half cannot be edited in
one place and missed in the other. EC finalization reads the same
constant instead of keeping a copy. The new test in the constants module
asserts the containment, the total, and that no name appears twice, so
going back to two hand-written lists fails the build.

Addresses: crates/trusted-server-core/src/ec/finalize.rs and
crates/trusted-server-core/src/constants.rs, where one list of Edge
Cookie response headers was maintained by hand in two places.
The composition root resolved `[ec] provider` and threw the provider
away, keeping only the knowledge that the selection could be satisfied,
and then the request path resolved the same settings again to get a
provider it could use. On the Fastly, Cloudflare and Spin adapters that
is twice for every request, because those three run a fresh instance per
request and rebuild application state each time, which was confirmed by
reading `run_app` in the matching edgezero adapters.

The composition root now keeps what it resolved, in `AppState`, and
hands the same instance to every request through the new
`RuntimeServices::resolved_ec_provider`. Core reads it through
`request_provider`, which returns the threaded instance when there is
one and otherwise resolves exactly as before, so an adapter that threads
nothing, the core tests and any embedder driving core directly included,
keeps today's behaviour, the loud failure on a selected but uninjected
provider included. Nothing about which provider is chosen changes, only
how many times the choosing happens.

The Axum adapter is deliberately left checking rather than keeping,
because it is a long-lived process whose application state is built once
at start-up, so it has no second resolution to save.

Addresses: crates/trusted-server-core/src/ec/provider.rs and the Fastly,
Cloudflare and Spin adapters, where `ensure_provider_available` and
`EcContext::read_from_request` each built the provider once per request.
The Spin adapter cannot start on upstream/main today, and this fixes it
here. `build_state` compiled `trusted-server.example.toml` into the
binary and parsed it, but that template ships placeholder secrets by
design and its placeholder admin password is the first entry in
`PASSWORD_PLACEHOLDERS`, so `validate_admin_handler_passwords` refused it
every time. `build_state` therefore never returned `Ok`, the router fell
back to the start-up error handler, and the component answered 503 to
every request. The failure is "Handler `^/_ts/admin` uses a placeholder
password; configure a strong secret".

Nothing caught it because nothing called `build_state`. Every Spin test
enters through the `routes_with_settings` parity seam and supplies its
own settings, so the one path a deployed component actually takes was
the one path never exercised.

Settings now come from the platform config store at run time, which is
what the Fastly, Axum and Cloudflare adapters already do, so an operator
publishes one with `ts config push` and the component reads it. The new
`SpinPlatformConfigStore` reads Spin component variables directly rather
than through the per-request handle, because application state is built
before any request context exists. Component variables are ambient, which
is how the secret store already reads them, and both paths map keys
through `spin_variable_name` so start-up and the request path read the
same variable for the same key.

The new test calls `build_state` and requires any failure to be the
absence of a config store. Outside the Spin runtime there are no
component variables, so it cannot return `Ok` under `cargo test`, but a
configuration compiled into the binary would fail for a different reason
and the test says so. Restoring the old body fails it with the
placeholder-password message.

Addresses: crates/trusted-server-adapter-spin/src/app.rs, where
`build_state` parsed a baked example template whose placeholder admin
password made every request fail.
`get_ec_id` is public on upstream/main today and this fixes it here. It
reads the `x-ts-ec` request header and then the `ts-ec` cookie, and
checks the result only with `ec_id_has_only_allowed_chars`. That function
is the global cookie backstop, the length cap and the cookie-safe
alphabet, and its own documentation in `ec/cookies.rs` says the strict
check is the one used to reject untrusted request values. On its own it
accepts any run of `[A-Za-z0-9._~-]` up to the cap, so it cannot tell an
identifier this deployment minted from one an attacker typed. `x-ts-ec`
is stripped from responses but not from inbound requests, so the header
really is the client's to set, and the raw reader prefers it over the
cookie. This is the inbound twin of the egress fault this branch already
fixes, which is why it belongs here.

The right check is not the built-in strict format validator. A vendor
provider's identifier is not required to match the HMAC
`<64 hex>.<6 alphanumeric>` shape, so holding every deployment to it
would drop exactly the opaque identifiers the provider model exists to
carry. The right check is provider ownership, where the `{code}~` prefix
is dispatched to the provider that owns it and that provider's
`accepts_id` decides, which is what `recognized_ec_id` already does and
what the EC lifecycle applies on read-back.

The raw reader cannot make that check, because it has neither settings
nor the selected provider, so it stops being a public entry point. It is
now `pub(crate)` and named `unvalidated_ec_id_from_request`, so no caller
can read it as returning a validated identifier, and `recognized_ec_id`
is the only way in from outside the module. Nothing outside the crate
called the old name.

The new test drives three identifiers this deployment could never have
issued through both readers, shows the bounds alone accept all three,
and requires the public path to recognize none of them, while an
identifier the selected provider does own is still returned. Replacing
the ownership check with the bounds fails it on the first one.

Addresses: crates/trusted-server-core/src/edge_cookie.rs, where
`get_ec_id` was public and validated client-supplied identifiers with the
outbound backstop list.
Reverts the removal of the request-evidence accessors, so RequestInfo carries
the client IP, the User-Agent, headers by name, header names, the path, the
query and its parameters again.

They were removed to satisfy a rule in our own specification, which says every
trait method needs a production caller in the change that introduces it. That
rule is right for a behavioural trait, where a method nothing calls is dead
weight. It is wrong for an evidence interface, and applying it here was our
mistake rather than anyone else's.

An evidence interface describes what a request carries, not what today's code
happens to read. Held to the caller rule it grows a method every time a vendor
arrives, so no vendor can write against it and it cannot stay stable across a
release. It also puts the boundary in the wrong place, because what a provider
may see was never the control. What a provider may do with what it sees is the
control, and that is the permission model.

The specification is amended in the same series rather than quietly ignored.

Two test provider codes restored with the revert predate ProviderCode::new
returning an Option, so they now build through the macro that cannot fail.

Addresses: crates/trusted-server-core/src/evidence.rs, an evidence interface
narrowed to today's callers
Fixes doc comments, error strings and TOML comments on split/1 so they
match the code they describe, and applies house-style wording rules to
every added line touched. Continues and completes work a prior agent
started (which stopped partway through the B3 item list), reviewed
against the run books at .claude/pr1/runbook-track-1-code-chain.md and
.claude/pr1/comments-docs-runbook.md in the trusted-server repo.

F2 (verified, already done by the prior agent): added "cache-control"
to FRAMING_OR_HOP_BY_HOP_HEADERS in ec/provider.rs with a regression
test, and removed the false reference to a per-adapter
is_hop_by_hop_response_header function.

B3.9 (verified, already done by the prior agent): recorded, rather
than fixed, the gap where pull sync (ec/pull_sync.rs) and the admin
lookup (ec/admin.rs) key identity-graph rows by the raw identifier
instead of the canonical form the three organic paths use. A doc
comment on EcContext::kv_key_for and AcceptedProviders now names the
gap and points at commit 343ac3e, which fixed the three organic paths.
Recorded as a known issue for a later change rather than changed now,
because routing these two paths through the canonical key this late
changes behavior.

Wrong-claim and stale-reference fixes (runbook Part B3, items 1-25):
verified each item against the current tree. Most were already
corrected by the prior agent (HmacProvider failure handling, the
environment-variable override claim, EcProviders selection docs,
provider construction timing, BorrowedRequestInfo allocation, the
generate_if_needed and validate_provider_selection # Errors lists, the
retirement-arithmetic doc, kv_key_for, ec_allowed, request_headers,
edge_cookie.rs recognized_ec_id, admin.rs and cookies.rs identifier
grammar, IdentityInput gating, and the plural "built-in providers"
wording in platform/types.rs). This pass added the one remaining fix:
crates/trusted-server-adapter-spin/src/app.rs no longer claims
Cloudflare reads settings the same way as Fastly and Axum (Cloudflare
also reads a JSON binding and compiles in the example TOML natively).

House-style sweeps (runbook Part C), restricted to lines split/1 added
over upstream/main d516a9e, verified per line via git diff before
editing:
- "mint"/"minted"/"mints" -> "create"/"created"/"creates" (or "issue"/
  "derive" by sense) in doc comments, inline comments and expect()/
  assert messages across ec/provider.rs, ec/mod.rs, ec/admin.rs,
  ec/batch_sync.rs, ec/cookies.rs, ec/finalize.rs, ec/generation.rs,
  ec/identify.rs, ec/pull_sync.rs, edge_cookie.rs,
  integrations/testlight.rs, platform/test_support.rs, proxy.rs, and
  crates/trusted-server-adapter-fastly/src/app.rs. Left the `mint: bool`
  test-builder struct field name alone (an identifier, not prose), and
  left every "mint" occurrence that predates split/1 alone (confirmed
  against upstream per file before editing; two lines were edited by
  mistake and then reverted once the upstream check showed they were
  pre-existing text, not split/1 additions).
- "initialise" -> "initialize" in the four adapters' build_state /
  build_state_with_settings doc comments.
- "several" -> "multiple" in ec/generation.rs.
- "HTTP/2 fingerprint" -> "TLS and HTTP/2 signal" in ec/generation.rs.
- "built-in HMAC default" -> "built-in HMAC provider" (there is no
  default provider) in crates/edgecookie/README.md.
- Environment variable casing: TRUSTED_SERVER__ec__provider ->
  TRUSTED_SERVER__EC__PROVIDER in trusted-server.example.toml, to match
  settings.rs and the documented upper-case form.
- Bytes vs characters: trusted-server.example.toml's new
  [ec.providers.hmac] block comment now says ">= 32 bytes" to match the
  startup error message, which counts bytes.
- A8 cargo-feature overclaim: trusted-server.example.toml no longer
  says a vendor provider "needs its own cargo feature" (none exists);
  it now says a vendor provider ships in its own crate the adapter
  composes in.
- A15 environment-loading overclaim: trusted-server.example.toml's [ec]
  block comment now matches settings.rs, saying deployment tooling can
  merge an environment value into the published configuration before
  load, and that the running server itself reads settings from the
  platform config store, not the environment.

Also fixed two doc-comment line-wrap glitches left by the prior
agent's edits (a stray single-word line in ec/provider.rs's
build_provider doc and in settings.rs's Ec::provider doc), where a
mid-sentence line break had been left in place after wording changed.

Left for a documented human decision rather than changed, per the
runbook's own "James decides" note: two em dashes in
trusted-server.example.toml:76 and crates/trusted-server-core/README.md
that follow an existing dash-separated heading/bullet convention used
throughout each file; and the one new "test-publisher.com" test URI in
ec/identify.rs, which matches roughly twenty pre-existing (not
split/1-introduced) occurrences of the same fixture domain already in
that file, so changing only the new one would be inconsistent and
changing the rest is outside split/1's introduced lines.

Out of scope for this branch, so not touched: the "no host-specific
call" overclaim (B1 item 1, on split/2/3), the geo-default docs (A1, on
split/6/7), and the drafted GitHub text fixes (A2-A4, A10-A16), all of
which live on later branches or in .claude/pr1/ review artifacts.

Not built or tested per instructions; verification is deferred to the
full-stack gate run after every branch in the chain is rebased.

AI assistance note: this commit was produced by an AI coding session
that continued a prior AI session's partially completed edits, reading
both against the run books named above. A human should review the
"James decides" items before the stack is pushed.
…e provider

Second slice of the PR 838 decomposition. Device classification and
geolocation become selectable providers, mirroring the Edge Cookie
provider seam:

- [device] provider selects the classifier. The built-in default reads
  the User-Agent alone and makes no host call; the opt-in fastly
  provider strengthens the browser/bot gate with the host's TLS JA4 and
  HTTP/2 signals (crates/device/fastly).
- [geo] provider selects geolocation. The host platform's lookup is the
  default, matching the behavior before the selector existed, and
  provider = "platform" spells the same choice explicitly
  (crates/geo/fastly wraps the Fastly host lookup behind the
  PlatformGeo trait). provider = "none" opts out entirely, so a client
  IP is never sent to any host geo service. The disabled-by-default
  flip ships with the permission model in the next slice, which adds
  the jurisdiction baseline that makes a no-geo deployment viable.
- Every adapter routes its host geo through the same build_geo_provider
  selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider
  identically, so the selector is not a Fastly-only behavior.
- The provider configuration sections ([device], [geo],
  [ec.providers.hmac], [ec.providers.host-signals]) reject unknown
  keys at startup, so a mistyped key fails loudly instead of silently
  selecting a default.
- The host-signal Edge Cookie provider arrives with the capability it
  needs: the Fastly adapter injects the TLS/HTTP-2 signals as a
  HostSignals service, and the provider mints from them plus the client
  IP. With no host signals at all it defers with a warning rather than
  degrading to an IP-only identifier.
- Device signals move to a field-based DeviceSignals derived in the
  adapter (derive_ua_only for hosts without host signals).
- The new crates join the fastly cargo aliases so they build, lint, and
  test in CI rather than compiling only transitively.
The geo and device selectors are optional, and both serialized as
`"provider": null` when unset rather than being left out.

That is invisible today, because the whole section is skipped while every field
in it is default. It stops being invisible in the permission model PR, which
makes `[geo] default_country` required, so the geo section is always emitted and
the null selector goes with it. A pushed config blob then carries a key that a
binary predating the selector rejects, which breaks an otherwise unchanged
`ts config push` during a rollout or a rollback. That is the same compatibility
rule the surrounding fields already follow.

Both fields now skip serialization when unset. The test serializes each section
directly rather than through Settings, because through Settings the section is
skipped as a whole here and the fault would not show until two PRs later.

Addresses: crates/trusted-server-core/src/settings.rs, optional selectors
serialized as null
The Cloudflare adapter resolves no region on upstream/main today and this
fixes it here. `build_geo` read no region header and `CloudflareGeo`'s
`lookup` returned `region: None` unconditionally, so no Cloudflare
deployment could ever place a visitor in a US state.

I traced the consequence rather than assuming it, and it holds.
`detect_jurisdiction` in `consent/jurisdiction.rs` reaches its US branch
only when the country is `US` and a region is present, so with no region
every US visitor fell through to `Jurisdiction::NonRegulated`. In
`allows_ec_creation`, the `NonRegulated` arm returns true and never reads
`ctx.gpc`, while the `UsState` arm blocks on `ctx.gpc` first and then on
a GPP US sale opt-out and the US Privacy string. A US visitor sending
Sec-GPC therefore had that opt-out ignored on Cloudflare, and an Edge
Cookie was created anyway. The same is true for the GPP and US Privacy
opt-outs, because none of them are consulted on the unregulated arm.

The region now comes from `cf-region-code`, which is the ISO 3166-2
subdivision code, rather than `cf-region`, which is the subdivision name.
The state rules in `[consent.us_states] privacy_states` are written as
two-letter codes, so a name would never match one and the fix would look
applied while changing nothing. This is a request header, read the same
way the adapter already reads `cf-ipcountry` and `cf-ipcity`, and like
those it needs the visitor-location managed transform, which the doc
comment now says.

The new test walks the whole chain in one place, from the header through
`build_geo` and `lookup` to `detect_jurisdiction` and then to
`allows_ec_creation` with Sec-GPC set, and asserts the opt-out is
honored. Putting `region: None` back fails it.

Addresses: crates/trusted-server-adapter-cloudflare/src/platform.rs,
where `CloudflareGeo::lookup` hardcoded no region and `build_geo` read no
region header, so US state privacy signals failed open.
Restoring the evidence accessors added a `with_headers` builder to
`OwnedRequestInfo` and `BorrowedRequestInfo`, and on both types it landed
between `with_request_target`'s doc comment and the function that
comment describes. So `with_request_target` was left undocumented and
without its `#[must_use]`, while `with_headers` carried a stray
attribute and a comment describing its neighbour.

The compiler reported the stray attribute and clippy separately asked
for a `#[must_use]` on `with_request_target`, which is the attribute
that had been severed from it. Both builders now sit with the
documentation and the attribute that belong to them, so a caller that
drops either builder's result is caught again.

This went unnoticed because the checks run against this branch were
`cargo check` and a cached build, neither of which re-reports warnings,
and the branch fails `cargo clippy` with warnings denied. It is fixed
here rather than further up the stack because this is the change that
introduced the builders, so every branch above inherited the fault.

Addresses: crates/trusted-server-core/src/evidence.rs, where both
`with_request_target` builders lost their documentation and their
`#[must_use]`.
Third slice of the PR 838 decomposition. Permissions become the
primitive that gates identity features; consent is one of several ways
a permission is established:

- permissions.rs resolves a per-request PermissionState from the
  country/region baseline in permissions.yaml augmented by the session's
  signals (TCF, GPP, GPC, US Privacy). Permission names follow the
  Privacy Taxonomy Data Uses.
- Signal precedence is fixed in code, most restrictive first: a US-style
  opt-out (GPC, GPP sale opt-out, US Privacy) suppresses the Data Uses
  the policy revokes even when a TCF record consents, a present but
  undecodable record blocks baseline grants (fail-closed), and only then
  does a TCF record decide its mapped Data Uses. The yaml authoritative
  flag governs the TCF record's own grants and revokes, never whether an
  opt-out can be overridden. Pinning tests cover each opt-out source
  against a consenting TCF record.
- Destructive withdrawal is narrow: only a TCF record refusing storage
  in a jurisdiction whose baseline did not grant it expires the cookie
  and writes the identity-graph tombstone. Opt-outs suppress use
  (headers stripped, nothing egressed) but never destroy an issued
  identifier, so lifting an opt-out restores the identity.
- Sharing beyond the edge requires storage plus personalised-ad
  selection, the same pair that gates bidstream EIDs, at every egress:
  the auction endpoint's user.id, the publisher navigation and
  page-bids auction requests, the identify response, and partner
  pull-sync. A storage-only grant keeps first-party use while
  withholding partner sharing.
- The Edge Cookie gate moves from raw consent to the permission model:
  a provider declares required_permissions() and core executes it only
  when every one is set.
- [geo] default_country becomes required: it names the permissions.yaml
  rule that applies when the geo provider leaves a request unmatched. A
  failed geo lookup is distinct: it resolves at the requires-signal
  floor instead of the default, and is logged at error level. The
  lookup moves into EcContext::read_from_request_resolving_geo so every
  adapter reports the distinction identically.
- Geolocation is now off by default ([geo] provider unset resolves no
  location); the host lookup is opt-in via provider = "platform". A
  deployment that runs an Edge Cookie provider with no geo provider
  must set [geo] assume_single_jurisdiction = true, acknowledging that
  every request is treated as the default jurisdiction.
- permissions.yaml rules use an explicit per-permission acquisition map
  (granted / requires_signal / denied) instead of +/- sigils, unknown
  keys in a detailed rule are rejected, and two rule keys naming the
  same location in different case are rejected at parse.
- The consent module keeps building the ConsentContext; its EC-specific
  gating helpers move behind the permission model. An EU-27 plus EEA
  coverage test locks the gdpr-eu mapping.
- The design spec for this slice lives at
  docs/superpowers/specs/2026-07-30-permission-model-design.md, the
  2026-07-31 draft revised to match this implementation with a
  revision-record table of every divergence.
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
@jwrosewell
jwrosewell force-pushed the split/3-permissions branch from 4d591e9 to 35f6ef2 Compare August 31, 2026 12:50
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