Skip to content

Add device and geo module selection with the host-signal Edge Cookie module - #1044

Open
jwrosewell wants to merge 85 commits into
IABTechLab:mainfrom
jwrosewell:split/2-device-geo
Open

jwrosewell wants to merge 85 commits into
IABTechLab:mainfrom
jwrosewell:split/2-device-geo

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

This description was updated on 7 October 2026, and on 8 October for the merge of main at 182fdf4. The branch now carries
the refreshed Edge Cookie seam (#1043 at 6b2e303) and, through it, main
at 182fdf4. Since the last version of this text:

  1. What an operator selects is a module. The selectors read
    [device] module and [geo] module, the host-signal Edge Cookie module
    is selected with [ec] module = "host_signals", and every name this pull
    request adds says module, as the seam's names do. Through the seam the
    branch carries the module name rule, under which a module from a crate
    is named by its folder below crates/. The names this pull request
    adds, host_signals, fastly, platform and none, are core's and the
    host's own bare names, and are unchanged.
  2. A block named after one built-in Edge Cookie implementation that
    configures the other ([ec.hmac] with implementation = "host_signals",
    or the reverse) resolves its passphrase once. It was listed twice, and
    the second resolution read the secret as a key name and failed the load.
  3. The old host-signals spelling is no longer refused by name. No
    configuration uses it, and it now fails as any unknown implementation
    does, with the message listing the implementations the deployment has.
  4. Comments say what the code does and why, with the history and plans
    taken out, and the branch carries Add a pluggable Edge Cookie module seam with the built-in HMAC module #1043's statement of what
    normalize_id_for_kv decides.

The commits since the merge are 80a842e (the merge), 2c34ed1 (the old host-signals spelling is no longer refused by name), badb58b (a cross-named built-in block resolves its passphrase once) and 13cfa9b (the device and geo seams say module), then on 7 October 6fc9cdf (the merge of #1043 at 6b2e303, which brings the module name rule), 35ccc36 (the merge of #1043 at ef4c013), cec6052 (comments say what the code does), 09514b1 (the merge of #1043 at 5d808f2) and fb1d369 (the merge of #1043 at 6f52771, which words the identity graph's comments as device records, not person records).

Stacks on the Edge Cookie module seam (#1043), and depends on its
EdgeCookieModule trait and module-code registry, which the host-signal
module below uses. The stack has six pull requests
(#1043, #1044, #1045, #1046, #1047, #1094), each targeting main, with this one
second, and the first five decompose #838 as requested in the #986 review.
Compare split/1-ec-provider with split/2-device-geo
to see only this pull request's change.

The design specs for the series are carried by the spec pull request (#1084).
The spec for this pull request, 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] module selects the classifier. The default builtin reads the
    User-Agent alone and makes no host call. The opt-in fastly module
    strengthens the browser and bot gate with the host's TLS JA4 and HTTP/2
    signals
    (crates/device/fastly).
  • [geo] module selects geolocation. In this pull request the host
    platform's lookup remains the default, matching the behavior before the
    selector existed, with module = "none" as an explicit opt-out that sends
    no 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.
  • Every adapter (Fastly, Axum, Cloudflare, Spin) routes its host geo through
    the same build_geo_module selector, so [geo] module behaves the same
    everywhere rather than on Fastly alone.
  • The module tables ([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 host-signal Edge Cookie module ships as an opt-in built-in. It creates
    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] passphrase
    names 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.yaml rather than compiled into the build.

How it was verified

The head this pull request shows now is 47551a622, which merges #1043 at d33507b32 and, through it, main at 182fdf4 into fb1d369a1. The merge brings the ts dev lint domains and ts dev install-hooks commands (#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. On 47551a622 these ran locally on Windows: cargo fmt --all --check, a locked dependency check (cargo tree --locked) and the core suite natively (3,021 tests).

fb1d369a1 adds to 6fc9cdf71
one 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 fb1d369a1 these ran locally on
Windows: cargo fmt --all --check, Clippy with warnings denied on all four
adapters, the core suite natively (3,021 tests), and the docs lint,
Prettier and VitePress build. On 6fc9cdf71 the 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.

@jwrosewell
jwrosewell force-pushed the split/2-device-geo branch 3 times, most recently from 849954b to d9271cf Compare August 25, 2026 10:51
@jwrosewell
jwrosewell force-pushed the split/2-device-geo branch 4 times, most recently from 9c3598e to c931bc8 Compare August 27, 2026 15:33
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)
@aram356

aram356 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

The sequencing discussion for this series is on #1084. This PR is superseded rather than rejected. The design in §3.6 is accepted and most of the provider work carries over onto the reordered base. See #1084.

…ider

First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.

- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
  The deprecated [ec] passphrase form still starts for one release
  cycle: it maps to provider = "hmac" with a deprecation warning, and a
  configuration carrying both forms is rejected. provider = "none"
  spells explicit statelessness. A configured block that is not the
  selected provider is rejected at startup, as is a block with no
  selector.
- Global identifier bounds enforced by core at mint, read-back, and
  cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
  cap. An identifier outside the bounds is rejected loudly, never
  rewritten, so the cookie value and the identity-graph key can never
  silently diverge.
- The identity graph is keyed by the provider's canonical form of the
  identifier (normalize_id_for_kv), so equivalent representations of
  one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
  access to the client IP, headers (including cookies), URL path, and
  query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
  provider, so a vendor provider lives in its own crate and core never
  names it. A selected provider the adapter does not inject fails the
  request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
  proceeding stateless.

Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.

Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.

The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.

Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.

is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
CodeQL's cleartext-logging query treats a call whose name contains
"passphrase" as a sensitive source, and because the method mutates the
Settings it belongs to, every later log line that prints anything from
Settings (store names, timeouts, header names) is reported as writing a
secret to a log. The passphrase itself is a Redacted<String> and none of
the flagged lines prints it. The method now describes what it does,
migrate_legacy_ec_layout, and its behavior is unchanged.
A reviewer raised a P1 against the pluggable Edge Cookie provider work:
three of the four adapters broke the provider contract that an
unavailable required service or an uninjected provider stops the
request.

The Axum, Cloudflare and Spin adapters each read the Edge Cookie context
with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`,
logged a warning and continued with `EcContext::default()`. A deployment
whose selected provider could not be built therefore came up and served
every request with no identity, silently. The Fastly adapter already
kept the report and answered with an error response.

`build_ec_context` on the three adapters now returns
`Result<EcContext, Report<TrustedServerError>>` and every call site
propagates it to that adapter's own `http_error`, the same helper Fastly
uses, so all four answer with the same status and shape.

The design this implements has the composition root check a selected
provider's needs once at startup rather than per request, so
`ensure_provider_available` was added to `ec/provider.rs` and is called
from `build_state_with_settings` on all four adapters (Fastly included,
so the rule is uniform). Building a provider reads no request data, so
a selection an adapter can never supply now fails when application state
is built, and the three adapters answer every route from their existing
`startup_error_router` instead of coming up. Statelessness, meaning no
`[ec] provider` selector or the explicit `"none"`, still passes and
still serves.

The widening question was checked rather than assumed.
`read_from_request_with_geo` can only fail from two places: the provider
build, and a `Cookie` header that is not valid UTF-8. A malformed cookie
value is dropped with a warning by `request_ec_id_if_allowed`, consent
parsing returns a value rather than a `Result`, and the geo lookup is
already swallowed by the adapter before the call, so no ordinary parse
problem reaches the error path and none is turned into a failed request.

Tests: each of the three adapters gains a route test proving an
uninjected provider fails at startup, and an in-crate test proving
`build_ec_context` returns the error rather than a default context.
Core gains a test that the startup check rejects an uninjected provider
and still allows statelessness both ways.

Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1)
`Settings::finalize_deserialized` runs derive validation before
`Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase`
field carries no `#[validate]` attribute of its own, so the advertised
32-byte minimum was only enforced on the new `[ec.providers.hmac]`
location. A configuration still on the old form could start with
`passphrase = "short"`, or with an empty value, and mint identifiers
from keying material the new location rejects.

The migration now calls `Ec::validate_passphrase` on the value it is
about to move, before it logs the deprecation warning and writes the
`[ec.providers.hmac]` block, and reports a configuration error naming
the minimum length and the new location.

Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives
`Settings::from_toml` with the `[ec]` section rewritten to the
deprecated form and proves a short value and an empty value are both
rejected, and that a passphrase of adequate length still migrates to
`provider = "hmac"` with the passphrase in the hmac block. Removing the
new check makes that test fail, so it tests the fix rather than the
surrounding code.

Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench)
The provider spec (section 6) says `deny_unknown_fields` is set on both
built-in provider config structs, but `HmacProviderConfig` carried no
such attribute, so `[ec.providers.hmac] typo_key = "x"` was accepted
silently. An operator who mistypes a key gets a deployment that starts
and quietly uses the default for the setting they meant to change.

`HmacProviderConfig` now sets `#[serde(deny_unknown_fields)]`, matching
`Ec` itself and the rest of the settings tree. The struct is a plain
field of `EcProviders` rather than a flattened one, so the attribute
does not collide with the `#[serde(flatten)]` vendor map alongside it.

Tests: `an_unknown_key_in_the_hmac_provider_block_is_rejected` adds an
unknown key to the block in the crate test configuration and proves
`Settings::from_toml` fails and names the key. Removing the attribute
makes that test fail.

Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:726 (wrench)
`build_provider`'s `"hmac"` arm mapped over `ec.providers.hmac`, so a
deployment that selected `provider = "hmac"` with no
`[ec.providers.hmac]` block got `Ok(None)` and ran stateless under a
selector that says it has an identity provider. Every other unbuildable
selection in the same match already errors.

The arm now returns `TrustedServerError::EdgeCookie` naming the missing
block, which the startup check `ensure_provider_available` turns into a
failed application state on every adapter.

`Ec::validate_provider_selection` rejects that pair before settings
reach the composition root, so nothing routes through the new arm today.
It is the drift guard for the case where the two checks stop agreeing,
which is exactly the shape of the defect being fixed, so it is worth
keeping rather than leaving the silent branch in place.

Tests: `selecting_hmac_without_its_block_fails_loudly` builds the `Ec`
programmatically, bypassing settings validation to reach the seam, and
proves the error names the missing block. The doc comment's `# Errors`
section is corrected in the same commit, since it still claimed no
built-in construction can fail.

Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:304 (refactor)
The error raised when a provider mints an identifier outside the
identifier bounds was written across two source lines without the
trailing backslash that joins them, so the 22 spaces of source
indentation became part of the literal and the logged message read
"...bytes, or                      outside the cookie-safe alphabet".

The continuation is restored, so the message reads as one sentence.

The whole of ec/mod.rs was scanned for the same fault, matching every
string literal and stripping real continuations before looking for runs
of more than one space or a newline inside a literal. This message was
the only one.

Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:444 (nitpick)
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.
@jwrosewell jwrosewell changed the title Add device and geo provider selection with the host-signal Edge Cookie provider Add device and geo module selection with the host-signal Edge Cookie module Oct 6, 2026
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.
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants