Add the client-set Edge Cookie value path - #1046
Open
jwrosewell wants to merge 122 commits into
Open
jwrosewell wants to merge 122 commits into
jwrosewell wants to merge 122 commits into
Conversation
This was referenced Aug 19, 2026
jwrosewell
force-pushed
the
split/4-client-resolve
branch
3 times, most recently
from
August 25, 2026 10:51
88f96f8 to
82cd70f
Compare
jwrosewell
force-pushed
the
split/4-client-resolve
branch
from
August 25, 2026 13:37
82cd70f to
0217e09
Compare
jwrosewell
force-pushed
the
split/4-client-resolve
branch
6 times, most recently
from
August 31, 2026 12:50
8a67a9f to
eff9f74
Compare
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Aug 31, 2026
The review of IABTechLab#1043 asked that spec changes land before the code that implements them, so a divergence is a decision taken in review rather than a ratification of something already merged. PRs IABTechLab#1043 to IABTechLab#1047 each carried the design document for their own step, and IABTechLab#1043 carried a 607-line spec describing device providers, geo providers, the permission model and the browser resolve endpoint, none of which is in that PR. Move all six series documents here, so this PR carries the complete normative set and no code: - 2026-07-30-pluggable-providers-design.md (from IABTechLab#1043) - provider-code-registry.md (from IABTechLab#1043) - 2026-07-30-permission-model-design.md (from IABTechLab#1045) - 2026-07-30-client-cycle-ec-resolve-design.md (from IABTechLab#1046, later revised by IABTechLab#1047) - 2026-07-30-integration-response-header-hook-design.md (from IABTechLab#1047) - 2026-07-30-provider-migration-rollout-design.md (from IABTechLab#1047) Each file is taken verbatim at the tip of the stack, so the later revisions are preserved: the provider-switching continuity section, the geo requires-signal floor, and the code-envelope paragraph IABTechLab#1047 added to the client-cycle spec. The revision-record tables are unchanged. No document's substance was edited. The only edits are to this spec's own status line, which said the PR adds one document and that the series specs land with IABTechLab#1047, and a revision-record row recording the move.
Collaborator
jwrosewell
force-pushed
the
split/4-client-resolve
branch
from
September 1, 2026 15:34
eff9f74 to
5552519
Compare
jwrosewell
force-pushed
the
split/4-client-resolve
branch
5 times, most recently
from
September 1, 2026 23:24
670b0e8 to
bb76eb5
Compare
…ider
First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.
- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
The deprecated [ec] passphrase form still starts for one release
cycle: it maps to provider = "hmac" with a deprecation warning, and a
configuration carrying both forms is rejected. provider = "none"
spells explicit statelessness. A configured block that is not the
selected provider is rejected at startup, as is a block with no
selector.
- Global identifier bounds enforced by core at mint, read-back, and
cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
cap. An identifier outside the bounds is rejected loudly, never
rewritten, so the cookie value and the identity-graph key can never
silently diverge.
- The identity graph is keyed by the provider's canonical form of the
identifier (normalize_id_for_kv), so equivalent representations of
one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
access to the client IP, headers (including cookies), URL path, and
query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
provider, so a vendor provider lives in its own crate and core never
names it. A selected provider the adapter does not inject fails the
request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
proceeding stateless.
Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.
Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.
The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.
Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.
is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
CodeQL's cleartext-logging query treats a call whose name contains "passphrase" as a sensitive source, and because the method mutates the Settings it belongs to, every later log line that prints anything from Settings (store names, timeouts, header names) is reported as writing a secret to a log. The passphrase itself is a Redacted<String> and none of the flagged lines prints it. The method now describes what it does, migrate_legacy_ec_layout, and its behavior is unchanged.
A reviewer raised a P1 against the pluggable Edge Cookie provider work: three of the four adapters broke the provider contract that an unavailable required service or an uninjected provider stops the request. The Axum, Cloudflare and Spin adapters each read the Edge Cookie context with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`, logged a warning and continued with `EcContext::default()`. A deployment whose selected provider could not be built therefore came up and served every request with no identity, silently. The Fastly adapter already kept the report and answered with an error response. `build_ec_context` on the three adapters now returns `Result<EcContext, Report<TrustedServerError>>` and every call site propagates it to that adapter's own `http_error`, the same helper Fastly uses, so all four answer with the same status and shape. The design this implements has the composition root check a selected provider's needs once at startup rather than per request, so `ensure_provider_available` was added to `ec/provider.rs` and is called from `build_state_with_settings` on all four adapters (Fastly included, so the rule is uniform). Building a provider reads no request data, so a selection an adapter can never supply now fails when application state is built, and the three adapters answer every route from their existing `startup_error_router` instead of coming up. Statelessness, meaning no `[ec] provider` selector or the explicit `"none"`, still passes and still serves. The widening question was checked rather than assumed. `read_from_request_with_geo` can only fail from two places: the provider build, and a `Cookie` header that is not valid UTF-8. A malformed cookie value is dropped with a warning by `request_ec_id_if_allowed`, consent parsing returns a value rather than a `Result`, and the geo lookup is already swallowed by the adapter before the call, so no ordinary parse problem reaches the error path and none is turned into a failed request. Tests: each of the three adapters gains a route test proving an uninjected provider fails at startup, and an in-crate test proving `build_ec_context` returns the error rather than a default context. Core gains a test that the startup check rejects an uninjected provider and still allows statelessness both ways. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1)
`Settings::finalize_deserialized` runs derive validation before `Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase` field carries no `#[validate]` attribute of its own, so the advertised 32-byte minimum was only enforced on the new `[ec.providers.hmac]` location. A configuration still on the old form could start with `passphrase = "short"`, or with an empty value, and mint identifiers from keying material the new location rejects. The migration now calls `Ec::validate_passphrase` on the value it is about to move, before it logs the deprecation warning and writes the `[ec.providers.hmac]` block, and reports a configuration error naming the minimum length and the new location. Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives `Settings::from_toml` with the `[ec]` section rewritten to the deprecated form and proves a short value and an empty value are both rejected, and that a passphrase of adequate length still migrates to `provider = "hmac"` with the passphrase in the hmac block. Removing the new check makes that test fail, so it tests the fix rather than the surrounding code. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench)
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.
Brings the permission signal work on split/3-permissions up to this branch, so the client-set value path builds on the permission model as it now stands. The merge has no conflicts.
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 the documentation and assertion message fixes up from split/3-permissions. The merge has no conflicts.
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.
Brings upd/split/3-permissions at 3c9f4b0, which carries main at 066ea3c through split/1, split/2 and split/3, into split/4-client-resolve. Two files conflicted. Each resolution keeps both sides' changes. ec/finalize.rs takes main's imports for identity-graph writes and EID collection, and keeps this branch's expire_ec_resolved_marker beside them. This branch's two resolved-marker tests from ee07418 sit beside main's orphan-recovery tests from IABTechLab#885, and now pass main's mutable context to ec_finalize_response. docs/guide/api-reference.md keeps this branch's section for the POST /_ts/api/v1/ec/resolve endpoint and drops the POST /third-party/ad section, which main's IABTechLab#1016 removed.
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.
Brings upd/split/3-permissions at e3bb4b9 into split/4-client-resolve. That carries the split/1 review follow-ups (0980b73, 0e7f7eb, fb1bc28, cb6f717 and 1b88e42), their merge into split/3 under that branch's permission gate, and split/3's be0ed2d, which lets the Cloudflare and Spin startup tests from main's IABTechLab#1016 load under the single-jurisdiction rule. This branch carries the same four settings blocks, so it takes that fix here. The merge had no conflicts. The resolve endpoint already applies a provider's response headers only with the 200 that sets the cookie or the 204 when the provider creates nothing, which is the rule cb6f717 brings to organic generation, so nothing in resolve.rs changes. Tests. The merged tree passes a native all-targets check, 2,885 core tests, the Axum, Cloudflare and Spin adapter tests, the permission signal crate tests, and the Fastly, Cloudflare and Spin wasm checks.
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.
Brings upd/split/3-permissions at 88a7482 into split/4-client-resolve. That carries the one commit which makes `tools/permissions-inspector/wasm` pass the request evidence and the signal providers to `assemble_permissions`, and which formats the crate. This branch carries the same inspector and the same `Permissions Inspector` workflow, so it failed the same way. The merge had no conflicts and touches only the four files under `tools/permissions-inspector/wasm`. Tests. `./scripts/build-inspector-wasm.sh` builds clean for wasm32-unknown-unknown on this tree, 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.
Brings upd/split/3-permissions at a2a1dfc into split/4-client-resolve. That renames `config/permissions/vanilla.yaml` to `config/permissions/sample.yaml` and says in the file, in the guide and on the constant that the sample is for testing and evaluation only, is not a production policy and is not legal advice. The merge had no conflicts. `trusted-server.example.toml` auto-merged, because this branch changes other parts of the same file. Tests. `cargo fmt --all --check` is clean and the core suite passes 2,885 tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #1045, the permission model, and depends on the permission model because the resolve endpoint applies the same permission gate as organic generation. Through the stack this pull request also depends on #1043, whose
EdgeCookieProvidertrait gains the client resolve method here. The stack has six pull requests (#1043, #1044, #1045, #1046, #1047, #1094), each targetingmain, with this one fourth, and the first five decompose #838 as requested in the #986 review. Comparesplit/3-permissionswithsplit/4-client-resolveto see only this pull request's change.The design specs for the series are carried by #1084. The spec for this pull request is
2026-07-30-client-cycle-ec-resolve-design.md, the threat-model draft revised to the implemented state. Its revision record maps each requirement to what v1 implements and what deliberately waits for the first vendor scheme.Why this path matters
A client-cycle provider establishes the identifier through a browser round trip. The page script obtains or derives a value (for a real vendor, a signed envelope from the vendor's identity system), posts it to
POST /_ts/api/v1/ec/resolve, and the provider verifies that value before the edge sets it as the Edge Cookie. The vendor Edge Cookie provider proposed in #1072 works client-side by design, so this endpoint is on the series' critical path rather than deferred.What this pull request does
EdgeCookieProvidergainsresolve_from_clientwith a default that creates nothing, so server-side providers are untouched.Originon the publisher's domain (403 otherwise) and atext/plainorapplication/jsonbody (415 otherwise, 413 over 64 KiB). A created identifier must fit the global identifier bounds (400) and must not silently replace a different identity already on the request (409).Cache-Control: no-store, and a provider or configuration error goes to the adapter's error response instead.ts-ecr=1, no identity content) tells the page script a resolve succeeded, fixing the earlier defect where the script checked for a cookie it could never read and so posted on every page view. The marker expires together with the Edge Cookie on withdrawal. A Rust test checks that the marker name and the demo's fixed word match the page-script source.client-fixeddemonstration provider is compiled only behind theclient-fixed-democargo feature, and production builds reject the selection at startup. A fixed shared word is not an identity. The demo provider's cookie carries the registry code (cfix~an-ec), so even demo identities are provider-namespaced.identifyandbatch-sync, which need the same platform KV wiring those adapters lack (documented in the Spin adapter route list).How it was verified
Every job
main's CI runs was run locally against2c34be429, on Windows and under WSL, and all of them passed. That coverscargo 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-docsand template cache harness jobs that only run on Linux. The core suite passes with 2,885 tests natively and 2,879 under Viceroy. The head then gained the mergeb7dd13f8e, which touches onlytools/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 this head is green across all 20 checks, being Run Tests, Run Format, Integration Tests, Permissions Inspector and CodeQL Advanced.
Endpoint tests in
crates/trusted-server-core/src/ec/resolve.rscover the origin, content type, body size, conflict, no-graph and marker behaviors, andclient_set_value_round_trips_through_the_ec_scenariodrives a client identifier through deferral, resolve, cookie set and verbatim read-back. The endpoint tests in that file do not drive the identifier-bound 400 or the graph-write 503 answer.References #778. Decomposes #838. Spec baseline from #986.