Add the permission model with the Privacy Taxonomy vocabulary - #1045
Open
jwrosewell wants to merge 77 commits into
Open
Add the permission model with the Privacy Taxonomy vocabulary#1045jwrosewell wants to merge 77 commits into
jwrosewell wants to merge 77 commits into
Conversation
This was referenced Aug 19, 2026
jwrosewell
force-pushed
the
split/3-permissions
branch
2 times, most recently
from
August 20, 2026 02:22
6d20255 to
760b921
Compare
16 tasks
jwrosewell
force-pushed
the
split/3-permissions
branch
from
August 25, 2026 10:51
760b921 to
b3a0eae
Compare
This was referenced Aug 25, 2026
jwrosewell
force-pushed
the
split/3-permissions
branch
from
August 25, 2026 13:37
b3a0eae to
5a707bf
Compare
jwrosewell
force-pushed
the
split/3-permissions
branch
6 times, most recently
from
August 31, 2026 12:50
4d591e9 to
35f6ef2
Compare
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Aug 31, 2026
The review of IABTechLab#1043 asked that spec changes land before the code that implements them, so a divergence is a decision taken in review rather than a ratification of something already merged. PRs IABTechLab#1043 to IABTechLab#1047 each carried the design document for their own step, and IABTechLab#1043 carried a 607-line spec describing device providers, geo providers, the permission model and the browser resolve endpoint, none of which is in that PR. Move all six series documents here, so this PR carries the complete normative set and no code: - 2026-07-30-pluggable-providers-design.md (from IABTechLab#1043) - provider-code-registry.md (from IABTechLab#1043) - 2026-07-30-permission-model-design.md (from IABTechLab#1045) - 2026-07-30-client-cycle-ec-resolve-design.md (from IABTechLab#1046, later revised by IABTechLab#1047) - 2026-07-30-integration-response-header-hook-design.md (from IABTechLab#1047) - 2026-07-30-provider-migration-rollout-design.md (from IABTechLab#1047) Each file is taken verbatim at the tip of the stack, so the later revisions are preserved: the provider-switching continuity section, the geo requires-signal floor, and the code-envelope paragraph IABTechLab#1047 added to the client-cycle spec. The revision-record tables are unchanged. No document's substance was edited. The only edits are to this spec's own status line, which said the PR adds one document and that the series specs land with IABTechLab#1047, and a revision-record row recording the move.
Collaborator
jwrosewell
force-pushed
the
split/3-permissions
branch
from
September 1, 2026 15:34
35f6ef2 to
1c9bb91
Compare
…ider
First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.
- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
The deprecated [ec] passphrase form still starts for one release
cycle: it maps to provider = "hmac" with a deprecation warning, and a
configuration carrying both forms is rejected. provider = "none"
spells explicit statelessness. A configured block that is not the
selected provider is rejected at startup, as is a block with no
selector.
- Global identifier bounds enforced by core at mint, read-back, and
cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
cap. An identifier outside the bounds is rejected loudly, never
rewritten, so the cookie value and the identity-graph key can never
silently diverge.
- The identity graph is keyed by the provider's canonical form of the
identifier (normalize_id_for_kv), so equivalent representations of
one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
access to the client IP, headers (including cookies), URL path, and
query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
provider, so a vendor provider lives in its own crate and core never
names it. A selected provider the adapter does not inject fails the
request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
proceeding stateless.
Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.
Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.
The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.
Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.
is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
CodeQL's cleartext-logging query treats a call whose name contains "passphrase" as a sensitive source, and because the method mutates the Settings it belongs to, every later log line that prints anything from Settings (store names, timeouts, header names) is reported as writing a secret to a log. The passphrase itself is a Redacted<String> and none of the flagged lines prints it. The method now describes what it does, migrate_legacy_ec_layout, and its behavior is unchanged.
A reviewer raised a P1 against the pluggable Edge Cookie provider work: three of the four adapters broke the provider contract that an unavailable required service or an uninjected provider stops the request. The Axum, Cloudflare and Spin adapters each read the Edge Cookie context with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`, logged a warning and continued with `EcContext::default()`. A deployment whose selected provider could not be built therefore came up and served every request with no identity, silently. The Fastly adapter already kept the report and answered with an error response. `build_ec_context` on the three adapters now returns `Result<EcContext, Report<TrustedServerError>>` and every call site propagates it to that adapter's own `http_error`, the same helper Fastly uses, so all four answer with the same status and shape. The design this implements has the composition root check a selected provider's needs once at startup rather than per request, so `ensure_provider_available` was added to `ec/provider.rs` and is called from `build_state_with_settings` on all four adapters (Fastly included, so the rule is uniform). Building a provider reads no request data, so a selection an adapter can never supply now fails when application state is built, and the three adapters answer every route from their existing `startup_error_router` instead of coming up. Statelessness, meaning no `[ec] provider` selector or the explicit `"none"`, still passes and still serves. The widening question was checked rather than assumed. `read_from_request_with_geo` can only fail from two places: the provider build, and a `Cookie` header that is not valid UTF-8. A malformed cookie value is dropped with a warning by `request_ec_id_if_allowed`, consent parsing returns a value rather than a `Result`, and the geo lookup is already swallowed by the adapter before the call, so no ordinary parse problem reaches the error path and none is turned into a failed request. Tests: each of the three adapters gains a route test proving an uninjected provider fails at startup, and an in-crate test proving `build_ec_context` returns the error rather than a default context. Core gains a test that the startup check rejects an uninjected provider and still allows statelessness both ways. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1)
`Settings::finalize_deserialized` runs derive validation before `Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase` field carries no `#[validate]` attribute of its own, so the advertised 32-byte minimum was only enforced on the new `[ec.providers.hmac]` location. A configuration still on the old form could start with `passphrase = "short"`, or with an empty value, and mint identifiers from keying material the new location rejects. The migration now calls `Ec::validate_passphrase` on the value it is about to move, before it logs the deprecation warning and writes the `[ec.providers.hmac]` block, and reports a configuration error naming the minimum length and the new location. Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives `Settings::from_toml` with the `[ec]` section rewritten to the deprecated form and proves a short value and an empty value are both rejected, and that a passphrase of adequate length still migrates to `provider = "hmac"` with the passphrase in the hmac block. Removing the new check makes that test fail, so it tests the fix rather than the surrounding code. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench)
The provider spec (section 6) says `deny_unknown_fields` is set on both built-in provider config structs, but `HmacProviderConfig` carried no such attribute, so `[ec.providers.hmac] typo_key = "x"` was accepted silently. An operator who mistypes a key gets a deployment that starts and quietly uses the default for the setting they meant to change. `HmacProviderConfig` now sets `#[serde(deny_unknown_fields)]`, matching `Ec` itself and the rest of the settings tree. The struct is a plain field of `EcProviders` rather than a flattened one, so the attribute does not collide with the `#[serde(flatten)]` vendor map alongside it. Tests: `an_unknown_key_in_the_hmac_provider_block_is_rejected` adds an unknown key to the block in the crate test configuration and proves `Settings::from_toml` fails and names the key. Removing the attribute makes that test fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:726 (wrench)
`build_provider`'s `"hmac"` arm mapped over `ec.providers.hmac`, so a deployment that selected `provider = "hmac"` with no `[ec.providers.hmac]` block got `Ok(None)` and ran stateless under a selector that says it has an identity provider. Every other unbuildable selection in the same match already errors. The arm now returns `TrustedServerError::EdgeCookie` naming the missing block, which the startup check `ensure_provider_available` turns into a failed application state on every adapter. `Ec::validate_provider_selection` rejects that pair before settings reach the composition root, so nothing routes through the new arm today. It is the drift guard for the case where the two checks stop agreeing, which is exactly the shape of the defect being fixed, so it is worth keeping rather than leaving the silent branch in place. Tests: `selecting_hmac_without_its_block_fails_loudly` builds the `Ec` programmatically, bypassing settings validation to reach the seam, and proves the error names the missing block. The doc comment's `# Errors` section is corrected in the same commit, since it still claimed no built-in construction can fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:304 (refactor)
The error raised when a provider mints an identifier outside the identifier bounds was written across two source lines without the trailing backslash that joins them, so the 22 spaces of source indentation became part of the literal and the logged message read "...bytes, or outside the cookie-safe alphabet". The continuation is restored, so the message reads as one sentence. The whole of ec/mod.rs was scanned for the same fault, matching every string literal and stripping real continuations before looking for runs of more than one space or a newline inside a literal. This message was the only one. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:444 (nitpick)
The 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
force-pushed
the
split/3-permissions
branch
from
September 2, 2026 14:31
6072513 to
e43095e
Compare
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.
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.
Contributor
Author
|
To make the permissions-file discussion concrete, #1045 now carries the policy made real rather than argued in the abstract:
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third of five stacked PRs decomposing #838 as requested in the #986 review. Stacks on #1044. Compare
split/3-permissionstosplit/2-device-geoto 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.rsresolves a per-request permission state from the country and region baseline inpermissions.yaml, augmented by the session's signals (TCF, GPP, GPC, US Privacy). Permission names follow the IAB Privacy Taxonomy Data Uses.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.required_permissions()and core executes it only when every one is set.[geo] default_countrybecomes required and is validated againstpermissions.yamlat 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.yamlrules 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 thegdpr-eumapping.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.yamland 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.