Add the client-set Edge Cookie value path - #1046
Open
jwrosewell wants to merge 8 commits into
Open
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
…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.
jwrosewell
force-pushed
the
split/4-client-resolve
branch
3 times, most recently
from
August 27, 2026 05:37
e916594 to
8a49f16
Compare
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.
jwrosewell
force-pushed
the
split/4-client-resolve
branch
from
August 27, 2026 15:10
8a49f16 to
a51f6fc
Compare
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.
…e provider Second slice of the PR 838 decomposition. Device classification and geolocation become selectable providers, mirroring the Edge Cookie provider seam: - [device] provider selects the classifier. The built-in default reads the User-Agent alone and makes no host call; the opt-in fastly provider strengthens the browser/bot gate with the host's TLS JA4 and HTTP/2 signals (crates/device/fastly). - [geo] provider selects geolocation. The host platform's lookup is the default, matching the behavior before the selector existed, and provider = "platform" spells the same choice explicitly (crates/geo/fastly wraps the Fastly host lookup behind the PlatformGeo trait). provider = "none" opts out entirely, so a client IP is never sent to any host geo service. The disabled-by-default flip ships with the permission model in the next slice, which adds the jurisdiction baseline that makes a no-geo deployment viable. - Every adapter routes its host geo through the same build_geo_provider selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider identically, so the selector is not a Fastly-only behavior. - The provider configuration sections ([device], [geo], [ec.providers.hmac], [ec.providers.host-signals]) reject unknown keys at startup, so a mistyped key fails loudly instead of silently selecting a default. - The host-signal Edge Cookie provider arrives with the capability it needs: the Fastly adapter injects the TLS/HTTP-2 signals as a HostSignals service, and the provider mints from them plus the client IP. With no host signals at all it defers with a warning rather than degrading to an IP-only identifier. - Device signals move to a field-based DeviceSignals derived in the adapter (derive_ua_only for hosts without host signals). - The new crates join the fastly cargo aliases so they build, lint, and test in CI rather than compiling only transitively.
Third slice of the PR 838 decomposition. Permissions become the primitive that gates identity features; consent is one of several ways a permission is established: - permissions.rs resolves a per-request PermissionState from the country/region baseline in permissions.yaml augmented by the session's signals (TCF, GPP, GPC, US Privacy). Permission names follow the Privacy Taxonomy Data Uses. - Signal precedence is fixed in code, most restrictive first: a US-style opt-out (GPC, GPP sale opt-out, US Privacy) suppresses the Data Uses the policy revokes even when a TCF record consents, a present but undecodable record blocks baseline grants (fail-closed), and only then does a TCF record decide its mapped Data Uses. The yaml authoritative flag governs the TCF record's own grants and revokes, never whether an opt-out can be overridden. Pinning tests cover each opt-out source against a consenting TCF record. - Destructive withdrawal is narrow: only a TCF record refusing storage in a jurisdiction whose baseline did not grant it expires the cookie and writes the identity-graph tombstone. Opt-outs suppress use (headers stripped, nothing egressed) but never destroy an issued identifier, so lifting an opt-out restores the identity. - Sharing beyond the edge requires storage plus personalised-ad selection, the same pair that gates bidstream EIDs, at every egress: the auction endpoint's user.id, the publisher navigation and page-bids auction requests, the identify response, and partner pull-sync. A storage-only grant keeps first-party use while withholding partner sharing. - The Edge Cookie gate moves from raw consent to the permission model: a provider declares required_permissions() and core executes it only when every one is set. - [geo] default_country becomes required: it names the permissions.yaml rule that applies when the geo provider leaves a request unmatched. A failed geo lookup is distinct: it resolves at the requires-signal floor instead of the default, and is logged at error level. The lookup moves into EcContext::read_from_request_resolving_geo so every adapter reports the distinction identically. - Geolocation is now off by default ([geo] provider unset resolves no location); the host lookup is opt-in via provider = "platform". A deployment that runs an Edge Cookie provider with no geo provider must set [geo] assume_single_jurisdiction = true, acknowledging that every request is treated as the default jurisdiction. - permissions.yaml rules use an explicit per-permission acquisition map (granted / requires_signal / denied) instead of +/- sigils, unknown keys in a detailed rule are rejected, and two rule keys naming the same location in different case are rejected at parse. - The consent module keeps building the ConsentContext; its EC-specific gating helpers move behind the permission model. An EU-27 plus EEA coverage test locks the gdpr-eu mapping. - The design spec for this slice lives at docs/superpowers/specs/2026-07-30-permission-model-design.md, the 2026-07-31 draft revised to match this implementation with a revision-record table of every divergence.
The permissions module doc said device and geo providers are refused when their required permissions are not set; only the Edge Cookie provider is gated today, and the built-in device and geo providers require none. It also said only two purposes are resolved against signals, while every TCF purpose is. An intra-doc link pointed at a function that does not exist. The spec and guide now say that publisher navigation and page-bids user.id ride the sharing pair, that a malformed permissions.yaml fails at settings load, that there are three opt-out-over-TCF pinning tests, that a US-style opt-out revokes whether or not a TCF record is present, and that the US has one country rule.
Fourth slice of the PR 838 decomposition. A client-cycle Edge Cookie provider defers at the edge and lets the page derive the identifier in the browser; the page posts it to POST /_ts/api/v1/ec/resolve and the provider verifies it before the edge mints it as the Edge Cookie: - EdgeCookieProvider gains resolve_from_client with a no-op default, so server-side providers are untouched. ClientResolveInput carries the posted payload plus the request's resolved permissions and consent. - The resolve endpoint enforces the same rules as organic generation and several of its own. The permission gate applies unchanged. The request must carry an Origin on the publisher's domain (an identity-setting POST must not be drivable from a foreign page) and a text/plain or application/json body. A minted identifier must fit the identifier bounds (400), must not silently replace a different identity already on the request (409), and is persisted to the identity graph before the cookie is set, keyed by the provider's canonical form, so withdrawal reaches a client-set identity the same way it reaches an edge-minted one. With no graph available nothing is minted, matching the organic rule against phantom cookies; a graph write failure is 503. Every response carries Cache-Control: no-store. - The Edge Cookie stays HttpOnly. A non-HttpOnly companion marker cookie (ts-ecr=1, no identity content) tells the page script a resolve succeeded, so it does not re-post on every page view; the marker expires together with the Edge Cookie on withdrawal. A Rust test asserts the marker name and the demo's fixed word stay in sync with the page script source. - The client-fixed demonstration provider (fixed shared word, verify-before-mint) is compiled only behind the trusted-server-core client-fixed-demo cargo feature. Production builds reject the selection at startup: a fixed shared word is not an identity. - The Fastly adapter routes the endpoint and passes the same bot-gated identity graph as generation, so unrecognized clients cannot mint through resolve either. The other adapters deliberately do not route it yet, matching identify and batch-sync, which need the same platform KV wiring those adapters lack. The design spec for this slice lives at docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md, the 2026-07-31 threat-model draft revised to the implemented state, with the deferred reservation design retained verbatim as the bar for the first vendor scheme.
The spec used em dashes throughout; they are replaced with plain punctuation. The resolve handler's doc claimed every response carries Cache-Control: no-store, but a provider or configuration error leaves through the error path to the adapter's own response, so the doc now says every response the handler builds.
jwrosewell
force-pushed
the
split/4-client-resolve
branch
from
August 27, 2026 15:33
a51f6fc to
8a67a9f
Compare
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.
Fourth of five stacked PRs decomposing #838 as requested in the #986 review. Stacks on #1045. Compare
split/4-client-resolvetosplit/3-permissionsto see only this PR's change.Spec: docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md, the Tech Lab 2026-07-31 threat-model draft revised to the implemented state. The review feedback on the earlier draft of this endpoint is addressed point by point in this PR, and the spec's revision-record table 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 via 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 it before the edge mints it as the Edge Cookie. The first vendor integration this project targets works client-side by design, so this endpoint is on the series' critical path rather than deferred.What this PR does
EdgeCookieProvidergainsresolve_from_clientwith a no-op default, so server-side providers are untouched.Originon the publisher's domain (403 otherwise), and atext/plainorapplication/jsonbody (415 otherwise, 413 over 64 KiB). A minted 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; a provider or configuration error propagates to the adapter as an error response.ts-ecr=1, no identity content) tells the page script a resolve succeeded, fixing the earlier defect where the script gated on a cookie it could never read and therefore posted on every page view. The marker expires together with the Edge Cookie on withdrawal. A Rust test asserts the marker name and the demo's fixed word stay in sync with 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. Its minted 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
Full local gate on this branch, all clean, including new endpoint tests for the origin, content-type, body-size, conflict, no-graph, and marker behaviors (the identifier-bound
400and graph-write503paths are covered by unit tests of their helpers, not endpoint tests), and a round trip driving a client identifier through deferral, resolve, cookie set, and verbatim read-back.cargo test-fastly,cargo test-axum,cargo test-cloudflare,cargo test-spin, the integration parity suite, JS tests (npx vitest run),cargo fmt --check, and all six per-target clippy aliases.References #778. Decomposes #838. Spec baseline from #986.
Produced with AI assistance under James Rosewell's direction, and flagged here so reviewers know to apply the usual scrutiny.