Skip to content

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

Draft
jwrosewell wants to merge 83 commits into
feature/2-device-geofrom
feature/3-permissions
Draft

jwrosewell wants to merge 83 commits into
feature/2-device-geofrom
feature/3-permissions

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 35 files, 6846 lines added, 1100 removed
tools/permissions-inspector 8 files, 3700 lines added, 0 removed
docs/guide 3 files, 797 lines added, 10 removed
crates/permission-signal/tcf 3 files, 678 lines added, 0 removed
crates/trusted-server-adapter-axum/tests 2 files, 678 lines added, 0 removed
crates/permission-signal/mtm 2 files, 611 lines added, 0 removed
config/permissions 1 file, 551 lines added, 0 removed
crates/permission-signal/us-privacy 2 files, 295 lines added, 0 removed
crates/trusted-server-js/lib 4 files, 295 lines added, 0 removed
crates/permission-signal/gpp 2 files, 293 lines added, 0 removed
crates/permission-signal/gpc 2 files, 221 lines added, 0 removed
crates/trusted-server-adapter-fastly/src 4 files, 111 lines added, 28 removed
AGENTS.md 1 file, 106 lines added, 9 removed
crates/trusted-server-adapter-cloudflare/src 3 files, 81 lines added, 26 removed
crates/trusted-server-adapter-spin/src 3 files, 70 lines added, 18 removed
crates/trusted-server-adapter-axum/src 3 files, 65 lines added, 15 removed
Cargo.lock 1 file, 57 lines added, 0 removed
crates/trusted-server-integration-tests/fixtures 2 files, 49 lines added, 2 removed
scripts/template-cache-local-test.sh 1 file, 38 lines added, 0 removed
crates/trusted-server-integration-tests/tests 2 files, 37 lines added, 22 removed
scripts/build-inspector-wasm.sh 1 file, 29 lines added, 0 removed
.github/workflows 1 file, 26 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/tests 1 file, 11 lines added, 0 removed
Cargo.toml 1 file, 10 lines added, 0 removed
crates/trusted-server-adapter-spin/tests 1 file, 8 lines added, 0 removed
crates/trusted-server-adapter-axum/Cargo.toml 1 file, 5 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/Cargo.toml 1 file, 5 lines added, 0 removed
crates/trusted-server-adapter-fastly/Cargo.toml 1 file, 5 lines added, 0 removed
crates/trusted-server-adapter-spin/Cargo.toml 1 file, 5 lines added, 0 removed
.cargo/config.toml 1 file, 4 lines added, 2 removed
docs/.vitepress 1 file, 2 lines added, 0 removed
crates/trusted-server-core/Cargo.toml 1 file, 1 line added, 0 removed
crates/trusted-server-core/benches 1 file, 1 line added, 0 removed
trusted-server.example.toml 1 file, 0 lines added, 0 removed

Closes

Closes #779

Test plan

