Repository navigation
Add device and geo module selection with the host-signal Edge Cookie module - #1044
Open
jwrosewell wants to merge 85 commits into
Open
jwrosewell wants to merge 85 commits into
jwrosewell wants to merge 85 commits into
Conversation
This was referenced Aug 19, 2026
jwrosewell
force-pushed
the
split/2-device-geo
branch
3 times, most recently
from
August 25, 2026 10:51
849954b to
d9271cf
Compare
jwrosewell
force-pushed
the
split/2-device-geo
branch
4 times, most recently
from
August 27, 2026 15:33
9c3598e to
c931bc8
Compare
jwrosewell
added a commit
to jwrosewell/trusted-server
that referenced
this pull request
Aug 31, 2026
`is_valid_ec_id` is the built-in HMAC grammar and rejects every other provider code, yet pull sync, batch sync, and the admin lookup all called it directly. A deployment running a non-HMAC provider therefore minted and read identifiers on the organic path that these three paths skipped or rejected. PR IABTechLab#1044's `hs00~` host-signal provider makes that concrete. The check is now split in two, in `AcceptedProviders` in `ec/provider.rs`. The global cookie bounds, the length cap and the cookie-safe alphabet in `ec_id_has_only_allowed_chars`, apply to every identifier whoever minted it. The rest is dispatched by the `{code}~` prefix to the provider that owns that code, which canonicalizes its own value part and decides whether the canonical form is one of its own. Dispatch is on the code alone, before any provider inspects a value, so an identifier a partner echoed back in a different case still reaches its own provider to be canonicalized rather than being rejected first. KV normalization goes the same way through `canonical_kv_key`, so a row is always keyed by the owning provider's canonical form. A code no configured provider reads is rejected. The set of accepted providers is the deployment's active provider. `legacy_providers`, the design's list of readers that never mint, is not implemented on this branch (the key is rejected as unknown, see section 6.1 of the pluggable-providers design), so `AcceptedProviders::active` fills the reader list with the one active provider. The list is the seam: configured legacy readers are pushed alongside it and neither `accepts` nor `canonical_kv_key` changes. With no provider selected at all the deployment is stateless, and the built-in grammar stays the fallback, matching what `EcContext::accepts_id` has always done. Wiring: `EcContext::accepts_id` now goes through `AcceptedProviders`, so pull sync validates through it; `handle_batch_sync` and `handle_admin_ec_lookup` take the selected provider, which the Fastly adapter builds at both call sites. Tests cover a non-HMAC identifier accepted in pull sync, batch sync, and the admin lookup; a code neither active nor configured rejected in batch sync and the admin lookup, including one in the built-in HMAC shape; KV normalization dispatched to the owning provider (the built-in lowercases its hash segment, an opaque provider keys verbatim); and the global bounds rejecting before any provider is consulted. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/generation.rs:207 (P2)
jwrosewell
force-pushed
the
split/2-device-geo
branch
from
August 31, 2026 12:50
c931bc8 to
1371127
Compare
This was referenced Aug 31, 2026
Collaborator
jwrosewell
force-pushed
the
split/2-device-geo
branch
from
September 1, 2026 15:34
1371127 to
e99de21
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 provider spec (section 6) says `deny_unknown_fields` is set on both built-in provider config structs, but `HmacProviderConfig` carried no such attribute, so `[ec.providers.hmac] typo_key = "x"` was accepted silently. An operator who mistypes a key gets a deployment that starts and quietly uses the default for the setting they meant to change. `HmacProviderConfig` now sets `#[serde(deny_unknown_fields)]`, matching `Ec` itself and the rest of the settings tree. The struct is a plain field of `EcProviders` rather than a flattened one, so the attribute does not collide with the `#[serde(flatten)]` vendor map alongside it. Tests: `an_unknown_key_in_the_hmac_provider_block_is_rejected` adds an unknown key to the block in the crate test configuration and proves `Settings::from_toml` fails and names the key. Removing the attribute makes that test fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:726 (wrench)
`build_provider`'s `"hmac"` arm mapped over `ec.providers.hmac`, so a deployment that selected `provider = "hmac"` with no `[ec.providers.hmac]` block got `Ok(None)` and ran stateless under a selector that says it has an identity provider. Every other unbuildable selection in the same match already errors. The arm now returns `TrustedServerError::EdgeCookie` naming the missing block, which the startup check `ensure_provider_available` turns into a failed application state on every adapter. `Ec::validate_provider_selection` rejects that pair before settings reach the composition root, so nothing routes through the new arm today. It is the drift guard for the case where the two checks stop agreeing, which is exactly the shape of the defect being fixed, so it is worth keeping rather than leaving the silent branch in place. Tests: `selecting_hmac_without_its_block_fails_loudly` builds the `Ec` programmatically, bypassing settings validation to reach the seam, and proves the error names the missing block. The doc comment's `# Errors` section is corrected in the same commit, since it still claimed no built-in construction can fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:304 (refactor)
The error raised when a provider mints an identifier outside the identifier bounds was written across two source lines without the trailing backslash that joins them, so the 22 spaces of source indentation became part of the literal and the logged message read "...bytes, or outside the cookie-safe alphabet". The continuation is restored, so the message reads as one sentence. The whole of ec/mod.rs was scanned for the same fault, matching every string literal and stripping real continuations before looking for runs of more than one space or a newline inside a literal. This message was the only one. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:444 (nitpick)
The paragraph written for the `EdgeCookieProvider` trait sat at the top of `ProviderCode`'s doc block, so rustdoc rendered it as part of that struct's documentation and the trait itself had no doc comment at all. A vendor implementer opening the trait saw nothing, and a reader of `ProviderCode` saw two subjects run together. The paragraph moves onto the trait and `ProviderCode` keeps only the registry text that belongs to it. The moved sentence was also stale: it said a provider returns `Ok(None)` from `generate`, but `generate` returns a `GeneratedEdgeCookie` and signals "no identifier this request" through its `id` field. The sentence now describes the actual return, with an intra-doc link to the field. `cargo doc --no-deps` reports no warning against either item. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:177 (nitpick)
`ec::get_ec_id` had no callers anywhere in the workspace, and this
branch loosened its filter to accept any well-formed `{code}~` value
with no ownership check against the selected provider. A future caller
picking it up would adopt another provider's identifiers, which
`EcContext` deliberately treats as absent.
The no-callers claim was checked across every crate in the workspace
(the four adapters, the CLI, core, the integration tests, openrtb) plus
benches, tests and docs. The only matches are for a different,
crate-private `edge_cookie::get_ec_id`, which reads the `x-ts-ec` header
as well as the cookie and is what `proxy.rs` and the testlight
integration call.
Deleted rather than realigned, for two reasons. The workspace sets
`publish = false`, so `trusted-server-core` is not distributed and
nothing outside this repository depends on the symbol. And aligning the
filter would mean calling `provider_owns_id`, which needs a
`&dyn EdgeCookieProvider` that a function taking only `&Request` cannot
obtain, so it would have meant changing the signature of a function with
no callers. `EcContext::read_from_request` already performs the
provider-aware read that production uses.
`parse_ec_from_request`, `is_valid_ec_id` and `log_id` all keep other
callers in the module, so nothing else becomes dead. The core README
line that advertised the helper is removed in the same commit.
Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:137 (nitpick)
The `ec/provider.rs` module doc said a provider's constructor takes the services it needs, naming `RequestInfo` as the example, and its opening sentence was garbled where two half-sentences had been spliced together. `RequestInfo` is not a constructor argument. It is borrowed per call as the `request_info` parameter of `EdgeCookieProvider::generate`, so the first thing a vendor implementer read contradicted the trait they were about to implement. `evidence.rs` carried the same claim in its own words, that a constructor takes services as `Arc<dyn Trait>` supplied per request. Nothing in the workspace passes `RequestInfo` that way. Every use site is a `&dyn RequestInfo` argument. Both module docs now describe the real shape, which is construction once at startup from configuration or adapter injection, then borrowed request evidence on every call with nothing retained. The `evidence.rs` title changes to match, and its pointer to the borrowed view `BorrowedRequestInfo` is named alongside `OwnedRequestInfo`. Documentation only, no behavior change. `cargo doc --no-deps` reports no warning against either module. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:4 (nitpick)
The keys `"hmac"` and `"none"` were spelled as bare string literals at
four places: `Ec::validate_provider_selection`, `build_provider`,
`provider_owns_id`'s `provider.id() == "hmac"` check, and a private
`HMAC_PROVIDER_CODE` in `ec/generation.rs`. Nothing tied them together,
so a fifth built-in provider would add a fifth spelling and a typo in
any one of them would compile.
`EcProviderSelection { None, Hmac, Vendor(String) }` now holds the
vocabulary in `ec/provider.rs`, with `NONE_KEY` and `HMAC_KEY` as the
only places those two words are written. Vendor keys are open-ended, so
the catch-all `Vendor` variant takes any other key and
`#[serde(from = "String", into = "String")]` gives the enum an
infallible conversion in each direction rather than a hand-written
visitor. `HMAC_PROVIDER_CODE` moves next to it as a `ProviderCode`
const, built from `HMAC_KEY`, and `generation.rs` uses that instead of
its own copy. `HmacProvider::id` and `HmacProvider::code` return the
same two constants.
`Ec::provider` becomes `Option<EcProviderSelection>`, so the two
validation paths and `build_provider` match on variants rather than
comparing strings, and `Option` still distinguishes an absent selector
from an explicit `"none"` exactly as before.
The configuration surface is unchanged. The selector reads and writes
the same string, so an existing `trusted-server.toml` parses to the same
choice and a config push writes the same key back.
Tests: `the_selector_round_trips_through_serialization` parses `none`,
`hmac` and an arbitrary vendor key from TOML, checks each maps to its
variant, and checks each serializes back to the same string.
`each_selection_builds_what_its_string_key_built_before` proves the
three selections still build what they built before, which is nothing
for `none`, the built-in provider with the built-in code for `hmac`, and
the adapter-injected provider of that id for a vendor key.
Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs (refactor)
A provider's response headers were inserted into the outbound response without any check on what they set. A provider could return `Set-Cookie: ts-ec=...`, including on a request where it minted no identifier at all, and so write the managed identity cookie without going through core's identifier validation or its requirement that a minted identifier have an identity-graph row. It could also overwrite an `x-ts-*` header or a framing header. Core now defends by reserving its own namespace rather than banning `Set-Cookie`, because providers legitimately need cookies of their own. `reserved_response_effect` in `ec/provider.rs` classifies one header and rejects three things: a `Set-Cookie` naming a cookie in the `ts-` prefix core manages (`ts-ec`, `ts-eids`, `ts-tester`), a header in the `x-ts-` namespace core emits and strips, and a message framing or hop-by-hop header (RFC 7230 6.1 plus `content-length`, the same set each adapter's `is_hop_by_hop_response_header` uses). Everything else, a provider's own cookie included, passes through unchanged. The cookie name is read from the raw header bytes so a value that is not valid UTF-8 cannot smuggle a managed name past the check. A rejected effect fails the request rather than being dropped with a log. The check sits in `EcContext::generate_with_provider`, the only place provider headers are captured, next to the identifier-bounds check that already fails the request when a provider mints outside the cookie-safe alphabet. Both are the same kind of fault, a provider breaking its contract, and this branch has already decided that identity problems stop the request rather than serving without identity. Finalization cannot fail a request in any case, since it returns no result. Tests cover the classifier directly (managed cookie, reserved header, framing header, a non-UTF-8 `Set-Cookie`, and the allowed cases), and cover both halves through the organic generate path: a provider setting `ts-ec` with no identifier fails the request, and a provider setting its own `acme-evidence` cookie mints normally and has that cookie reach the response alongside core's own `ts-ec`. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/finalize.rs:57 (P2)
Brings in the eight commits main gained since this branch was cut, of which three touch the same code as the provider seam. The parser-aware body hold gives every adapter a second entry point, `build_state_with_services`, so a caller can supply the `RuntimeServices` each request uses. The composition-root check the seam added now runs in that function rather than in `build_state_with_settings`, and it is given whatever provider those supplied services already carry instead of always `None`, so a caller that resolved one is not made to resolve it twice. On Cloudflare and Spin the state keeps both the provider this branch resolves once at start-up and the services main lets a caller supply. The free function this branch added is gone and its one job, handing the resolved provider to every request, moved into `services_for_request`, which is the method main introduced for the same purpose. The core README this branch corrected one line of has been rewritten wholesale by the documentation refresh, and the section that line was in no longer exists, so main's version is taken as it stands.
Carries up the resolutions from split/1, where main's second entry point, `build_state_with_services`, meets the composition-root provider check. This branch had already given that check and `build_reusable_provider` a `host_signals` argument, so each call now passes `None` for host signals and whatever provider a caller's supplied services already carry. The free function that handed the resolved provider to every request is gone on both adapters and its job sits in `services_for_request`, the method main added, which passes the settings this branch made `build_runtime_services` take.
The per-request Edge Cookie test builds AppState by hand, and the merge left it without the field the supplied-services path added. It drives the per-request path, so it supplies none.
Takes the identity graph changes merged upstream on 28 September (conditional writes, tombstones from a snapshot, grouped batch sync, the pull sync marker and the EID sync source) and the reusable sandbox in the Fastly entry point, and keeps the provider seam on top of them. Conflicts, settled file by file: - crates/trusted-server-adapter-fastly/src/app.rs: upstream's sandbox lifecycle. The seam's settings_with_missing_consent_store test helper had no caller after the merge and is dropped. - crates/trusted-server-core/src/ec/batch_sync.rs: mappings are validated inside upstream's grouping loop, keyed by the owning provider's canonical form through accepted_providers.canonical_kv_key. The seam's three provider tests stay, followed by upstream's renamed fan-out test. - crates/trusted-server-core/src/ec/pull_sync.rs: build_pull_sync_context keeps the provider dispatch for the identifier and its key, then applies upstream's gate (pull-enabled partners, a consented row still missing a partner UID). PullSyncContext carries the key, and dispatch returns early with no pull partners. - crates/trusted-server-core/src/ec/finalize.rs: provider response headers are applied first, then the marker is validated and the gate read. The returning-user, generated and recovery paths key the snapshot, EID ingestion and sync by the provider's canonical key, and withdrawal tombstones each provider-derived key through tombstone_existing_from_snapshot, warning on a failed tombstone. Upstream's new code and tests assumed ec.passphrase and helpers that take no provider, which is not this branch's shape: - The pull sync marker key derives from the selected provider's HMAC passphrase, or from the proxy secret for a provider that has none, since ec.passphrase is only the deprecated location here. - Upstream's tests pass the accepted providers to process_mappings, the provider to handle_batch_sync_with_writer, the gate flag to the finalize context helpers, and a registry with a pull partner plus a seeded snapshot to build_pull_sync_context. Tests that set ec.passphrase select the hmac block instead, and the redaction canary reads ec.hmac.passphrase. The seam's EID ingestion test opens upstream's new EID sync source gate, as upstream's own returning-user tests do.
EcContext captured the request headers, path and query only when no usable identifier arrived, while orphan recovery runs only when one did. So a provider asked for a replacement identifier during recovery saw a request with no headers, although the docs said recovery reads the evidence captured at read time. A provider that derives identity from client hints then ran with no client hints on every recovery attempt. The snapshot is now also captured for a document navigation, the only request that can recover, so a returning visitor's subresource requests still clone nothing. The field doc says when it is captured. Test recovery_on_a_navigation_passes_request_parameters_and_cookies_to_the_provider fails without the change.
a_provider_reads_request_cookies_from_the_request_info built its own OwnedRequestInfo with a Cookie header and called a test double's generate with it, so it tested OwnedRequestInfo::header and nothing in core. The test and its CookieCapturingProvider double are gone, and the tests module no longer imports OwnedRequestInfo. The recovery test added in the previous commit covers a request's cookies reaching a provider through the request path.
remove_labeled_provider_secret_errors strips the error that judged a labeled block's secret key name as a passphrase. Nothing checked that it strips only that one, so it could drop the Edge Cookie section's other errors and no test would fail. push_validation_keeps_the_sections_other_errors_for_a_labeled_block selects [ec] provider = "primary" with a primary block for hmac holding the key name ec_key, adds a partner whose source domain carries a scheme, and expects the section's errors to hold the partner error and no passphrase error. They are read from the section rather than from the message, because deploy validation reports the same bad partner.
Comments and docs say what the code does. History and plans come out of them: the legacy reader retirement plan on provider_owns_id, the plan that the built-in provider becomes a module, the reader list seam on AcceptedProviders, the design-document references, the pull request narrative around the test doubles, and the "still built into core" wording. The header-appending rule on apply_provider_response_headers is stated in three sentences, the resolution docs say what resolves where, and edge_cookie.rs says it reads an inbound identifier and that its generation helpers are test-only. The ec module summary lists admin, finalize and provider. Tests that could not fail now can, and repeated tests are folded into tables. One OpaqueProvider double in ec::tests serves the admin lookup, batch sync and pull sync tests in place of four copies. The batch sync canonicalization test asserts the key the writer was given. The request path reuse test drives a built-in selection through an unthreaded services value, since the threaded helper already threads the provider. The uninjected provider test covers build_provider and the startup check together. The reserved response effect tests are one table over each header with and without an identifier, and the three discarded-candidate tests are one table. The withdrawal key cases and the HMAC grammar cases are tables. The consent gate test has an open-gate control and a backed context, so only the gate can withhold the cookie. New tests: a provider that creates nothing still has its headers applied; an orphan recovery that cannot rotate leaves a failed snapshot and no cookie, for a provider with no client IP, a store whose write fails and a replacement that always collides; an orphan rotation applies the replacement provider's response headers; both RequestInfo views list their header names; and the request path hashes an IPv6 client by its /64 prefix, which replaces the edge_cookie test that compared two test helpers. Tests say create where they said mint. noop_services_with_resolved_ec_provider duplicated noop_services_with_ec_provider and is gone.
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 Edge Cookie identity seam this series adds, and only the words it adds: every provider word was compared with upstream main, and a word that exists there, in the auction code or the EID extensions, is untouched. The retired [ec.providers] table keeps its literal in the refusal that names its replacement. - ec/provider.rs is ec/module.rs, so ec::provider is ec::module. - EdgeCookieProvider is EdgeCookieModule and HmacProvider is HmacModule. - EcProviderSelection, EcProviderBlock, EcProviderBlocks and EcProviderSettings are EcModuleSelection, EcModuleBlock, EcModuleBlocks and EcModuleSettings, HmacProviderConfig is HmacModuleConfig, and Ec's provider and provider_blocks fields are module and module_blocks, so the selector reads [ec] module and the environment override is TRUSTED_SERVER__EC__MODULE. - ProviderCode and the provider_code! macro are ModuleCode and module_code!, and HMAC_PROVIDER_KEY, BUILTIN_PROVIDER_KEYS, HMAC_PROVIDER_CODE, PROVIDER_CODE_SEPARATOR and PROVIDER_IMPLEMENTATION_KEY say MODULE. - build_provider, build_shared_provider, request_provider, provider_owns_id, apply_provider_code, provider_kv_key, apply_provider_response_headers, ensure_provider_available, split_provider_code and resolve_named_provider say module, as do AcceptedProviders and accepted_providers, selected_provider, validate_provider_selection and validate_provider_name, and remove_labeled_provider_secret_errors. - RuntimeServices' resolved_ec_provider and with_resolved_ec_provider, on the services and their builder, say module. - Test helpers, test doubles and test names in the Edge Cookie code say module. - The plain word, where it means an Edge Cookie module, in the Edge Cookie code, settings.rs, config.rs, config_payload.rs, test_support.rs, proxy.rs, testlight.rs, the adapters' entry points, the example settings, the integration fixture, the API reference and the edgecookie crate directory's README.
Brings the refreshed Edge Cookie seam, and through it upstream main at 7a0ecb4, into the device and geo branch. The seam's names say module, so every line here that used one of its names says module too, and the conflicts settled as that rename applied to this branch's own text: - The Fastly entry point keeps this branch's device and geo wiring with upstream's cache directive helper and PlatformCacheIntent, and drops the RuntimeServices and PlatformKvStore imports upstream no longer needs. - The cargo aliases keep this branch's device and geo crates and gain upstream's test-fastly-reuse alias. - In the seam, the host-signal implementation keeps its resolution arm, its key constant and its two tests, and the uninjected-module test covers build_module and the startup check together as the seam's does, with this branch's host_signals argument. - build_reusable_module keeps its three arguments, and the composition roots call it by that name. This branch's own device and geo names still say provider, and are renamed in a commit of their own.
The Edge Cookie settings refused the old host-signals spelling with a message naming the spelling to write. No configuration in this repository uses it, so the constant, the refusal in validate_module_selection, names_retired_module, the test of the refusal, the doc paragraphs describing it and its row in the error reference are gone. An old spelling now fails as any unknown implementation does when the module is built at startup, with a message listing the implementations the deployment has, which an_unknown_implementation_fails_naming_the_known_ones covers. The settings test kept the one assertion that was not about the old spelling, that a host_signals block is read as the built-in module's own settings, as a test of its own.
The fixed secret list already holds ec.hmac.passphrase and ec.host_signals.passphrase, but labeled_provider_block_names also listed a block named after one built-in that configures the other. The same path was then resolved twice, the second time reading the secret as a key name, and the load failed with a message that read as a missing store entry. A block named after either built-in is now left off the labeled list, because the fixed list covers it whichever it configures. Test a_built_in_block_naming_the_other_built_in_resolves_its_passphrase_once fails without the change.
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 Edge Cookie seam, which already says module, and only those words: every provider word was compared with upstream main, and a word that exists there is untouched. - DeviceProvider is DeviceModule, and BuiltinDeviceProvider, FastlyDeviceProvider and the test stand-ins are BuiltinDeviceModule, FastlyDeviceModule and so on. DeviceConfig's provider_key is module_key, and build_device_provider and build_geo_provider are build_device_module and build_geo_module. - HostSignalProvider is HostSignalModule, HostSignalsProviderConfig is HostSignalsModuleConfig, and HOST_SIGNALS_PROVIDER_KEY is HOST_SIGNALS_MODULE_KEY. - The [device] and [geo] selectors read module, as [ec] does, so the environment overrides read TRUSTED_SERVER__device__module and TRUSTED_SERVER__geo__module, and labeled_provider_block_names is labeled_module_block_names. - Test helpers and test names say module, and the plain word says module wherever it meant a device, geo or Edge Cookie module, in the core code, the adapters, the device and geo crates, the example settings, the fixtures and the guides.
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.
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.
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 Edge Cookie module seam (#1043), and depends on its
EdgeCookieModuletrait and module-code registry, which the host-signalmodule below uses. The stack has six pull requests
(#1043, #1044, #1045, #1046, #1047, #1094), each targeting
main, with this onesecond, and the first five decompose #838 as requested in the #986 review.
Compare
split/1-ec-providerwithsplit/2-device-geoto 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, shared with #1043, is
2026-07-30-pluggable-providers-design.md,which covers the Edge Cookie, device and location modules together.
What this pull request does
Device classification and geolocation become selectable modules, mirroring
the Edge Cookie seam and using the same configuration shape.
[device] moduleselects the classifier. The defaultbuiltinreads theUser-Agent alone and makes no host call. The opt-in
fastlymodulestrengthens the browser and bot gate with the host's TLS JA4 and HTTP/2
signals
(
crates/device/fastly).[geo] moduleselects geolocation. In this pull request the hostplatform's lookup remains the default, matching the behavior before the
selector existed, with
module = "none"as an explicit opt-out that sendsno client IP to any host geo service. The switch to no geolocation by default
lands with the permission model (Add the permission model with the Privacy Taxonomy vocabulary #1045), together with the permission
baseline that makes a deployment without geolocation workable, so this pull
request alone changes no deployment's geo behavior.
the same
build_geo_moduleselector, so[geo] modulebehaves the sameeverywhere rather than on Fastly alone.
[device],[geo],[ec.hmac],[ec.host_signals])reject keys they do not know at startup, so a mistyped setting fails loudly
instead of silently leaving a default in place. A table the selector does not
name is refused the same way, as is a device or geo module name this
build does not have.
the identifier from the host TLS and HTTP/2 signals plus the client IP, and
with no host signals at all it defers with a warning rather than falling back
to an identifier made from the IP alone. Its identifiers carry the
hs00~code from the module-code registry that Add a pluggable Edge Cookie module seam with the built-in HMAC module #1043 introduces, which fixes the
collision defect the review of Add pluggable Edge Cookie, device, and geo providers gated by a permission model #838 found, where host-signal identifiers
shared the HMAC format and key space. Its
[ec.host_signals] passphrasenames a secret-store key, as the HMAC passphrase does. Push validation checks
the name as a key reference, and the resolved value is held to the same
32-byte minimum. Whether host TLS and HTTP/2 processing ships in the series
is a policy question put to the task force in Host TLS/HTTP-2 signal processing: the separate design sign-off row 22 requires #1071, which is the separate
design that sign-off row 22 asks for. The proposal there is to close the row
with the capability opt-in, both uses permission-gated, and the policy
expressed in
permissions.yamlrather than compiled into the build.How it was verified
The head this pull request shows now is
47551a622, which merges #1043 atd33507b32and, through it,mainat 182fdf4 intofb1d369a1. 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. On47551a622these ran locally on Windows:cargo fmt --all --check, a locked dependency check (cargo tree --locked) and the core suite natively (3,021 tests).fb1d369a1adds to6fc9cdf71one commit of comment wording and three merges of #1043, which bring comment
wording, the identity graph's comments, one doc comment and one test. On
fb1d369a1these ran locally onWindows:
cargo fmt --all --check, Clippy with warnings denied on all fouradapters, the core suite natively (3,021 tests), and the docs lint,
Prettier and VitePress build. On
6fc9cdf71the wider set ran locally,being the core suite under Viceroy with the device and geo crates (3,257
across the crate's test binaries), the Axum (45 tests), Cloudflare (56) and
Spin (90) adapter suites and the cross-adapter parity suite (17), 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
47551a622: every check passes, being Run Tests, Run Format, Integration Tests and CodeQL Advanced, with the Fastly Edge Cookie lifecycle test in the integration run.References #780 and #781. Decomposes #838. Spec baseline from #986.