Skip to content

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

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

Add the permission model with the Privacy Taxonomy vocabulary#1045
jwrosewell wants to merge 77 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.

@jwrosewell
jwrosewell force-pushed the split/3-permissions branch 2 times, most recently from 6d20255 to 760b921 Compare August 20, 2026 02:22
@jwrosewell
jwrosewell force-pushed the split/3-permissions branch from 760b921 to b3a0eae Compare August 25, 2026 10:51
@jwrosewell
jwrosewell force-pushed the split/3-permissions branch from b3a0eae to 5a707bf Compare August 25, 2026 13:37
@jwrosewell
jwrosewell force-pushed the split/3-permissions branch 6 times, most recently 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.

…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 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
The edge injects the request's resolved permission state as
window.tsjs.permissions, either before the bundle runs (inline mode, at head
open) or after it has initialized (shared-template mode, as a plain assignment
at the </body> seam), and on a page with no seam it never arrives at all.

Core now installs a permissions accessor on the API object so the seam's plain
assignment is observed, keeps a head-open value as the current one, and
otherwise defaults to an empty set so page code never reads undefined. A new
whenPermissions() returns one shared promise that resolves on the first
assignment, immediately when a value was already present, or on
DOMContentLoaded with the current value when nothing arrives.

Verified with npx vitest run (895 passing, the two gpt/ad_init failures are a
pre-existing Windows path bug reproduced on an untouched worktree), prettier on
the changed files, node build-all.mjs, and cargo test-fastly -p
trusted-server-core -- tsjs (43 passing).
Permissions are resolved once at the start of every request, when the EC
context is built, but integration request filters could not see them.
The Fastly adapter builds that context before it runs the pre-route
filters, so the state is already available at the point the filters run.

RequestFilterInput and RequestFilterRegistryInput now carry an optional
reference to the resolved PermissionState, threaded through
IntegrationRegistry::filter_request the same way geo already is. The
Fastly adapter passes the state from the context it just built, and the
remaining construction sites, all in tests, pass None. None means a path
that builds no EC context, such as batch sync and admin diagnostics, so
a filter can tell an absent state apart from an empty one.

No filter reads the new field yet, so behavior is unchanged. A new
registry test drives a recording filter and asserts it sees exactly the
state handed in, and that None arrives as None.
Page code, Trusted Server's own bundle and vendor modules alike, had no
way to learn which permissions were set for a request, so anything that
needed the answer had to guess it from a CMP. The state is already
resolved at the start of every request into `EcContext`, so this carries
it to the page as `window.tsjs.permissions`, in the shape
`{"set":["<data use name>", ...]}` with the names sorted so the same
state always serializes to the same bytes.

`PermissionState::page_json` is the only place that shape is spelled, so
no delivery point writes the JSON by hand and an unfilled value renders
as the empty state rather than as `JSON.parse("")`.

Where the state is delivered depends on the assembly mode, for the same
reason `adSlots` does. Under `AssemblyMode::Inline` the response is
per-navigation, so a head script goes in at `<head>` open, ahead of the
slots and the tsjs bundle, on every HTML document the processor handles
and not only when the ad stack runs. Under `AssemblyMode::Esi` the
processed document is a shared template cached and served to many
readers, so the head carries nothing request-scoped and the state rides
the per-request `</body>` seam instead. A reader whose ad stack did not
run (a bot, a prefetch, or unset permissions) used to receive an empty
seam and now receives a permissions-only one, which sets the state and
deliberately does not set `adSlots` or `bids` or call
`scheduleInitialAdInit`, because scheduling ad init for traffic that
opted out is what that gate exists to prevent.

The seam's `var a=JSON.parse`, `var b=JSON.parse` and `s(b,a)` literals
are unchanged, so the probes in `scripts/template-cache-local-test.sh`
keep matching and that harness needs no update.

