Repository navigation
Add the client-set Edge Cookie value path - #1046
Open
jwrosewell wants to merge 199 commits into
Open
jwrosewell wants to merge 199 commits into
jwrosewell wants to merge 199 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
4 tasks
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
4 tasks
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)
The docs format job also runs prettier over the Markdown outside docs/, which rejected both files. This is prettier's own output, with no change to the wording.
Three tests built the same ClientResolveInput and differed only in the module, the payload and the identifier expected. They become one table naming each case: client_fixed with the known word creates the identifier, client_fixed with another word creates nothing, and hmac creates nothing from any payload because a server-side module inherits the no-op resolve_from_client default.
Brings the four commits that fixed the permission branch's CI: the inspector no longer calls a removed method, its lock follows the workspace's edgezero revision, and prettier's formatting of the two permission guide pages, the agent guide and the permission signal README. # Conflicts: # AGENTS.md
What an operator selects to supply a capability is a module, and provider keeps the one meaning it has on main, an auction provider instance. This renames the words this branch adds on top of the permission model, which already says module, and only those words: every provider word was compared with upstream main, and a word that exists there is untouched, as is the icu_provider crate the permissions inspector's lock file names. - ClientFixedProvider is ClientFixedModule, CLIENT_FIXED_PROVIDER_KEY is CLIENT_FIXED_MODULE_KEY, and check_named_provider_configuration is check_named_module_configuration. - The test doubles ResolveHeaderProvider and TestIdProvider are ResolveHeaderModule and TestIdModule, HEADER_PROVIDER_ID is HEADER_MODULE_ID, and the test helpers and test names say module. - The plain word says module wherever it meant an Edge Cookie module, in the resolve endpoint, the cookie and finalize code, the integration registry, the demonstration page script, the cargo feature's description and the API reference.
resolve_conflicts_when_a_different_identity_already_exists built its context by hand with no module, so once the endpoint stopped rebuilding one it answered 204 for no module where the test expects the 409 conflict. The context now carries the module the settings select, as the other resolve tests' contexts do.
The merge of the permission model into this branch settled the inspector's `Cargo.lock` through the same rename that turned provider into module in the code, and the crate `icu_provider` became `icu_module`, a package that does not exist. The inspector still built, because its build script runs cargo without `--locked` and cargo re-resolved the crate from the registry each time, so the fault was invisible to CI and visible to anyone reading the file. The three lines name the crate again, and `cargo check --locked` accepts the lockfile as written.
A module's name is the path of its crate below `crates/`, with `.` between the parts, taken from `CARGO_MANIFEST_DIR` when the crate is built, so the name cannot drift from the folder and no crate carries a hand-written id. `module_name!()` gives a crate its own name, `module_name::resolve` finds the name an operator wrote among the modules offered, as written or with the section's type folder in front, and `module_name::short_form` gives the name back without that folder. Core's own modules keep bare names. The Edge Cookie side is the first to use it. The type folder of every Edge Cookie crate is `edgecookie`, so `[ec] module` may name an injected module in full or with that folder left off, and an `implementation` line may do the same. The rule for a name written in `[ec]`, its blocks and their `implementation` lines is the module name rule, parts joined by `.`, each of lower case letters, digits, `_` or `-`, in place of snake_case.
Brings the naming module, so a module is named by its crate folder, and the Edge Cookie side resolving a written name within its type folder. The host-signal module's key and the type folder sit side by side.
Brings the naming module from the Edge Cookie seam, so a module is named by its crate folder.
Each of the five modules takes its name from its crate folder through `module_name!()`, so `crates/permission-signal/gpp` is `gpp` and `crates/permission-signal/us-privacy` is `us-privacy`, where the two had called themselves `gpp_sale_opt_out` and `us_privacy` by hand. The page is told a signal's source by the short name, without the type folder. The section is `[permission-signal]`, named exactly as its folder, and its key is `modules`, a list, because signals compose where `[ec]`, `[geo]` and `[device]` each name one module. A name in the list may be written in full, as `permission-signal.gpp`, or with the type folder left off, and the refusal of a name this build does not offer lists the short names. A configuration still carrying `[permission_signal]`, a `module` key or the retired `sources` key is refused with directions to the new spelling.
Brings the naming module and the permission signal modules named by their crate folders, with [permission-signal] modules. The Edge Cookie module file keeps the client_fixed key beside the type folder, and the agent guide's capability table keeps this branch's rows with the permission signal selector as [permission-signal] modules.
This was referenced Oct 7, 2026
Comments on the adapters and two test notes in core described what the code did before it changed. Each now says what the code does and why. - The resolved Edge Cookie module held on the Cloudflare, Fastly and Spin application state is resolved when the state is built, because resolving reads no request data. - The three tests that a selected module the adapter cannot build fails the request say what a default context would do. - The Spin state builder reads its settings from the config store, and its test says why the failure has to be the absence of a config store. - The test that keeps the internal header list in step with the Edge Cookie response headers, and the test for an unknown key in a module block, say what they guard. No code changes.
- The Cloudflare region test says why the region header has to reach the privacy outcome. - The Fastly geo module's re-export and the geo selector's default are described as they are. - The integration fixture's note on the geo selector no longer promises a setting that a later change does not add. No code changes.
- The integration fixture carried two notes on its geo selector that contradicted each other. The one that stays says that Viceroy maps the loopback client to US/CA, so the platform geo module resolves a real place. - That fixture, the Viceroy template, the template cache harness and the permission model guide no longer mention a default country setting, which no released version had. - Five code comments say what the code does without saying what it did before. No code changes.
Core builds the identity-graph key from the module's code and whatever `normalize_id_for_kv` returns, so the method decides what two visits must have in common to be treated as the same visitor. Its documentation described normalization only and advised a module with a case-sensitive identifier to return the value unchanged. That is right for a stable identifier and wrong for one that carries a signature, a nonce or a timestamp, because each reissue then gets a row of its own and the identity does not survive it. The trait documentation now says that the returned value is the identity two visits must share, and that a module whose identifier has a part reissued each time must return the stable part. It also says that core asks `accepts_id` about the returned string as well as about the identifier as issued, so a module must accept its own canonical form, or no row is read or written for it. `two_issues_of_one_identity_share_one_identity_graph_key` drives a fixture module whose identifier is a stable part and a part that changes on each issue through `AcceptedModules::canonical_kv_key`. Two identifiers that differ only in the reissued part reach one key, and a different stable part reaches another. The test fails for a module that returns the value unchanged. No behavior changes.
…ords `KvNetwork` said a low cluster count "indicates an individual or household". A count of connections is a fact about a network, and reading a low one as an individual describes a device record as a record about a person. It now says a small network, such as a home connection. `KvEntry::ids` now says what it holds, which is each partner's identifier for the same browser on the same device, never an identifier for a person. Comments only, with no change in behavior.
This branch has not been deployed
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 the permission model (#1045), and depends on it because the resolve
endpoint applies the same permission gate as organic generation. Through the
stack this pull request also depends on the Edge Cookie module seam (#1043),
whose
EdgeCookieModuletrait gains the client resolve method here. Thestack has six pull requests (#1043, #1044, #1045, #1046, #1047, #1094), each
targeting
main, with this one fourth, and the first five decompose #838 asrequested in the #986 review.
Compare
split/3-permissionswithsplit/4-client-resolveto 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-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 module establishes the identifier through a browser round
trip. The page script obtains or derives a value, which for a real vendor is a
signed envelope from that vendor's identity system, posts it to
POST /_ts/api/v1/ec/resolve, and the module verifies that value before theedge sets it as the Edge Cookie. The vendor Edge Cookie module proposed in the
51Degrees module issue (#1072) works client-side by design, so this endpoint is
on the series' critical path rather than deferred.
What this pull request does
EdgeCookieModulegainsresolve_from_clientwith a default that createsnothing, so server-side modules are untouched.
own. The permission gate applies unchanged, being the module's complete
declaration rather than a hard-coded storage check, and the module's
response headers pass the same reserved-surface check before any is kept. The
request must carry an
Originon the publisher's domain (403 otherwise) anda
text/plainorapplication/jsonbody (415 otherwise, 413 over 64 KiB,refused from the advertised
Content-Lengthbefore the body is read). Acreated identifier must fit the global identifier bounds (400) and must not
silently replace a different identity already on the request (409).
when the page was served, so a module an adapter threads into the request
is the one that verifies the posted value.
set, keyed by the module's canonical form, so withdrawal reaches a
client-set identity the same way it reaches an edge-created one. With no
graph available the endpoint sets no cookie at all, which is stricter than
organic generation, where the identifier is kept and only the row write is
skipped. A graph write failure answers 503 with no cookie. A closed
permission gate, no configured module, no graph, or a module that creates
nothing answers 204 with no Edge Cookie. The module's own response headers
go out only with the 200 that sets the cookie, or with the 204 when the
module creates nothing. Every response the handler builds carries
Cache-Control: no-store, and a module or configuration error goes to theadapter'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, and when the request carries an Edge Cookie
the selected module does not own, so a switch between client-cycle modules
resolves again. A Rust test checks that the marker name and the
demonstration's fixed word match the page-script source.
[ec] resolve_allowed_originslists the extra origins the endpoint acceptsbeyond the publisher's own, each a bare origin, checked when the settings
load.
client_fixeddemonstration module is compiled only behind theclient-fixed-democargo feature, and production builds reject the selectionat startup. A fixed shared word is not an identity. The demonstration
module's cookie carries the registry code (
cfix~an-ec), so evendemonstration identities are module-namespaced. The cargo feature keeps its
own spelling, because a build feature is not something a deployment writes
in
trusted-server.toml.as generation. The other adapters deliberately do not route the endpoint yet,
matching
identifyandbatch-sync, which need the same platform key-valuewiring those adapters lack. The Spin adapter's route list records it.
v1. The reservation design needs the real vendor envelope's unique id and
session binding, and the spec keeps the design verbatim as the bar for that
implementation.
How it was verified
The head this pull request shows now is
e7623de78, which merges #1045 at0afd715c5and, through it,mainat 182fdf4 into59660c468. The merge brings thets dev lint domainsandts dev install-hookscommands (#733) and a design document (#930), and changes none of this pull request's own code. No dependency of core or of an adapter changed version, and the one line of core the merge touches gains a comment. One7623de78these ran locally on Windows:cargo fmt --all --check, a locked dependency check (cargo tree --locked) and the core suite natively (3,140 tests).59660c468is two merges of #1045 above9a3772871, which bring comment and guide wording, the identity graph'scomments, one doc comment and one test. On
59660c468these ran locally on Windows:cargo fmt --all --check,Clippy with warnings denied on all four adapters and the five permission
signal crates, the core suite natively (3,140 tests), the five permission
signal crates' own tests (55 tests), and the docs lint, Prettier and VitePress
build. On
9a3772871the wider set ran locally, being the core suite underViceroy (3,377 across the test binaries the Fastly alias runs, the Fastly
adapter's included), the Axum (123 tests, the five permission signal crates
included), Cloudflare (56) and Spin (90) adapter suites, the cross-adapter
parity suite (17) and the permissions inspector's wasm build, and CI runs
all of them on this head. The CLI tests, the browser integration tests and
the Fastly Edge Cookie lifecycle test run only in CI.
CI on
e7623de78: every check passes, being Run Tests, Run Format, Integration Tests, Permissions Inspector and CodeQL Advanced, with the Fastly Edge Cookie lifecycle test in the integration run. Run Tests passed on its second attempt. The first failed in one test,collects_gpt_slot_from_local_fixture, on a browser launch timeout, which #1256 describes.Endpoint tests in
crates/trusted-server-core/src/ec/resolve.rscover the origin, content type, advertised and actual body size, identifier
bounds, conflict, no-graph, failed graph write, marker and threaded-module
behaviors, and
client_set_value_round_trips_through_the_ec_scenariodrives aclient identifier through deferral, resolve, cookie set and verbatim
read-back.
References #778. Decomposes #838. Spec baseline from #986.