Skip to content

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

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

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

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

This description has been edited. Permission signals are now selected
the way every other provider type in the stack is selected, so the key and
two of the provider names have changed. If you read an earlier version of
this text, re-read "How the signal providers are selected". Nothing about
precedence, withdrawal, the terms document list or the permission model
itself has moved.

What changed in the pull request since the last version of this description:

  1. The order and the set of signal providers are given by
    [permission_signal] provider, not [permission_signal] sources. It is
    the same key name every provider type uses.
  2. Two provider names are snake_case, so gpp-sale-opt-out is
    gpp_sale_opt_out and us-privacy is us_privacy. gpc and tcf are
    unchanged. The crate directories on disk keep their own spelling.
  3. A provider with settings would put them in
    [permission_signal.<name>]. None of the four shipped here has any, so
    none of them needs a table.

The convention itself, what it is checked against and how to move an existing
file are written up in
docs/guide/configuration-rules.md,
which lands with the integration seam (#1094).

Stacks on device and location provider selection (#1044), and depends on its
[geo] provider selector, whose default this pull request changes to no
geolocation. Through #1044 this pull request also depends on the Edge Cookie
provider seam (#1043) for the EdgeCookieProvider trait, which it gates on
permissions. The stack has six pull requests
(#1043, #1044, #1045, #1046, #1047, #1094), each targeting main, with this one
third, and the first five decompose #838 as requested in the #986 review.
Compare split/2-device-geo with split/3-permissions
to see only this pull request's change.

The design specs for the series are carried by the spec pull request (#1084).
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 providers.
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/vanilla.yaml.
    Permission names follow the IAB Privacy Taxonomy Data Uses.

How the signal providers are selected

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

[permission_signal]
provider = ["gpc", "gpp_sale_opt_out", "us_privacy", "tcf"]

An adapter selects them once at startup from [permission_signal] provider,
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
providers in the order [permission_signal] provider lists them. Each answers
grant, revoke or neutral, a neutral answer leaves the prior value standing, and
the last provider with an opinion decides. Leaving the list out runs every
provider 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 four in the same default order, being gpc,
gpp_sale_opt_out, us_privacy and tcf. Global Privacy Control, a browser
setting with no interface of its own, is asked first, and the three 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.

The rest of the model

  • A consent record that is present but cannot be decoded revokes everything
    ahead of the providers, whichever of them are configured, rather than
    degrading to the no-signal baseline.
  • Leaving gpc off the list stops that provider, 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 provider 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 four real providers and cover the default order against each
    opt-out, the reversed order, a provider left off the list, and one opt-out
    removed while the others stand.
  • The TCF purpose to Data Use mapping lives in the TCF provider 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, as the providers declare them, in provider order
    with duplicates removed, and the page receives {"set":[],"tdls":[]}. 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. An empty list says
    no terms were declared, which is not the same as terms permitting anything.
    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. None of the four schemes here declares terms, so the
    list is empty on every shipped path, and the tests drive it with a provider
    that declares one, through the production assembly.
  • The four schemes that ship are a starting set rather than the list. Model
    Terms for Marketing, where a publisher and the parties it passes data to
    agree to be bound by a published set of terms, is the next provider and
    arrives in a following pull request, as a crate like the four here with no
    change to core.
    docs/guide/permission-signals.md
    says so too, so a reader is not left thinking the four are fixed.
  • Destructive withdrawal is narrow. A provider answers withdrawal separately
    through withdraws, core scopes the answer to the storage baseline, and of
    the four only TCF answers it. Only a TCF record refusing storage in a
    jurisdiction whose baseline did not grant storage therefore expires the
    cookie 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 now use a TCF record
    that refuses storage.
  • 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
    provider 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. Geolocation is now off by default, and
    a deployment that runs an Edge Cookie provider with no location provider 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. 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 now 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. The pinning tests for the old rule are replaced by the
cross-scheme tests named above.

How it was verified

Every job main's CI runs was run locally against e3bb4b977, on Windows and
under WSL, and all of them passed. That covers cargo fmt --all --check,
Clippy with warnings denied on the Axum, Cloudflare, Spin and Fastly adapters,
on both wasm targets and on the four permission signal crates, the Axum,
Cloudflare and Spin adapter suites, the cross-adapter parity suite, the
benchmark smoke run, the release wasm builds for Spin and Fastly, and the CLI,
OpenRTB codegen, format-docs and template cache harness jobs that only run on
Linux. The core suite passes with 2,851 tests natively and 2,845 under Viceroy.
The head then gained one commit, 88a7482f8, which touches only
tools/permissions-inspector/wasm, a crate that is deliberately its own
workspace, so no workspace job's result changes. That crate builds clean for
wasm32-unknown-unknown and is rustfmt clean.

CI on that head was green across all 20 checks, being
Run Tests,
Run Format,
Integration Tests,
Permissions Inspector
and
CodeQL Advanced.

On the head this pull request shows now, 5c9f27e6c, every CI check passes: cargo test, the CLI tests, format-docs, the integration tests, vitest and CodeQL.

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.

@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)
malformed-record was never a signalling model. It is the flag
ConsentContext::has_malformed_record sets when a raw TCF, GPP or US Privacy
string arrives and will not decode, and the original code checked it as an
early return ahead of TCF so an unreadable preference failed closed.

Moving the inline chain behind the source trait made it look like a peer of
gpc and tcf, and the configuration work then offered it in the list, so a
publisher could remove it and silently turn off fail-closed handling that was
never optional. It also read as incoherent next to the others: a publisher
who removes tcf is saying which signal they act on, while removing
malformed-record would be saying what happens when a signal they do act on
arrives broken. Those are different questions.

It is now applied ahead of the configured models and regardless of which are
configured, and it is out of SOURCE_IDS entirely.

That also fixes a regression the ordered rule introduced. With it sitting in
the order, a TCF record that consents was asked after the unreadable GPP
string and overwrote the refusal it caused, which the original early return
never allowed. It overrides rather than holding a position, so one model
arriving unreadable is not cured by another arriving readable. Two tests
cover it, including that case.

Startup still logs the selection, and now warns about every model left out
rather than one, since a signal read from a request and then ignored is worth
seeing when someone asks why it had no effect.

cargo fmt clean, clippy clean on all five targets, 2429 core tests, 2619
fastly, 40 axum.
Core now supplies no signal model of its own. The seam that decides whether
a permission is set takes providers from outside core, asks them in the
order configuration gives, and applies the country and region rules to what
they settle on. The four models that lived inline in ec/consent.rs are gone
from core, and the trait they implemented is renamed PermissionSignalProvider
and given what a provider for a scheme core has never heard of needs: the
request as evidence, through RequestInfo, with a defaulted cookie accessor,
alongside the decoded consent record for the schemes core's pipeline already
decodes and caches.

Three things move with the models. The TCF purpose to Data Use table leaves
permissions.yaml and SignalPolicy, because which purpose grants which Data
Use is the TCF scheme's own meaning rather than a deployment's policy, and a
deployment that runs no TCF should carry no table of another scheme's
numbers. Storage withdrawal becomes a provider's answer through a defaulted
withdraws hook, scoped by core to the jurisdiction's storage baseline exactly
as ec_storage_withdrawn scoped it, and is recorded on PermissionState so the
finalize path reads it there. The hardcoded list of model identifiers leaves
settings validation, because which names are valid is only known where the
provider crates are linked, so an adapter's composition root now selects the
providers once at startup through build_permission_signal_providers, which
refuses an unknown or repeated name with a message naming what is available,
and carries the shared list on RuntimeServices.

One behavior of PR3 as pushed is not preserved, and it changed in the four
commits this builds on rather than here. A US-style opt-out used to win over
a consenting TCF record as a fixed rule in code. The providers are now asked
in order and the last with an opinion decides. The default order asks the one
signal with no interface of its own first, Global Privacy Control, and the
three carrying a choice someone made through an interface after, so an answer
given at a prompt amends the header the visitor arrived with, and a deployment
wanting the opposite reorders the list.
Every document that still stated the old rule is corrected here.
Global Privacy Control, the GPP sale opt-out, the US Privacy string and TCF
v2 each become a crate under crates/permission-signal, implementing
PermissionSignalProvider against the record core's consent pipeline already
decoded, so a returning visitor's cached record and the expiry rule are
honored the same way by every reader of the request. The TCF crate owns the
purpose mapping in code, with a test that every identifier in the table is a
real Data Use and none is granted by two purposes, and answers the storage
withdrawal question. The three opt-outs stay separate so a publisher who does
not act on Global Privacy Control can remove it and keep the other two. Each
crate carries the maintainers declaration the vendor crates carry.

Every adapter links the four, in one order, selects them once at startup so a
name no crate answers to fails there rather than on the first request, and
hands the shared list to every request's services. The cross-scheme behavior
that only shows when multiple providers run together, being a prompt's answer
applying over an opt-out, one opt-out standing when another is removed, a
scheme left off the list not running, and withdrawal being TCF's alone and
scoped to the place, is tested in the Axum adapter, which links all four
and runs natively.

Nothing in these crates is vendor-specific, and core links none of them.
A guide page for the seam and the four shipped providers, in the site's
sidebar, covering why the providers are not in core, how a request resolves,
that the order is the policy and what the default order does, withdrawal as
a separate question, and how a scheme is added. The permission model guide
is corrected where it still described the TCF mapping as living in
permissions.yaml or an opt-out as always winning over a consenting TCF
record, and the example configuration now calls the entries providers and
says the TCF purpose mapping is in the crate.
A permission says what may be done with the data. It does not say 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.
So a provider may now declare the terms documents that cover the request,
core carries what every configured provider declared on the permission
state, and the page reads them as `tdls` beside `set`.

A locator is the address of a published document a person can read. The
document must never be edited once published, which is why a version belongs
in its address, because a document that can be rewritten tomorrow means a
recipient can never prove what it agreed to and one edit silently rewrites
the basis of every transaction already sent under it. That is a property of
how a document is published rather than of its address, so the type refuses
only an address nothing could fetch, and says why in its own documentation.
The name matches the `tdl` member the Data Labels work puts on a node of an
OpenRTB request, which is where these travel when a bid request carries them.

None of the four schemes here declares terms, so the list is empty in every
shipped path and the tests use a provider that declares one, through the
production assembly rather than beside it. Model Terms for Marketing (MTM)
is the first scheme that will declare terms and one of many rather than the
only one, since a publisher, a trade body or a regulator can each publish
terms and each set becomes a provider.

An empty list says no terms were declared, which is not the same as terms
permitting anything, so a recipient needing a basis and finding none has
none. That is the reason the state carries the list at all rather than
leaving a reader to assume.

The permission state stops being `Copy`, because it now owns a list whose
length varies. One test helper copied it and now clones it; nothing else in
the workspace was relying on the copy.
The permission signal module documentation opened with the argument for
keeping signaling schemes out of core. That is a position rather than a
description of the code, and the same argument already stands in
docs/guide/permission-signals.md, so the module documentation now points
there and keeps to what core holds.
AGENTS.md asks for a descriptive message on every assertion, and the
tests added with the permission signal seam left 21 without one. Each
now states the rule it holds, so a failure names what broke rather than
printing two values that differ.
Brings main at 066ea3c into split/1-ec-provider. Twelve files
conflicted and one more needed a change without a textual conflict.
Most resolutions keep both sides. Five needed a decision, recorded here
so that no behavior changes silently.

Edge Cookie generation, in ec/mod.rs. Main's IABTechLab#885 creates a row only
when no row holds the key, retries on a collision and binds the request
snapshot to the new row. This branch creates identifiers through the
selected provider. Both are kept. Each attempt asks the provider for a
candidate through the new EcContext::candidate_id, which runs the
reserved-header and identifier-bound checks, then creates the row with
create_if_absent under the provider's canonical key and binds the
snapshot to that key.

Orphan recovery, in ec/finalize.rs. Main's IABTechLab#885 rotates an orphaned
cookie by generating an HMAC identifier directly, which would give a
vendor-provider deployment a built-in identifier. Recovery now asks the
selected provider through candidate_id, keeps main's proof of absence
and retry limit, and does nothing when no provider is selected. Any
headers the provider asks for while creating the replacement are
applied to the response.

Withdrawal, in ec/finalize.rs. Main's IABTechLab#1113 hardening is kept whole,
being finalize_unusable_consent, the existence check inside
write_withdrawal_tombstone, the snapshot write-back and
log_tombstone_outcome. The tombstones are keyed by this branch's
withdrawal_kv_keys, the canonical keys of the cookie and the active
identifier, in place of withdrawal_ec_ids, and the write-back compares
against the active identifier's canonical key. EID ingestion on the
returning and generated paths uses main's collect-then-upsert form
under the canonical key.

Secret references, in config.rs. Main's IABTechLab#1036 resolves secret settings
from the secret store by the paths listed in secret_fields. This branch
makes the legacy ec.passphrase optional and adds the
[ec.providers.hmac] passphrase, which was not listed, so it would have
been read as a literal value. Both are now listed as optional paths and
checked as key references when the configuration is pushed, and the
integration fixture and the example configuration name the secret key
rather than a value.

Spin start-up, in the Spin adapter. Main's IABTechLab#1036 loads settings from
the config store with secret resolution, which fixes the same start-up
failure this branch's Spin commit fixed. Main's loader is taken and
SpinPlatformConfigStore, which nothing else uses, is removed.

The four adapters keep this branch's composition-root provider check,
and Axum, Cloudflare and Spin keep its error propagation from
build_ec_context, alongside main's compiled auction plan from IABTechLab#1016.

Tests. Main's collision-retry tests and its orphan-rotation tests built
their context with no provider selected, which main's code did not
need. They now select the built-in HMAC provider, as an HMAC deployment
does, because with no provider there is nothing to create or rotate. No
other test reaches identifier creation or orphan recovery without a
provider selected. Tests from each side were moved onto the other
side's interfaces, being this branch's six-parameter context helpers,
main's mutable context in ec_finalize_response, and the
[ec.providers.hmac] passphrase in main's two secret validation tests.
Main's IABTechLab#1016 builds the orchestrator and the integration registry from a
compiled auction plan, and limits IntegrationRegistry::new to core's own
tests. The merge of main moved the Axum adapter's state building onto the
plan and took main's imports, but missed the test helper
state_with_uninjected_provider, which still called build_orchestrator and
IntegrationRegistry::new, so the adapter's library tests stopped compiling.
The helper now builds both from the plan, the same way
build_state_with_settings does.
Brings upd/split/1-ec-provider at cb28ad3, which carries main at
066ea3c, into split/2-device-geo. Six files conflicted, all where this
branch's host signals meet main's compiled auction plan or main's
imports. Each resolution keeps both sides' changes, so nothing either
side does is dropped.

The Axum, Cloudflare and Spin adapters keep this branch's provider
calls, which pass the host signals (None in all three) as well as the
injected provider, with this branch's comments saying so. They then
build the orchestrator and the integration registry from main's
compiled auction plan, the way split/1 does.

The Fastly imports keep what each side still uses. app.rs keeps main's
StoreName alongside this branch's build_geo_provider and
FastlyHostSignals. main.rs drops the config_store_name import, because
main replaced its only use with the runtime store names, and keeps this
branch's device provider import. platform.rs keeps main's
BackendNamingPolicy and drops GeoInfo and PlatformGeo, which this branch
moved into the trusted-server-geo-fastly crate.
Main's IABTechLab#1036 resolves secret settings from the secret store by the
paths TrustedServerAppConfig::secret_fields lists, and push validation
skips the validators on those paths because they hold key names.
Merging main registered the [ec.providers.hmac] passphrase, but not this
branch's [ec.providers.host-signals] passphrase. A key name there failed
the passphrase length check when the configuration was pushed, and a
key name long enough to pass would have been used unresolved as the
HMAC key.

The path is now listed as an optional secret and checked as a key name.
EdgeZero matches secret paths against validation error keys verbatim,
and the derived validation keyed the block's errors by the Rust field
name host_signals, so EcProviders now validates each built-in block
under the key the configuration uses. New tests cover pushing a
host-signals key name, resolving one from the mapped store, and still
rejecting a short resolved value.
Brings upd/split/2-device-geo at 0832f00, which carries main at
066ea3c through split/1, into split/3-permissions. Eleven files
conflicted, and CLAUDE.md changed type. Most resolutions keep both
sides. Four needed a decision, recorded here so that no behavior changes
silently.

Withdrawal, in ec/finalize.rs. Main's IABTechLab#1113 moved the path for a request
whose Edge Cookie is not permitted into finalize_unusable_consent, and
tells that path whether the request withdraws with ec_consent_withdrawn,
which looks for an explicit withdrawal signal in the consent context.
This branch decides withdrawal in the permission model, where the signal
providers answer it when the permissions are assembled, and reads the
answer with storage_withdrawn. Main's hardened path is kept whole and is
given storage_withdrawn as that answer. Of the shipped schemes only a
TCF record refusing storage withdraws, and only where the storage
baseline is not granted. On main, has_explicit_ec_withdrawal also treats
a Global Privacy Control, GPP or US Privacy sale opt-out in a US state as
a withdrawal. On this branch those opt-outs suppress without
withdrawing, as the permission model spec records, so the Edge Cookie
headers are stripped and the cookie is kept.

The identifier sent to auction partners, in auction/endpoints.rs and
publisher.rs. Main's IABTechLab#885 makes the identifier an owned value so the
identity-graph snapshot can be written back to the context, and on the
navigation path keeps the active identifier unfiltered for the snapshot
preload and finalization. This branch forwards the identifier only when
sharing is permitted, being storage plus personalized-ad selection, the
same pair that gates EIDs. Both are kept. The snapshot preload still
reads the active identifier, and only the forwarded copy is gated by
sharing.

Settings validation, in settings.rs. Main's IABTechLab#1117 removed the warning
about disabled creative rewriting, because request-time settings loading
logged it on every request. The warning stays removed. This branch's log
of the permission baseline sits in the same function and would repeat
for the same reason, so it now logs at debug level rather than info.

The template cache harness. Main rewrote the configuration edits with a
replace_once helper that stops on a missing target. This branch's
switch to the platform geo provider, which gives the harness a US state
jurisdiction now that default_country is retired, is written the same
way. The harness fastly.toml gets main's local backend and secret stores
as well as this branch's Viceroy geolocation answer.

CLAUDE.md is a symlink to AGENTS.md on main (IABTechLab#923). The 80 lines this
branch added to CLAUDE.md are applied to AGENTS.md instead, and
CLAUDE.md stays the symlink.

The four adapters build main's compiled auction plan (IABTechLab#1016) and then
this branch's permission signal providers. The example configuration
keeps this branch's [permission_signal] notes and main's asset proxy
example.

Tests. Main's endpoint tests from IABTechLab#885 and IABTechLab#1016 called the test helper
with a jurisdiction, where this branch's helper takes the permission
gate. They now build a non-regulated context with the gate open, which
is what their jurisdiction gave them on main. Main's withdrawal tests
from IABTechLab#885 and IABTechLab#1113 used a Global Privacy Control opt-out as the
withdrawal. They now use a TCF record refusing storage and state the
withdrawal answer, as this branch's withdrawal tests do because core
links no provider, and what they check, the tombstone and cookie
handling, is unchanged. This branch's own opt-out test, which now passes
main's mutable context, keeps the case where an opt-out leaves the
cookie in place. Main's asset proxy test from IABTechLab#742 sets this branch's
permissions_script field to None, and its settings acknowledge
single-jurisdiction operation, which this branch requires of an Edge
Cookie provider with no geo provider.
The Windows notes said CI runs the adapter tests on both ubuntu-latest
and windows-latest. No job in this branch's test workflow runs on
Windows, so the sentence now says ubuntu-latest.

The notes also say that trusted-server-cli does not build on a Windows
host, because its dependency edgezero-adapter-fastly uses a standard
library feature that is unstable on Windows, so its tests and the
template cache harness, which builds it, run in WSL too.
Christian Pavilonis's review thread on provider response effects asked
core to validate them against the managed ts- cookies, the x-ts-
namespace and framing headers. The check is in place, but several
comments said a rejected effect "fails the request", which is not what
happens. EcContext::generate_if_needed returns the error, and its only
two callers outside tests, the publisher fallback in the Fastly adapter
and IntegrationRegistry::handle_proxy, log it and serve the response
without an Edge Cookie. The reserved_response_effect and
apply_provider_response_headers docs, the comment in
EcContext::candidate_id and the reserved-surface test now say that.
The candidate_id comment no longer implies the rejection matches the
identifier-bounds check in every respect, because that check runs after
the provider's headers are captured.

A new test, a_rejected_provider_effect_never_reaches_the_finalized_response,
proves a rejected header never reaches the response. For each reserved
effect it lets generation fail, runs EC finalization on the same
context, and checks the response carries no forged ts-ec cookie, no
x-ts-ec header and no transfer-encoding. It failed when the header
capture in candidate_id was moved ahead of the reserved check, and
passes with the code as it is. The test helper now hands back the
context even when generation fails, so the test can finalize on it.

The review thread is
IABTechLab#1043 (comment)
Christian Pavilonis's review thread on partner paths asked for
validation and KV normalization to be dispatched by provider code.
Validation already was, but several paths still read or wrote identity
graph rows under the identifier as issued, while generation stores each
row under the owning provider's canonical form. For a provider whose
canonical form differs from the cookie value those paths found no row.

- Pull sync validated the identifier but kept only the raw value. It
  looked the request snapshot up under that value, while generation
  binds the snapshot to the canonical key and every read EC
  finalization makes uses that key, so it skipped every partner. Its
  revalidation read and write-back used the raw value too.
- The admin lookup answered 404 for a row that exists.
- The /auction, publisher navigation and /_ts/page-bids preloads loaded
  a miss, and resolve_auction_eids matched the snapshot by the raw
  identifier, so auctions carried no server-side EIDs.
- The navigation preload also replaced the snapshot generation had just
  bound to the canonical key with that miss, so a newly created
  identifier got no ts-ec cookie on a navigation with no EID cookies to
  ingest.

Pull sync now carries the canonical key and uses it for the request
snapshot lookup, the revalidation read and the write-back. Partners
still receive the identifier as issued, and the pull rate limit key
still hashes the issued identifier, which leaves rate limiting
unchanged for every provider. The admin lookup reads under the key,
reports the requested identifier as ec_id, and adds the key it read as
kv_key, which the API reference now describes. The three preloads load
under EcContext::ec_kv_key and resolve_auction_eids looks the entry up
under the key. EcContext::accepts_id lost its only caller and is
removed.

For the built-in HMAC provider the canonical key is the identifier
itself for every identifier read-back accepts, so these paths behave
as before for HMAC cookies. Apart from the new kv_key field, the one
change an HMAC deployment can see is that an admin lookup given an
identifier with an uppercase hash now finds the row stored under the
lowercase key instead of answering 404.

Each path has a test using CanonicalizingProvider, whose identifier
t0ca~MiXeD.CaseId is stored under t0ca~mixed.caseid, and all seven new
tests failed before the fix. The shared constants for that identifier
now live beside the provider, so the identify, finalization and new
tests use one definition. The known-gap note on EcContext::kv_key_for,
which cited commit 343ac3e from outside this branch, and the matching
note on AcceptedProviders now state which paths key rows through the
canonical form.

The review thread is
IABTechLab#1043 (comment)
Some comments added for the two review threads on provider response
effects and canonical keying claimed more than the code does, and one
changed comparison had no test that depended on it.

The AcceptedProviders doc said pull sync, batch sync and the admin
lookup all read and write rows, but the admin lookup only reads. The
comment in the test
a_rejected_provider_effect_never_reaches_the_finalized_response said a
rejected header kept anywhere on the context would reach the browser,
when EC finalization applies only the response headers the context
holds. The admin lookup docs and the API reference entry for kv_key
named the owning provider as the source of the row key, which does not
hold for a deployment with no provider selected, where the built-in
HMAC identifier format supplies the key. Three other comments now say
precisely what they mean. The EcContext::kv_key_for doc names the
function each reference points at, the EcContext::candidate_id comment
no longer credits an unnamed caller with serving the response, and the
dispatch_pull_sync doc names ec_hash as the input to the rate limit key.

Before replacing the snapshot that generation bound, the publisher
navigation preload compares that snapshot with its fresh read. The
comparison is keyed by the canonical key, yet the navigation tests for
a provider whose canonical key differs from the cookie value passed
with it keyed by the identifier as issued, because their stores
returned the row on the first read. The test for a newly created
identifier's cookie now also runs against a store whose first point
read misses the row generation just wrote. With the comparison keyed
by the identifier as issued, the preload replaced the snapshot with
that miss, EC finalization skipped the cookie and the test failed.
With the canonical key the test passes.
Main's configuration-driven OpenRTB auction providers (IABTechLab#1016) added
three startup tests each to the Cloudflare and Spin adapters, and their
settings set the deprecated [ec] passphrase with no geo provider. That
form migrates to provider = "hmac", and on this branch
GeoConfig::validate_jurisdiction_acknowledgment requires
[geo] assume_single_jurisdiction = true for an Edge Cookie provider
with no geo provider, so the settings failed to load and all six tests
failed. The four settings blocks now set it, as the other test settings
in both adapters that select an Edge Cookie provider already do.
A provider's own response headers, such as an evidence cookie, reached
the browser even when generation discarded the candidate they came
with. EcContext::candidate_id kept the headers before checking the
identifier against the cookie bounds, and generate_with_provider left
them on the context when the candidate collided with an existing row
or its row could not be written. EC finalization applies whatever
headers the context holds, and the publisher and integration proxies
log a generation error and still serve the response, so the cookie
went out with no identifier stored for it.

candidate_id now keeps the headers only once the identifier has passed
the bounds check, or when the provider produced no identifier at all,
and generate_with_provider drops them with a colliding or unpersisted
candidate. The reserved-surface check asked for in the review thread on
provider response effects already ran before any header was kept, so
the same rule now also holds when the identifier is rejected, when it
collides and when its row cannot be written.

A test covers each of those three cases, and all three failed before
the change with the provider's cookie on the finalized response.
HeaderSettingProvider now takes the identifier it returns, so a test
can pair a permitted header with an identifier outside the cookie-safe
alphabet. The finalization comment on applying provider headers now
names candidate_id as the place they are checked, where it named
generate_with_provider.

The review thread is
IABTechLab#1043 (comment)
The API reference said the explicit admin EC lookup route accepts an EC
ID in the bare {64 lowercase hex}.{6 alphanumeric} form. The route
accepts whatever AcceptedProviders::canonical_kv_key accepts, which is
an identifier created by the selected provider, such as the built-in
HMAC provider's hmac~ form, and the bare legacy form that provider
still reads, with both HMAC forms accepted when no provider is
selected. The hash may be given in either case, because
canonical_kv_key lowercases it before the check.

The note on retiring the legacy bare reader said a page view with
ts-eids or sharedId cookies runs ingest_eid_cookies in
ec_finalize_response and so restarts the row's one-year clock. Main's
change threading the EC KV read through the request (IABTechLab#885) moved
finalization to collect_eid_cookie_updates and
upsert_partner_ids_from_snapshot, which writes nothing unless a partner
ID is added or changed. The note now names that function and says only
such a view restarts the clock.
Brings upd/split/1-ec-provider at 1b88e42 into split/2-device-geo.
The five commits this branch lacked, on top of the cb28ad3 it
already had, answer two review threads on IABTechLab#1043 and correct what
reviewing those answers found.

- Pull sync, the admin lookup, /auction, the publisher navigation
  preload and /_ts/page-bids read and write identity-graph rows under
  the owning provider's canonical key (0e7f7eb and fb1bc28).
- The docs on a rejected provider effect say generation returns an
  error and the page is served without an Edge Cookie, with a test that
  a rejected header never reaches the finalized response (0980b73).
- A provider response's headers are kept only for a candidate that
  generation commits (cb6f717).
- The API reference lists the admin lookup's accepted EC ID forms, and
  the bare reader note names the function that writes EID updates
  (1b88e42).

The merge had no conflicts. The merged tree passes a native
all-targets check, 2,763 core tests, the Axum, Cloudflare and Spin
tests and the three wasm checks.
Brings upd/split/2-device-geo at 37eadae, which carries the split/1
review follow-ups (0980b73, 0e7f7eb, fb1bc28, cb6f717 and
1b88e42), into split/3-permissions. Two blocks conflicted.

- The build_pull_sync_context doc keeps this branch's statement that
  pull sync needs the sharing permission pair, and adds the follow-up's
  case where no provider this deployment reads owns the identifier.
- In handle_publisher_request the forwarded identifier keeps this
  branch's ec_sharing_allowed() filter, and the follow-up's
  active_kv_key line goes in front of it, so the navigation preload
  reads and compares the row under the canonical key.

One test needed a change without a textual conflict. The follow-up's
auction_endpoint_loads_the_row_under_the_canonical_key built its
context with make_ec_context(Jurisdiction::NonRegulated, ...), and on
this branch that helper takes the permission gate as a bool. The test
now uses this branch's make_non_regulated_ec_context, which opens the
gate in a non-regulated jurisdiction. The other new tests build their
context with EcContext::new_for_test, which on this branch grants
storage and personalized-ad selection, so they pass the sharing gate
unchanged.

Tests. The merged tree passes a native all-targets check, 2,851 core
tests, the Axum, Cloudflare and Spin adapter tests, the permission
signal crate tests, and the Fastly, Cloudflare and Spin wasm checks.
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Sep 14, 2026
The permission signal providers in IABTechLab#1045 replace the fixed rule, where
an opt-out always beat a consenting TCF record, with the order a
deployment configures in [permission_signal] sources. The spec still
described the retired rule, so it now states what the implementation
does.

Section 4 describes the resolution steps, how the order is configured
and its default, and the decision matrix under that default. The
overview, policy format, validation, withdrawal, normalization, US
field mapping, failure-mode matrix, testing strategy and the
divergence and revision records follow it, and a new section 13
records each change. The sample policy's opt-out revoke list is also
described as it is, a list of specific Data Uses rather than all.
The inspector calls `assemble_permissions`, which this branch later gave two
more arguments, the request evidence and the signal providers. Nothing caught
it, because `tools/permissions-inspector/wasm` is deliberately its own
workspace, so the gates that build the main workspace never build it, and the
`Permissions Inspector` workflow that does is added by this branch too and so
first ran on this pull request. It failed with three E0061 errors.

The inspector now builds the same four providers every adapter offers, in the
same default order (`gpc`, `gpp-sale-opt-out`, `us-privacy`, `tcf`), and hands
them to `assemble_permissions`. Passing an empty slice would have compiled just
as well and silently stopped the page acting on any signal at all, which is the
one thing the page exists to show.

The evidence argument is an empty `OwnedRequestInfo`. The page carries no
request, only the consent signals its form collects, and each of the four
providers answers from the consent record rather than from request evidence, so
empty evidence changes none of their answers. The comment in `eval_json` says
so, and says that a provider reading a header or a cookie would need real
evidence there.

The lock file picks up the four crates, and with them the edgezero v0.0.7 to
v0.0.8 bump and `cssparser`, which this separate lock had not taken from the
`main` merge.

`build.rs` and one line of `src/lib.rs` are rustfmt output. The crate is outside
the workspace, so `cargo fmt --all` never reached it and `build.rs` had never
been formatted.

Tests. `./scripts/build-inspector-wasm.sh` builds clean for
wasm32-unknown-unknown, and `cargo fmt --check` on the crate is clean.
Renames `config/permissions/vanilla.yaml` to `config/permissions/sample.yaml`
and says at the top of the file, in the guide and on the constant that the file
is for testing and evaluation only, is not a production policy and is not legal
advice. "Vanilla" described the flavour of the rules and said nothing about
whether anyone should run them, which is the thing a reader needs to know
first.

The display name becomes "Sample (testing and evaluation only)", so the
inspector's dropdown carries the warning wherever the page is opened. The
build script globs `config/permissions/*.yaml`, so its manifest picks the new
name up with no change.

`include_str!` in `permissions.rs` follows the rename. The doc comment above it
is rewrapped and loses a clause-joining semicolon. The guide gains a short
paragraph saying the same thing.

The word "vanilla" is left alone where it means plain JavaScript, in the
DataDome and Lockr script guards and in an older plan document.

Tests. `cargo fmt --all --check` is clean, the core suite passes 2,851 tests,
and `./scripts/build-inspector-wasm.sh` builds and writes a manifest reading
`{"file":"sample.yaml","name":"Sample (testing and evaluation only)"}`. The
docs Prettier configuration sets `proseWrap: preserve`, so the shorter path
cannot change the formatting.
Every provider type selects the same way, with provider in its own section,
and names its providers in snake_case. Permission signals now follow that,
so [permission_signal] sources becomes [permission_signal] provider. The key
stays an ordered list, and a configuration that leaves it out still runs
every provider the adapter links, in the order the adapter offers them.

The two hyphenated provider names become gpp_sale_opt_out and us_privacy,
while gpc and tcf are unchanged. The crates report the new identifiers, and
core's selection messages, the startup log, the module README, the guides,
the example configuration and the permissions sample follow.

sources is removed rather than accepted alongside provider, which breaks a
configuration written against the branch that introduced it. A section
carrying sources is refused when the settings are read, with a message
naming provider, on each path a deployment reads settings through, being a
TOML file, the TOML value ts config push reads, and the JSON config blob
read at startup. Reading the section by hand rather than with
deny_unknown_fields is what makes that message possible, because a derived
refusal can only name sources by declaring it as a field, and would then
offer it as a key it expects whenever it refused any other.

No provider takes settings, so a [permission_signal.<name>] block stays an
unknown field. Its refusal now says that a provider which gains settings
will take them there.

Tests cover the removed key on each of those paths, a settings block
refused as an unknown field, a list surviving a config blob round trip,
and, where the real crates are linked, the documented names selecting all
four providers in order and an old hyphenated name being refused with the
names available.
Every name an operator types into configuration is snake_case, so the built-in
host-signal provider is now selected with `[ec] provider = "host_signals"` and
configured with `[ec.providers.host_signals]`. The key constant, the serde name
of the typed block, the validation error key, the registered secret path
`ec.providers.host_signals.passphrase`, the placeholder-secret report, the two
startup messages that spell the key and the provider's own log line all follow.
This commit changes only that one name.

The `HostSignals` trait, the `host_signals` fields and arguments that carry it,
and the prose that calls this the host-signal provider are unchanged, because a
host capability is not a configuration value. The test secret-store key name
`host-signals-passphrase-key` is unchanged for the same reason, being a name an
operator picks for a secret rather than a provider name.

The Rust field name and the configuration name are now the same word, so the
`#[serde(rename)]` on the typed block goes and the field is declared the same
way as `hmac` beside it. That also retires the reason the `EcProviders`
validation keys were spelled out by hand, so the note above those keys is
rewritten to say what still holds, which is that each key has to match the
secret path `TrustedServerAppConfig::secret_fields` registers.

The old name is refused when settings load, which every adapter does before it
serves a request. The message names `host_signals` and says what to write. The
refusal comes before any block is looked for, because a block left behind under
the old name is captured as a vendor block, so the old selector would otherwise
find that block, pass the settings check, and fail later in provider resolution
with a message about an adapter that supplies no such provider. That is a
breaking change, accepted for a major release.

Tests. `cargo fmt --all -- --check` is clean and `cargo clippy -p
trusted-server-core --all-targets --all-features -- -D warnings` passes. The
core suite passes 2,764 tests and 5 doc-tests with 4 ignored, the new test among
them. The Axum adapter passes 43 tests across its three binaries, `cargo
clippy-axum` is clean, and `cargo check -p trusted-server-adapter-fastly
--target wasm32-wasip1` builds.
The Edge Cookie provider blocks move from [ec.providers.<name>] to
[ec.<name>], so identity follows the one convention every provider type
uses, where [<type>] provider = "<name>" selects and [<type>.<name>]
holds that provider's settings. The [ec.providers] table is gone, and a
configuration still carrying it is rejected with the new location in the
message.

A block exists only when the provider has settings. The built-in hmac
provider has a required passphrase, so selecting it still needs
[ec.hmac], while a provider with no settings needs no block at all. Only
the adapter that injects a provider knows whether that provider has
settings, so core no longer demands a block for a name it does not
supply itself.

A block may name the implementation it configures with
implementation = "<id>", which makes the block's own name a label of the
operator's choosing. [ec] provider = "primary" with [ec.primary] holding
implementation = "hmac" and a passphrase configures the built-in
provider under a name that means something to the deployment. Everything
that resolves the selection now reads the implementation rather than the
label, covering the built-in lookup, the matching of a provider the
adapter injects, the check that a selected implementation has the
settings it needs, and the errors. An implementation this deployment
cannot build fails startup naming the implementations it does have.

The fixed [ec] keys stay reserved and cannot name a provider, every
other key in the section has to be a table, and a key that is not one is
reported as the unknown field it almost certainly is, so a typo such as
ec_stor is still caught with a sensible message. Provider names and
implementation ids are snake_case. A block the selector does not name
still fails startup, as it did before.

Secrets follow the blocks. TrustedServerAppConfig::secret_fields now
lists ec.hmac.passphrase, and EdgeZero's path segments cannot say
"whatever name the operator chose", so core reads the labeled blocks out
of the configuration itself through the new ConfiguredSecretFields trait
and resolves their passphrases from trusted_server_secrets in the same
pass. Push-time validation, where those fields hold key names rather
than secrets, no longer runs the passphrase value check against a
labeled block's key name. The check itself is unchanged wherever
settings are loaded with their secrets resolved.

The legacy [ec] passphrase shim still works and now points at [ec.hmac].
Brings the Edge Cookie provider block layout, where each provider has its
own [ec.<name>] table and an optional implementation = "<id>" line, under
the host-signal provider rename this branch already carried. Both changes
are the same decision applied to different parts of one section, so the
host-signal provider is now selected with [ec] provider = "host_signals"
and configured in [ec.host_signals], not [ec.providers.host_signals].

Four files conflicted.

crates/trusted-server-core/src/settings.rs. split/1 replaced the typed
EcProviders struct with EcProviderBlocks, a map of the blocks written
under [ec], in which only the hmac implementation keeps typed settings
and everything else is held as the raw values an adapter reads. This
branch had added host_signals as a second typed built-in beside hmac, so
the resolution gives it the same standing in the new model rather than
demoting it to raw values. EcProviderSettings gains a HostSignals
variant, EcProviderBlock gains host_signals_settings, EcProviderBlocks
gains host_signals_blocks beside hmac_blocks, read_provider_block reads a
host_signals block into the typed config, and From<HostSignalsProviderConfig>
builds the block. The block-required check in validate_provider_selection
now covers both implementations built into core, because both take a
passphrase. The placeholder-secret report names the block the operator
wrote, as it already did for hmac. The refusal of the old host-signals
spelling is kept and its message now sends the operator to
[ec.host_signals]. That refusal moved to the top of
validate_provider_selection and now covers a block left under the old
name as well as the selector, because split/1's snake_case rule runs over
the block names first and would otherwise refuse host-signals with the
general rule instead of the message naming the spelling to write. The
tests of both branches are kept, with this branch's host-signal test
rewritten onto the new block layout.

crates/trusted-server-core/src/ec/provider.rs. Both branches edited the
resolution arms and the module documentation. build_provider keeps the
host_signals parameter this branch added, because the host-signal
provider needs a service only some hosts supply, and the host-signal arm
of resolve_named_provider now reads its settings out of the block the
selector names rather than a fixed field. BUILTIN_PROVIDER_KEYS keeps
both entries under split/1's wording, which calls them implementation
ids. The documentation keeps split/1's [ec.<name>] spelling together with
this branch's statement that the host-signal provider is built per
request.

crates/trusted-server-core/src/config.rs. The registered secret path for
the host-signal passphrase drops the removed providers segment and
becomes ec.host_signals.passphrase. Push-time validation reads the
host-signal blocks the same way it reads the hmac ones, and the reader
that finds a built-in provider configured under a label of the operator's
choosing now looks for either implementation, so a labeled host-signal
block resolves its passphrase too.

crates/trusted-server-core/src/config_payload.rs. Both branches added a
key to the test secret store. Both are kept, being separate keys serving
separate tests.

Two follow-on fixes outside the conflicts. The tests this branch had
written against the removed EcProviders type now go through a new
select_host_signals_provider test helper beside select_hmac_provider, and
the build_provider calls split/1 had reduced to two arguments take the
host_signals argument again.

Tests. cargo fmt --all -- --check is clean and cargo clippy -p
trusted-server-core --all-targets --all-features -- -D warnings passes.
The core suite passes 2,782 tests and 5 doc-tests with 4 ignored. The
Axum adapter passes 43 tests across its four binaries, cargo clippy-axum
is clean, and cargo check -p trusted-server-adapter-fastly --target
wasm32-wasip1 builds.
…it/2

Brings up the Edge Cookie provider block layout from split/1, where each
provider has its own [ec.<name>] table, together with split/2's rename of
the host-signal provider to host_signals. Both are the same decision this
branch applies to permission signals, which is that every provider name
an operator types is snake_case and a provider type is a top-level table
whose provider key selects what runs.

Nothing conflicted. The permission signal work this branch carries sits
in its own section and its own module, so the Edge Cookie section
changes merged alongside it.

One fix the merge needed. A test of the jurisdiction acknowledgment built
its stateless case by deleting the hmac provider block from the test
configuration by text, and it still named that block [ec.providers.hmac].
Under the new layout the block is [ec.hmac], so the deletion would have
matched nothing and left a provider block configured with no selector,
which the new layout rejects. The test now names the block it means.

Tests. cargo fmt --all -- --check is clean and cargo clippy -p
trusted-server-core --all-targets --all-features -- -D warnings passes.
The core suite passes 2,874 tests and 8 doc-tests with 4 ignored. The
Axum adapter passes 64 tests across its five binaries, cargo clippy-axum
is clean, and cargo check -p trusted-server-adapter-fastly --target
wasm32-wasip1 builds.
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