Tests cover the page shape and its ordering, the inline head injection
and its position ahead of the slots and the bundle, an inline document
with no ad stack, the seam carrying the state alongside the probed
literals, and the no-ad-stack seam. The shared-template neutrality test
gains `permissions` to its forbidden-marker list, which was checked by
making the head script leak under ESI and watching that test fail.

Verified with `cargo fmt --all -- --check`, `cargo check-fastly` and
`cargo test-fastly -p trusted-server-core`, all under
`RUSTFLAGS="-D warnings"`. 2,377 core tests pass, plus the adapter,
openrtb and doc tests.

Trusted Server encodes no policy in any of this. The state it carries
to the page is the decision the deployer's own configuration and the
visitor's signals produced for the request, so page code can respect
that decision whatever the deployer's policy is, rather than have the
software decide on the deployer's behalf.
The rules section of permissions.yaml becomes one tree. Every node carries a
group and children are optional, country codes sit at the first level and
region codes beneath them, and a node written as a single string is shorthand
for that group with no children. The top node carries the group and the
jurisdiction that apply when no country can be resolved, so the unknown case is
stated in the file instead of being borrowed from a chosen country through
[geo] default_country, which this change retires.

The guide now describes the node rule, the shorthand, the required top group
and jurisdiction with the values taken from the Jurisdiction type in
crates/trusted-server-core/src/consent/jurisdiction.rs, the region then country
then top fallback chain, case-insensitive matching, and the canonical example
with a worked reading of it. The request resolution section, its diagram, and
the configuration section drop the default country and show the tree instead. A
failed geo lookup still keeps the requires-signal floor, which is a different
state from having no location.

The audience for this file is a policy owner rather than a developer, so the
nesting is there to show precedence on the page rather than hide it inside
slash-joined keys. Trusted Server encodes no jurisdiction's law, as the
deployer states the policy and the software only carries it out.
Jurisdiction::from_policy_name in
crates/trusted-server-core/src/consent/jurisdiction.rs reads a US state as
us-state/<CODE>, for example us-state/CA, rather than the shorter form the
guide first listed. The table now names the four accepted values as the parser
spells them, says that anything else is a configuration error rather than a
silent default, and says the state code may be written in either case.

Trusted Server encodes no jurisdiction's law, as the deployer states the policy
and the software only carries it out.
Any node of the rules tree may carry jurisdiction beside group, and a node that
carries none inherits the nearest ancestor that does. The top node must carry
both, so the inheritance always ends somewhere and the unresolved-country case
is simply the top node. Group and jurisdiction fall back the same way, region
then country then top, and the two answers need not come from the same node.

The value us-state carries no state code, because the region node it sits on
already names the state, which is why it is rejected at the top of the tree and
on a country node. The values table now lists gdpr, us-state, non-regulated and
unknown with where each may be written, replacing the us-state/<CODE> form.

The guide also records that [consent.gdpr] applies_in and [consent.us_states]
privacy_states retire into the tree, where the shipped file carries the same 31
GDPR countries inheriting gdpr from the top node and the same 20 states with a
comprehensive privacy law as region children of US carrying jurisdiction:
us-state. One file now holds the baselines and the regime applicability
together, so a policy owner keeps one list rather than two in step.

Trusted Server encodes no jurisdiction's law, as the deployer states the policy
and the software only carries it out.
A geo provider that reports an outright lookup failure resolves every
permission to the requires-signal floor and its jurisdiction to unknown, rather
than reaching the tree at all, which permissions.yaml now states and the guide
was missing. The place-code paragraph also records that where a code sits tells
two identical codes apart, since DE at the first level is Germany and DE under
US is Delaware.

Trusted Server encodes no jurisdiction's law, as the deployer states the policy
and the software only carries it out.
The [consent.gdpr] applies_in and [consent.us_states] privacy_states settings
are retired, so the illustrative TOML block drops both and the page no longer
says the publisher configures which countries and US states map to each
jurisdiction's rules. A jurisdiction attribute on the permissions.yaml rules
tree carries that now, where every node may name the jurisdiction for the
places it covers, a node naming none inherits the nearest one above it, and the
top of the tree answers a visitor whose country cannot be resolved.