CI on this pull request at eed2879 passed all 17 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at eed2879

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on device and location module selection (#1344), and depends on its
[geo] module selector, whose default this pull request changes to no
geolocation. Through #1344 this pull request also depends on the Edge Cookie
module seam (#1343) for the EdgeCookieModule trait, which it gates on
permissions. It is the third of the five pull requests (#1343 to #1347) that decompose
the combined module change (#838), as the review of the earlier design specs
(#986) asked.

The design specs for the series are carried by the spec pull request (#1342).
The spec for this pull request is
2026-07-30-permission-model-design.md,
revised to match this implementation, with revision records listing every
divergence and why, and section 13 covering the permission signal modules.
Reader documentation lands with this pull request at
docs/guide/permission-model.md
and
docs/guide/permission-signals.md.

What this pull request does

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

  • permissions.rs
    resolves a per-request permission state, being the country and region
    baseline from permissions.yaml amended by the signals the request carries.
    permissions.yaml is compiled into the build, and the repository sample is
    config/permissions/sample.yaml.
    Permission names follow the IAB Privacy Taxonomy Data Uses.

How the signal modules are selected

Signals are read by permission signal modules, which are crates outside core
under
crates/permission-signal/,
being Global Privacy Control, the GPP sale opt-out, the US Privacy string, TCF
v2 and the Model Terms for Marketing, each implementing the
PermissionSignalModule trait that core defines in
permission_signal/mod.rs.

[permission-signal]
modules = ["gpc", "gpp", "us-privacy", "tcf", "mtm"]

An adapter selects them once at startup from [permission-signal] modules,
refusing an unknown or repeated name there rather than on the first request,
and carries them on the request services. An unknown name is reported with the
names the build does have, so a typo is answered with the list to pick from.

Precedence is the configured order. For each permission, core asks the
modules in the order [permission-signal] modules lists them. Each answers
grant, revoke or neutral, a neutral answer leaves the prior value standing, and
the last module with an opinion decides. Leaving the list out runs every
module the adapter links, in the adapter's order, and an empty list runs
none, which leaves every permission at its country and region baseline. Every
adapter offers the five in the same default order, being gpc, gpp,
us-privacy, tcf and mtm. Global Privacy Control, a
browser setting with no interface of its own, is asked first, and the schemes
that carry a choice someone made through an interface are asked after it, which
means an answer given at a prompt amends the header the visitor arrived with. A
deployment that wants an opt-out to stand lists it after tcf.

What the state carries

The page receives the state as window.tsjs.permissions, with four lists:
set, the permissions that are set; awaiting, the permissions whose
baseline requires a signal and for which no configured module has answered,
narrowed to what some configured module could still grant, so a page can hold
what depends on them rather than read "nobody has answered" as "refused";
signals, one entry for each signal a module read and found valid, as it was
received, with the module and the scheme, so page code and bid requests rely
on exactly those signals and no other; and tdls, the terms documents the
data is available under. An empty state renders every list empty, and each
is an answer rather than a missing value.

The rest of the model

  • Each module owns its own unreadable signal. What an absent, unreadable or
    expired record means for the permissions is the decision of the module for
    that scheme, taken in its own answer. The shipped modules read their own
    unreadable record as the refusal it may have carried, on the permissions
    their scheme covers, and say nothing about any other scheme, so the
    configured order decides a readable record from one scheme beside an
    unreadable one from another. After assembly the consent context keeps only
    the raw strings the valid signals name, so a corrupt record never reaches a
    bid request.
  • Leaving gpc off the list stops that module, but the consent pipeline
    still turns the same header into a US Privacy opt-out for a US privacy state
    when the consent setting gpc_implies_optout is on, which it is by default,
    and the us-privacy module then acts on that record. A deployment that
    wants the header to have no effect turns that setting off as well.
  • Cross-scheme tests in
    crates/trusted-server-adapter-axum/tests/permission_signals.rs
    assemble the real modules and cover the default order against each opt-out,
    the reversed order, a module left off the list, one opt-out removed while
    the others stand, and the Model Terms words.
  • The TCF purpose to Data Use mapping lives in the TCF module crate
    (crates/permission-signal/tcf/src/mapping.rs),
    in code with tests, because which purpose grants which Data Use is that
    scheme's own meaning rather than a deployment's policy. permissions.yaml
    keeps what a deployment decides, being which opt-out sources revoke which
    Data Uses and whether a TCF record answers at all. The purposes are the IAB
    TCF Europe purposes and what they grant are IAB Tech Lab Privacy Taxonomy
    Data Uses, so the mapping is checked against the industry's own documents
    rather than against us. Two purposes have no Data Use yet, so the crate
    carries the proposed necessary.operations.storage key for purpose 1 and the
    TCF identifier select-basic-content for purpose 11 until the taxonomy adds
    them.
  • The permission state also carries tdls, the terms documents the request's
    data is available under, declared by the modules in the order they are
    asked with duplicates removed. A permission says what may be done with the
    data and not on what basis, and a recipient offered data needs both, because
    it has to decide whether the terms are ones it accepts and whether it may
    pass the data on. Each entry addresses a published document that must never
    be edited once published, which is why a version belongs in its address, and
    the name matches the tdl member the
    Data Labels work
    puts on an OpenRTB node. Of the five schemes only the Model Terms for
    Marketing declares terms, the versioned Model Terms document, whenever an
    answer is present.
  • The Model Terms for Marketing module reads one of the words standard,
    personalized and non-marketing from __mtm_pref. Both marketing words
    grant storage, contextual advertising, measurement, content performance,
    market research, product improvement and basic content selection, and only
    personalized grants the four Data Uses that depend on browsing history or
    interactions, which standard refuses because the visitor chose against
    them. non-marketing changes nothing, so the country and region rules
    decide. No word withdraws. The five schemes are a starting set rather than
    the list, and
    docs/guide/permission-signals.md
    says how a scheme is added as a crate with no change to core.
  • Destructive withdrawal is narrow. A module answers withdrawal separately
    through withdraws, core scopes the answer to the storage baseline, and of
    the five only TCF answers it. Only a TCF record refusing storage in a
    jurisdiction whose baseline did not grant storage therefore expires the
    cookie, the pull sync marker and writes the identity-graph tombstone.
    Opt-outs suppress use, with Edge Cookie headers stripped and nothing sent
    beyond the edge, but never destroy an issued identifier, so lifting the
    opt-out restores the identity. This differs from main, where
    has_explicit_ec_withdrawal in
    consent/mod.rs
    also withdraws in a US privacy state for a Global Privacy Control signal, a
    GPP sale opt-out or a US Privacy sale opt-out. Under this pull request those
    opt-outs suppress use and keep the cookie, and the withdrawal tests main
    wrote around a California Global Privacy Control opt-out state the
    withdrawal on the permission state.
  • Sharing beyond the edge, being the bidstream user.id, the identify response
    and partner pull sync, requires both storage and personalized-ad selection
    (StoreOnDevice and SelectPersonalisedAds in ec_sharing_allowed), 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
    module declares required_permissions() and core runs it only when every
    one is set.
  • The permission baseline and the jurisdiction to assume when no country
    resolves are declared together on the top node of the rules tree in
    permissions.yaml, so there is always a defined baseline and one file states
    the policy for both. A failed geo lookup is distinct from an unmatched
    country. It resolves at the requires-signal floor instead of the declared
    default and is logged at error level, and the one-time warning for a lookup
    that resolves no location says that the declared jurisdiction applies.
    Geolocation is off by default, and a deployment that runs an Edge Cookie
    module with no location module must set
    [geo] assume_single_jurisdiction = true, acknowledging that every request
    is treated as the declared jurisdiction.
  • permissions.yaml rules use an explicit per-permission acquisition map
    (granted, requires_signal, denied). Parsing rejects an unknown group,
    Data Use, acquisition or revoke keyword, a top node missing group or
    jurisdiction, and two place codes that differ only by case, each case in
    one table of refusals. An EU-27 plus EEA coverage test locks the gdpr-eu
    mapping.

Changed from an earlier description

An earlier version of this text described precedence as a rule fixed in code,
where an opt-out always beat a consenting TCF record. Precedence is the
configured order described above. Under the default order, a visitor who sends
Sec-GPC: 1 and then consents through a consent management platform has the
Data Uses that the TCF record consents to set, which is the opposite of the old
outcome, and a deployment gets the old outcome back by listing the opt-out
after tcf. An earlier version also said an unreadable consent record revoked
everything ahead of the modules. It no longer does, for the reason given
above.

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 #777 and #779. Decomposes #838. Spec baseline from #986.

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
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.
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.
jwrosewell and others added 7 commits October 7, 2026 07:15
Each of the five modules takes its name from its crate folder through
`module_name!()`, so `crates/permission-signal/gpp` is `gpp` and
`crates/permission-signal/us-privacy` is `us-privacy`, where the two had
called themselves `gpp_sale_opt_out` and `us_privacy` by hand. The page is
told a signal's source by the short name, without the type folder.

The section is `[permission-signal]`, named exactly as its folder, and its
key is `modules`, a list, because signals compose where `[ec]`, `[geo]` and
`[device]` each name one module. A name in the list may be written in full,
as `permission-signal.gpp`, or with the type folder left off, and the
refusal of a name this build does not offer lists the short names. A
configuration still carrying `[permission_signal]`, a `module` key or the
retired `sources` key is refused with directions to the new spelling.
- The integration fixture carried two notes on its geo selector that
  contradicted each other. The one that stays says that Viceroy maps the
  loopback client to US/CA, so the platform geo module resolves a real place.
- That fixture, the Viceroy template, the template cache harness and the
  permission model guide no longer mention a default country setting, which
  no released version had.
- Five code comments say what the code does without saying what it did
  before.

No code changes.
main's new EID persistence gate is the permission model's sharing check. EIDs are written to the identity graph only when the storage and personalised-ad permissions are both set, the pair that already gates the bid request, so allows_eid_persistence and its parity test are removed and the tests that described Purpose 4 denial state the permissions instead.
Comment thread tools/permissions-inspector/index.html Fixed
Comment thread tools/permissions-inspector/index.html Fixed
@jwrosewell
jwrosewell removed this pull request from stack #1401 October 15, 2026 20:51
The headline named the visitor's place by joining the country code from
the loaded policy file into an HTML string, so a code such as "<b>"
became markup. The parser limits a place code to three characters,
which is too short to run script but not to change the page. The
headline is now built from a span and a text node, so every policy
renders as before and a code is always shown as written.
The page loaded js-yaml 4.1.0 from cdnjs with no integrity attribute, so
it would run whatever the CDN served. The tag now carries the SHA-512
hash of that release's file, which matches the hash cdnjs publishes for
it, and requests it with CORS so the browser can check the hash.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15

This branch has not been deployed

No deployments
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