The page states that in one paragraph and links to the permission model guide
rather than repeating the tree documentation, and the guide is added to Next
Steps. Everything else on the page, being the signal formats, the storage
notes, the user rights and the best practices, is untouched.

Trusted Server encodes no jurisdiction's law, as the deployer states the policy
and the software only carries it out.
`permissions.yaml` held a flat `rules:` map keyed on `FR` or `US/CA`,
while the answer for an unresolved place lived outside the file in the
`[geo] default_country` setting, and the GDPR country list and the US
privacy state list lived in the consent configuration. Three places
stated one policy, so a reader had to hold all three to know what a
visitor gets.

`rules:` is now a single tree. The top node stands for the whole world,
countries sit under it keyed by ISO 3166-1 alpha-2 code, and a
country's regions sit under it keyed by ISO 3166-2 code with no country
prefix. A node written as a plain string is the shorthand for a group
with nothing below it. Any node may name a `group` and a
`jurisdiction`, and a node that names neither inherits from the node
above, so the top node's required `group` and `jurisdiction` terminate
the inheritance and are also the answer for a visitor whose place
cannot be resolved. Matching stays most specific first and case
insensitive. The reserved words `group`, `jurisdiction` and
`permissions` cannot collide with a place code, because ISO codes are
at most three letters.

`jurisdiction` accepts `gdpr`, `us-state`, `non-regulated` and
`unknown`, one for each state `Jurisdiction` can represent, so a policy
owner cannot write something the consent code has no way of applying.
`us-state` carries no state code because the region naming it is the
state, and it is rejected on a country or at the top of the tree where
there is no region to name. The vocabulary is documented on
`Jurisdiction::from_policy_name`, next to the type it maps onto.

`detect_jurisdiction` now walks that same tree, so
`consent.gdpr.applies_in` and `consent.us_states.privacy_states` retire
along with `GdprConfig` and `UsStatesConfig`. The shipped file
reproduces both compiled defaults, because the 31 GDPR countries (27
EU, 3 EEA, the UK) inherit `gdpr` from the top node and stay one-line
shorthands, and the 20 US privacy states are region children of `US`
carrying `jurisdiction: us-state`, each with the same `us-opt-out`
group as the country node so no permission changed. A new test asserts
both lists verbatim. `US` and `AU` carry `non-regulated`, matching what
the retired lists gave them. One behavior does change, in that a
located country with no rule of its own now inherits the top node's
`gdpr` rather than resolving `NonRegulated`, which is the same
treatment its permission baseline already received and is the stricter
of the two.

The file header teaches the format to a policy owner, covering the
tree, the shorthand, the required top node, the inheritance rule, the
accepted jurisdiction values, the case rules, and why a place code can
never be a reserved word. The worked example in the header is proven by
a test rather than asserted.

Trusted Server states no jurisdiction's law. The deployer states the
policy and this file is where they write it, and the software only
encodes what they wrote.

Retire the [geo] default_country setting

The permission baseline for a request the geo provider leaves
unmatched, and the jurisdiction its consent gates apply, both now come
from the top of the `permissions.yaml` rules tree, so the deployer
setting that used to supply them has nothing left to say. `GeoConfig`
loses the field, `validate_default_country` is replaced by
`validate_permission_policy`, which parses the compiled policy at
startup so a top node missing `group` or `jurisdiction` is a
configuration error rather than a panic on the first lookup, and the
startup log now names the policy's top node and its jurisdiction
instead of a configured country.

`assemble_permissions` and `storage_acquisition` no longer take
`Settings`, and `PermissionMaps::resolve_with`, `baseline` and
`resolve` lose their default country and region arguments. A geo lookup
failure reaches the requires-signal floor through the new
`PermissionMaps::floor_with` rather than by passing no default, so its
behavior is unchanged and it still never consults the tree.

`ConsentPipelineInput` gains `default_jurisdiction`, the value to apply
when no location resolved. It replaces the synthetic `GeoInfo` the EC
context used to build from `default_country` to stop the consent gates
failing closed where a jurisdiction had been declared. A failed lookup
passes `Unknown`, so it still fails closed.

Every fixture and test that set `default_country` is rewritten rather
than dropped. Tests that used `default_country = "US/CA"` to obtain a
granted baseline now place the request in that state through the geo
seam, which is a truer test because it exercises the lookup path. The
adapters, the integration fixture and `trusted-server.example.toml`
lose the key, and their comments now point at the policy file. Two
consequences are worth flagging for review. The Docker integration
fixture used `US/CA` so its loopback requests, which Viceroy does not
geolocate, got a granted baseline, and those requests now resolve at
the shipped top node (`gdpr-eu`), where storage requires a signal. The
same applies to `scripts/template-cache-local-test.sh`, whose harness
can no longer switch jurisdiction through configuration.

Trusted Server states no jurisdiction's law. Moving the default into
the policy file puts the deployer's whole statement in one reviewable
place, and the software only encodes what they wrote.
…tion

The EC lifecycle scenarios relied on the retired [geo] default_country to give the loopback client a US/CA baseline. Viceroy supports a geolocation mapping, so the template now maps the loopback client to California, United States, and the fixture selects the platform geo provider. The scenarios keep exercising the US state opt-out machinery through the genuine lookup path rather than through a setting that pretended a place. Verified by running test_ec_lifecycle_fastly against the release wasm with the generated config, 1 passed.
The harness relied on the retired [geo] default_country to run the auction without a consent signal, so CI's template-cache assertions failed once the retirement landed: the auction failed closed under the rules tree's GDPR top node and the served document carried no seam payload. The harness now appends a Viceroy geolocation block mapping the loopback client to US/CA and uncomments the platform geo selector in its generated config, so the auction runs under the US state opt-out rules through the genuine lookup path. Verified locally in both modes: esi 20 of 21 assertions pass and inline 7 of 8, with the single failure being the socket timing probe returning no numeric timings under Git Bash on Windows, an environment artifact CI's Linux runner does not share, and every assertion that failed in CI passing.
The docs branch's [geo] comment block contains a prose sentence that begins with the same characters as the commented selector, so the unanchored replace stripped the comment marker from prose and produced invalid TOML on the branches that carry it. The replace now matches the exact selector line between newlines.
The previous anchoring edit was corrupted in transit and left a Python string literal broken across lines, so the harness's config generation failed with a syntax error in CI. The replacement is now written with explicit newline characters that no quoting layer can mangle, and it was verified by executing the harness's own embedded generator against both the permissions branch's example config and the stack tip's, with the output parsing as valid TOML, the selector active, and the prose comment untouched.
The shipped policy was conservative to the point of looking wrong: with
a fully consenting TCF record in an EU country only 11 of 64 Data Uses
could ever be set, because every Data Use without a TCF mapping was
denied. The policy now models common practice so a reader evaluates the
mechanism rather than the data.

Service operations, security, fraud prevention and legal obligations
are granted in every group, matching TCF special purposes 1 and 2 where
they touch delivery. The modeled ad-tech Data Uses each map to a TCF
purpose, and a purpose may now grant a list of Data Uses (frequency
capping and negative targeting under purpose 2, delivery and viewability
reporting under purpose 7, campaign insights under purpose 9, and the
personalization Data Uses under purpose 6), which required letting
signals.tcf purposes carry lists, with a startup error when a Data Use
appears under more than one purpose. Umbrella Data Uses stay denied so
every grant is named at the specific level. The US group grants the
ad-tech set and the sale and sharing disclosures until an opt-out, and
the opt-out revoke list is narrowed from all to the US state scope,
being sale, sharing, targeted advertising, and device storage so an
opted-out visitor still gets no Edge Cookie and no identifier sharing,
which the consent precedence tests pin. The file header example also
now uses the shipped group names (us-notice was never defined).

Full consent in an EU country now sets 42 of 64 Data Uses, a US state
visitor gets 45 by default and 38 after a Global Privacy Control
opt-out. All 2400 core tests pass.
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Sep 2, 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.
The file header called itself a shipped demo policy, which reads as
Trusted Server shipping a policy position. It never does. The builder
of a deployment chooses the permissions.yaml compiled into their image,
an operator overlay is the recorded follow-on, and the visitor's
signals decide the rest at runtime. This copy is the repository's test
policy, and the header now says exactly that.
No policy ever ships with Trusted Server, and the repository's
permissions files are samples, not fixtures owned by any one consumer.
They now live in config/permissions, each carrying a display name at
the top (the repository sample is config/permissions/vanilla.yaml,
named Vanilla), so a tool listing the directory can present them
readably, and whoever builds, configures, or inspects makes an explicit
choice of file. The compiled-in default follows the move, and concrete
path references in the example TOML and guides are updated. All 2400
core tests pass.
A single page at tools/permissions-inspector answers the question a
policy owner has about a permissions file: for a visitor in a given
place, with given consent signals, which Data Uses are set? The page
walks three steps, the policy baseline, the visitor's input (location,
TCF consent, and Global Privacy Control on their own tabs), and what
the application layer gets, with a plain-language reason per Data Use
and balloon help quoting each Data Use's definition from the IAB Tech
Lab Privacy Taxonomy.

Step 3 is computed by trusted-server-core itself, compiled to
WebAssembly by the wrapper crate in tools/permissions-inspector/wasm
and built by scripts/build-inspector-wasm.sh, which a new workflow runs
in CI. The wrapper exposes the production path: raw TC, GPP and US
Privacy strings and GPC go through build_context_from_signals, the
policy resolves through assemble_permissions, and pasted files are
validated by PermissionMaps::from_yaml with the server's own error
messages. The page states the trusted-server version, branch, commit
and date the engine was built from, with source links, and reports how
many Data Uses the page's explanatory JS mirror agrees on so drift is
visible.

The page lists the repository's sample permissions from
config/permissions through a manifest the build script writes, labeled
by each file's name line, and says plainly that no policy ever ships
with Trusted Server. Hosting the page through GitHub Pages is noted in
the README as the follow-on.
Comment thread .github/workflows/inspector.yml Fixed
CI failed with permission denied because the executable bit was never
recorded in git, the usual Windows-authored-script gap.
The code-scanning bot flagged the new workflow for carrying the default
GITHUB_TOKEN permissions. The job only checks out and builds, so it
reads contents and nothing else.
The doc comment above the include_str still said the file lives at the
repository root after the move to config/permissions/vanilla.yaml.
@jwrosewell

Copy link
Copy Markdown
Contributor Author

To make the permissions-file discussion concrete, #1045 now carries the policy made real rather than argued in the abstract:

  • No policy ever ships with Trusted Server, and the file now says so in its own header. The builder of a deployment chooses the permissions file compiled into their image, and the operator can overlay their own at configuration time.
  • The repository's samples live in config/permissions/, each naming itself with a top-level name: line (the plain baseline is Vanilla), and choosing one is always an explicit choice.
  • A permissions inspector at tools/permissions-inspector/ lets anyone, policy people included, load a sample or paste their own file and see exactly which Data Uses are set for a visitor in a given place with given consent signals, with the reason per Data Use and each definition quoted from the IAB Tech Lab Privacy Taxonomy. Step 3 is computed by trusted-server-core itself compiled to WebAssembly, and the page states the version, branch, commit and date of the engine it runs, so the page and the code cannot drift apart.

That is the case for a standalone YAML document in the taxonomy's own vocabulary, separate from the code and from any one program's configuration surface: a compliance owner can read it, a tool can inspect it, and the same file works in a browser page and in the compiled server. A temporary hosting of the inspector is at https://jwrosewell.github.io/trusted-server/tools/permissions-inspector/ until the repository hosts its own.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants