Add device and geo provider selection with the host-signal Edge Cookie provider - #1044
Open
jwrosewell wants to merge 57 commits into
Open
jwrosewell wants to merge 57 commits into
jwrosewell wants to merge 57 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)
The Spin adapter cannot start on upstream/main today, and this fixes it here. `build_state` compiled `trusted-server.example.toml` into the binary and parsed it, but that template ships placeholder secrets by design and its placeholder admin password is the first entry in `PASSWORD_PLACEHOLDERS`, so `validate_admin_handler_passwords` refused it every time. `build_state` therefore never returned `Ok`, the router fell back to the start-up error handler, and the component answered 503 to every request. The failure is "Handler `^/_ts/admin` uses a placeholder password; configure a strong secret". Nothing caught it because nothing called `build_state`. Every Spin test enters through the `routes_with_settings` parity seam and supplies its own settings, so the one path a deployed component actually takes was the one path never exercised. Settings now come from the platform config store at run time, which is what the Fastly, Axum and Cloudflare adapters already do, so an operator publishes one with `ts config push` and the component reads it. The new `SpinPlatformConfigStore` reads Spin component variables directly rather than through the per-request handle, because application state is built before any request context exists. Component variables are ambient, which is how the secret store already reads them, and both paths map keys through `spin_variable_name` so start-up and the request path read the same variable for the same key. The new test calls `build_state` and requires any failure to be the absence of a config store. Outside the Spin runtime there are no component variables, so it cannot return `Ok` under `cargo test`, but a configuration compiled into the binary would fail for a different reason and the test says so. Restoring the old body fails it with the placeholder-password message. Addresses: crates/trusted-server-adapter-spin/src/app.rs, where `build_state` parsed a baked example template whose placeholder admin password made every request fail.
`get_ec_id` is public on upstream/main today and this fixes it here. It
reads the `x-ts-ec` request header and then the `ts-ec` cookie, and
checks the result only with `ec_id_has_only_allowed_chars`. That function
is the global cookie backstop, the length cap and the cookie-safe
alphabet, and its own documentation in `ec/cookies.rs` says the strict
check is the one used to reject untrusted request values. On its own it
accepts any run of `[A-Za-z0-9._~-]` up to the cap, so it cannot tell an
identifier this deployment minted from one an attacker typed. `x-ts-ec`
is stripped from responses but not from inbound requests, so the header
really is the client's to set, and the raw reader prefers it over the
cookie. This is the inbound twin of the egress fault this branch already
fixes, which is why it belongs here.
The right check is not the built-in strict format validator. A vendor
provider's identifier is not required to match the HMAC
`<64 hex>.<6 alphanumeric>` shape, so holding every deployment to it
would drop exactly the opaque identifiers the provider model exists to
carry. The right check is provider ownership, where the `{code}~` prefix
is dispatched to the provider that owns it and that provider's
`accepts_id` decides, which is what `recognized_ec_id` already does and
what the EC lifecycle applies on read-back.
The raw reader cannot make that check, because it has neither settings
nor the selected provider, so it stops being a public entry point. It is
now `pub(crate)` and named `unvalidated_ec_id_from_request`, so no caller
can read it as returning a validated identifier, and `recognized_ec_id`
is the only way in from outside the module. Nothing outside the crate
called the old name.
The new test drives three identifiers this deployment could never have
issued through both readers, shows the bounds alone accept all three,
and requires the public path to recognize none of them, while an
identifier the selected provider does own is still returned. Replacing
the ownership check with the bounds fails it on the first one.
Addresses: crates/trusted-server-core/src/edge_cookie.rs, where
`get_ec_id` was public and validated client-supplied identifiers with the
outbound backstop list.
Reverts the removal of the request-evidence accessors, so RequestInfo carries the client IP, the User-Agent, headers by name, header names, the path, the query and its parameters again. They were removed to satisfy a rule in our own specification, which says every trait method needs a production caller in the change that introduces it. That rule is right for a behavioural trait, where a method nothing calls is dead weight. It is wrong for an evidence interface, and applying it here was our mistake rather than anyone else's. An evidence interface describes what a request carries, not what today's code happens to read. Held to the caller rule it grows a method every time a vendor arrives, so no vendor can write against it and it cannot stay stable across a release. It also puts the boundary in the wrong place, because what a provider may see was never the control. What a provider may do with what it sees is the control, and that is the permission model. The specification is amended in the same series rather than quietly ignored. Two test provider codes restored with the revert predate ProviderCode::new returning an Option, so they now build through the macro that cannot fail. Addresses: crates/trusted-server-core/src/evidence.rs, an evidence interface narrowed to today's callers
Fixes doc comments, error strings and TOML comments on split/1 so they match the code they describe, and applies house-style wording rules to every added line touched. Continues and completes work a prior agent started (which stopped partway through the B3 item list), reviewed against the run books at .claude/pr1/runbook-track-1-code-chain.md and .claude/pr1/comments-docs-runbook.md in the trusted-server repo. F2 (verified, already done by the prior agent): added "cache-control" to FRAMING_OR_HOP_BY_HOP_HEADERS in ec/provider.rs with a regression test, and removed the false reference to a per-adapter is_hop_by_hop_response_header function. B3.9 (verified, already done by the prior agent): recorded, rather than fixed, the gap where pull sync (ec/pull_sync.rs) and the admin lookup (ec/admin.rs) key identity-graph rows by the raw identifier instead of the canonical form the three organic paths use. A doc comment on EcContext::kv_key_for and AcceptedProviders now names the gap and points at commit 343ac3e, which fixed the three organic paths. Recorded as a known issue for a later change rather than changed now, because routing these two paths through the canonical key this late changes behavior. Wrong-claim and stale-reference fixes (runbook Part B3, items 1-25): verified each item against the current tree. Most were already corrected by the prior agent (HmacProvider failure handling, the environment-variable override claim, EcProviders selection docs, provider construction timing, BorrowedRequestInfo allocation, the generate_if_needed and validate_provider_selection # Errors lists, the retirement-arithmetic doc, kv_key_for, ec_allowed, request_headers, edge_cookie.rs recognized_ec_id, admin.rs and cookies.rs identifier grammar, IdentityInput gating, and the plural "built-in providers" wording in platform/types.rs). This pass added the one remaining fix: crates/trusted-server-adapter-spin/src/app.rs no longer claims Cloudflare reads settings the same way as Fastly and Axum (Cloudflare also reads a JSON binding and compiles in the example TOML natively). House-style sweeps (runbook Part C), restricted to lines split/1 added over upstream/main d516a9e, verified per line via git diff before editing: - "mint"/"minted"/"mints" -> "create"/"created"/"creates" (or "issue"/ "derive" by sense) in doc comments, inline comments and expect()/ assert messages across ec/provider.rs, ec/mod.rs, ec/admin.rs, ec/batch_sync.rs, ec/cookies.rs, ec/finalize.rs, ec/generation.rs, ec/identify.rs, ec/pull_sync.rs, edge_cookie.rs, integrations/testlight.rs, platform/test_support.rs, proxy.rs, and crates/trusted-server-adapter-fastly/src/app.rs. Left the `mint: bool` test-builder struct field name alone (an identifier, not prose), and left every "mint" occurrence that predates split/1 alone (confirmed against upstream per file before editing; two lines were edited by mistake and then reverted once the upstream check showed they were pre-existing text, not split/1 additions). - "initialise" -> "initialize" in the four adapters' build_state / build_state_with_settings doc comments. - "several" -> "multiple" in ec/generation.rs. - "HTTP/2 fingerprint" -> "TLS and HTTP/2 signal" in ec/generation.rs. - "built-in HMAC default" -> "built-in HMAC provider" (there is no default provider) in crates/edgecookie/README.md. - Environment variable casing: TRUSTED_SERVER__ec__provider -> TRUSTED_SERVER__EC__PROVIDER in trusted-server.example.toml, to match settings.rs and the documented upper-case form. - Bytes vs characters: trusted-server.example.toml's new [ec.providers.hmac] block comment now says ">= 32 bytes" to match the startup error message, which counts bytes. - A8 cargo-feature overclaim: trusted-server.example.toml no longer says a vendor provider "needs its own cargo feature" (none exists); it now says a vendor provider ships in its own crate the adapter composes in. - A15 environment-loading overclaim: trusted-server.example.toml's [ec] block comment now matches settings.rs, saying deployment tooling can merge an environment value into the published configuration before load, and that the running server itself reads settings from the platform config store, not the environment. Also fixed two doc-comment line-wrap glitches left by the prior agent's edits (a stray single-word line in ec/provider.rs's build_provider doc and in settings.rs's Ec::provider doc), where a mid-sentence line break had been left in place after wording changed. Left for a documented human decision rather than changed, per the runbook's own "James decides" note: two em dashes in trusted-server.example.toml:76 and crates/trusted-server-core/README.md that follow an existing dash-separated heading/bullet convention used throughout each file; and the one new "test-publisher.com" test URI in ec/identify.rs, which matches roughly twenty pre-existing (not split/1-introduced) occurrences of the same fixture domain already in that file, so changing only the new one would be inconsistent and changing the rest is outside split/1's introduced lines. Out of scope for this branch, so not touched: the "no host-specific call" overclaim (B1 item 1, on split/2/3), the geo-default docs (A1, on split/6/7), and the drafted GitHub text fixes (A2-A4, A10-A16), all of which live on later branches or in .claude/pr1/ review artifacts. Not built or tested per instructions; verification is deferred to the full-stack gate run after every branch in the chain is rebased. AI assistance note: this commit was produced by an AI coding session that continued a prior AI session's partially completed edits, reading both against the run books named above. A human should review the "James decides" items before the stack is pushed.
The provider reached the request path two ways, through the adapter-resolved resolved_ec_provider and through a raw ec_provider slot on RuntimeServices that only the core test helpers ever set. Section 3.6 of the integration provider seam design specifies one path, so the raw slot goes and every caller now reaches a provider through the resolved seam. request_provider builds from [ec] settings alone when nothing was threaded, and the test helpers thread their provider the way a production adapter does, so the tests exercise the path production uses.
CI compiles the test build with -D warnings, where NoClientIpProvider is an error because nothing constructs it. The test it was written for had been rewritten around the evidence-capturing provider, leaving the fixture behind. The seam branch already removes it for the same reason, so remove it here where it first appears and the whole chain stays consistent.
…e provider Second slice of the PR 838 decomposition. Device classification and geolocation become selectable providers, mirroring the Edge Cookie provider seam: - [device] provider selects the classifier. The built-in default reads the User-Agent alone and makes no host call; the opt-in fastly provider strengthens the browser/bot gate with the host's TLS JA4 and HTTP/2 signals (crates/device/fastly). - [geo] provider selects geolocation. The host platform's lookup is the default, matching the behavior before the selector existed, and provider = "platform" spells the same choice explicitly (crates/geo/fastly wraps the Fastly host lookup behind the PlatformGeo trait). provider = "none" opts out entirely, so a client IP is never sent to any host geo service. The disabled-by-default flip ships with the permission model in the next slice, which adds the jurisdiction baseline that makes a no-geo deployment viable. - Every adapter routes its host geo through the same build_geo_provider selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider identically, so the selector is not a Fastly-only behavior. - The provider configuration sections ([device], [geo], [ec.providers.hmac], [ec.providers.host-signals]) reject unknown keys at startup, so a mistyped key fails loudly instead of silently selecting a default. - The host-signal Edge Cookie provider arrives with the capability it needs: the Fastly adapter injects the TLS/HTTP-2 signals as a HostSignals service, and the provider mints from them plus the client IP. With no host signals at all it defers with a warning rather than degrading to an IP-only identifier. - Device signals move to a field-based DeviceSignals derived in the adapter (derive_ua_only for hosts without host signals). - The new crates join the fastly cargo aliases so they build, lint, and test in CI rather than compiling only transitively.
The geo and device selectors are optional, and both serialized as `"provider": null` when unset rather than being left out. That is invisible today, because the whole section is skipped while every field in it is default. It stops being invisible in the permission model PR, which makes `[geo] default_country` required, so the geo section is always emitted and the null selector goes with it. A pushed config blob then carries a key that a binary predating the selector rejects, which breaks an otherwise unchanged `ts config push` during a rollout or a rollback. That is the same compatibility rule the surrounding fields already follow. Both fields now skip serialization when unset. The test serializes each section directly rather than through Settings, because through Settings the section is skipped as a whole here and the fault would not show until two PRs later. Addresses: crates/trusted-server-core/src/settings.rs, optional selectors serialized as null
The Cloudflare adapter resolves no region on upstream/main today and this fixes it here. `build_geo` read no region header and `CloudflareGeo`'s `lookup` returned `region: None` unconditionally, so no Cloudflare deployment could ever place a visitor in a US state. I traced the consequence rather than assuming it, and it holds. `detect_jurisdiction` in `consent/jurisdiction.rs` reaches its US branch only when the country is `US` and a region is present, so with no region every US visitor fell through to `Jurisdiction::NonRegulated`. In `allows_ec_creation`, the `NonRegulated` arm returns true and never reads `ctx.gpc`, while the `UsState` arm blocks on `ctx.gpc` first and then on a GPP US sale opt-out and the US Privacy string. A US visitor sending Sec-GPC therefore had that opt-out ignored on Cloudflare, and an Edge Cookie was created anyway. The same is true for the GPP and US Privacy opt-outs, because none of them are consulted on the unregulated arm. The region now comes from `cf-region-code`, which is the ISO 3166-2 subdivision code, rather than `cf-region`, which is the subdivision name. The state rules in `[consent.us_states] privacy_states` are written as two-letter codes, so a name would never match one and the fix would look applied while changing nothing. This is a request header, read the same way the adapter already reads `cf-ipcountry` and `cf-ipcity`, and like those it needs the visitor-location managed transform, which the doc comment now says. The new test walks the whole chain in one place, from the header through `build_geo` and `lookup` to `detect_jurisdiction` and then to `allows_ec_creation` with Sec-GPC set, and asserts the opt-out is honored. Putting `region: None` back fails it. Addresses: crates/trusted-server-adapter-cloudflare/src/platform.rs, where `CloudflareGeo::lookup` hardcoded no region and `build_geo` read no region header, so US state privacy signals failed open.
Restoring the evidence accessors added a `with_headers` builder to `OwnedRequestInfo` and `BorrowedRequestInfo`, and on both types it landed between `with_request_target`'s doc comment and the function that comment describes. So `with_request_target` was left undocumented and without its `#[must_use]`, while `with_headers` carried a stray attribute and a comment describing its neighbour. The compiler reported the stray attribute and clippy separately asked for a `#[must_use]` on `with_request_target`, which is the attribute that had been severed from it. Both builders now sit with the documentation and the attribute that belong to them, so a caller that drops either builder's result is caught again. This went unnoticed because the checks run against this branch were `cargo check` and a cached build, neither of which re-reports warnings, and the branch fails `cargo clippy` with warnings denied. It is fixed here rather than further up the stack because this is the change that introduced the builders, so every branch above inherited the fault. Addresses: crates/trusted-server-core/src/evidence.rs, where both `with_request_target` builders lost their documentation and their `#[must_use]`.
CI compiles the test build with -D warnings, where this import is an error because the one place that needs the type names it by its full path. The seam branch already removes it, so remove it here where the device tests first appear and the whole chain stays consistent.
The integration seam spec requires every vendor crate to carry a named maintainer the way Prebid.js requires of every adapter. The two Fastly host crates now declare their owner in package metadata, stating plainly that the Trusted Server maintainers own them until a vendor owner steps up, which is the ownership rule the reviews on IABTechLab#1084 asked the structure to make explicit.
jwrosewell
force-pushed
the
split/2-device-geo
branch
from
September 2, 2026 14:31
e99de21 to
dcdb493
Compare
Brings main at 066ea3c into split/1-ec-provider. Twelve files conflicted and one more needed a change without a textual conflict. Most resolutions keep both sides. Five needed a decision, recorded here so that no behavior changes silently. Edge Cookie generation, in ec/mod.rs. Main's IABTechLab#885 creates a row only when no row holds the key, retries on a collision and binds the request snapshot to the new row. This branch creates identifiers through the selected provider. Both are kept. Each attempt asks the provider for a candidate through the new EcContext::candidate_id, which runs the reserved-header and identifier-bound checks, then creates the row with create_if_absent under the provider's canonical key and binds the snapshot to that key. Orphan recovery, in ec/finalize.rs. Main's IABTechLab#885 rotates an orphaned cookie by generating an HMAC identifier directly, which would give a vendor-provider deployment a built-in identifier. Recovery now asks the selected provider through candidate_id, keeps main's proof of absence and retry limit, and does nothing when no provider is selected. Any headers the provider asks for while creating the replacement are applied to the response. Withdrawal, in ec/finalize.rs. Main's IABTechLab#1113 hardening is kept whole, being finalize_unusable_consent, the existence check inside write_withdrawal_tombstone, the snapshot write-back and log_tombstone_outcome. The tombstones are keyed by this branch's withdrawal_kv_keys, the canonical keys of the cookie and the active identifier, in place of withdrawal_ec_ids, and the write-back compares against the active identifier's canonical key. EID ingestion on the returning and generated paths uses main's collect-then-upsert form under the canonical key. Secret references, in config.rs. Main's IABTechLab#1036 resolves secret settings from the secret store by the paths listed in secret_fields. This branch makes the legacy ec.passphrase optional and adds the [ec.providers.hmac] passphrase, which was not listed, so it would have been read as a literal value. Both are now listed as optional paths and checked as key references when the configuration is pushed, and the integration fixture and the example configuration name the secret key rather than a value. Spin start-up, in the Spin adapter. Main's IABTechLab#1036 loads settings from the config store with secret resolution, which fixes the same start-up failure this branch's Spin commit fixed. Main's loader is taken and SpinPlatformConfigStore, which nothing else uses, is removed. The four adapters keep this branch's composition-root provider check, and Axum, Cloudflare and Spin keep its error propagation from build_ec_context, alongside main's compiled auction plan from IABTechLab#1016. Tests. Main's collision-retry tests and its orphan-rotation tests built their context with no provider selected, which main's code did not need. They now select the built-in HMAC provider, as an HMAC deployment does, because with no provider there is nothing to create or rotate. No other test reaches identifier creation or orphan recovery without a provider selected. Tests from each side were moved onto the other side's interfaces, being this branch's six-parameter context helpers, main's mutable context in ec_finalize_response, and the [ec.providers.hmac] passphrase in main's two secret validation tests.
Main's IABTechLab#1016 builds the orchestrator and the integration registry from a compiled auction plan, and limits IntegrationRegistry::new to core's own tests. The merge of main moved the Axum adapter's state building onto the plan and took main's imports, but missed the test helper state_with_uninjected_provider, which still called build_orchestrator and IntegrationRegistry::new, so the adapter's library tests stopped compiling. The helper now builds both from the plan, the same way build_state_with_settings does.
Brings upd/split/1-ec-provider at cb28ad3, which carries main at 066ea3c, into split/2-device-geo. Six files conflicted, all where this branch's host signals meet main's compiled auction plan or main's imports. Each resolution keeps both sides' changes, so nothing either side does is dropped. The Axum, Cloudflare and Spin adapters keep this branch's provider calls, which pass the host signals (None in all three) as well as the injected provider, with this branch's comments saying so. They then build the orchestrator and the integration registry from main's compiled auction plan, the way split/1 does. The Fastly imports keep what each side still uses. app.rs keeps main's StoreName alongside this branch's build_geo_provider and FastlyHostSignals. main.rs drops the config_store_name import, because main replaced its only use with the runtime store names, and keeps this branch's device provider import. platform.rs keeps main's BackendNamingPolicy and drops GeoInfo and PlatformGeo, which this branch moved into the trusted-server-geo-fastly crate.
Main's IABTechLab#1036 resolves secret settings from the secret store by the paths TrustedServerAppConfig::secret_fields lists, and push validation skips the validators on those paths because they hold key names. Merging main registered the [ec.providers.hmac] passphrase, but not this branch's [ec.providers.host-signals] passphrase. A key name there failed the passphrase length check when the configuration was pushed, and a key name long enough to pass would have been used unresolved as the HMAC key. The path is now listed as an optional secret and checked as a key name. EdgeZero matches secret paths against validation error keys verbatim, and the derived validation keyed the block's errors by the Rust field name host_signals, so EcProviders now validates each built-in block under the key the configuration uses. New tests cover pushing a host-signals key name, resolving one from the mapped store, and still rejecting a short resolved value.
Christian Pavilonis's review thread on provider response effects asked core to validate them against the managed ts- cookies, the x-ts- namespace and framing headers. The check is in place, but several comments said a rejected effect "fails the request", which is not what happens. EcContext::generate_if_needed returns the error, and its only two callers outside tests, the publisher fallback in the Fastly adapter and IntegrationRegistry::handle_proxy, log it and serve the response without an Edge Cookie. The reserved_response_effect and apply_provider_response_headers docs, the comment in EcContext::candidate_id and the reserved-surface test now say that. The candidate_id comment no longer implies the rejection matches the identifier-bounds check in every respect, because that check runs after the provider's headers are captured. A new test, a_rejected_provider_effect_never_reaches_the_finalized_response, proves a rejected header never reaches the response. For each reserved effect it lets generation fail, runs EC finalization on the same context, and checks the response carries no forged ts-ec cookie, no x-ts-ec header and no transfer-encoding. It failed when the header capture in candidate_id was moved ahead of the reserved check, and passes with the code as it is. The test helper now hands back the context even when generation fails, so the test can finalize on it. The review thread is IABTechLab#1043 (comment)
Christian Pavilonis's review thread on partner paths asked for validation and KV normalization to be dispatched by provider code. Validation already was, but several paths still read or wrote identity graph rows under the identifier as issued, while generation stores each row under the owning provider's canonical form. For a provider whose canonical form differs from the cookie value those paths found no row. - Pull sync validated the identifier but kept only the raw value. It looked the request snapshot up under that value, while generation binds the snapshot to the canonical key and every read EC finalization makes uses that key, so it skipped every partner. Its revalidation read and write-back used the raw value too. - The admin lookup answered 404 for a row that exists. - The /auction, publisher navigation and /_ts/page-bids preloads loaded a miss, and resolve_auction_eids matched the snapshot by the raw identifier, so auctions carried no server-side EIDs. - The navigation preload also replaced the snapshot generation had just bound to the canonical key with that miss, so a newly created identifier got no ts-ec cookie on a navigation with no EID cookies to ingest. Pull sync now carries the canonical key and uses it for the request snapshot lookup, the revalidation read and the write-back. Partners still receive the identifier as issued, and the pull rate limit key still hashes the issued identifier, which leaves rate limiting unchanged for every provider. The admin lookup reads under the key, reports the requested identifier as ec_id, and adds the key it read as kv_key, which the API reference now describes. The three preloads load under EcContext::ec_kv_key and resolve_auction_eids looks the entry up under the key. EcContext::accepts_id lost its only caller and is removed. For the built-in HMAC provider the canonical key is the identifier itself for every identifier read-back accepts, so these paths behave as before for HMAC cookies. Apart from the new kv_key field, the one change an HMAC deployment can see is that an admin lookup given an identifier with an uppercase hash now finds the row stored under the lowercase key instead of answering 404. Each path has a test using CanonicalizingProvider, whose identifier t0ca~MiXeD.CaseId is stored under t0ca~mixed.caseid, and all seven new tests failed before the fix. The shared constants for that identifier now live beside the provider, so the identify, finalization and new tests use one definition. The known-gap note on EcContext::kv_key_for, which cited commit 343ac3e from outside this branch, and the matching note on AcceptedProviders now state which paths key rows through the canonical form. The review thread is IABTechLab#1043 (comment)
Some comments added for the two review threads on provider response effects and canonical keying claimed more than the code does, and one changed comparison had no test that depended on it. The AcceptedProviders doc said pull sync, batch sync and the admin lookup all read and write rows, but the admin lookup only reads. The comment in the test a_rejected_provider_effect_never_reaches_the_finalized_response said a rejected header kept anywhere on the context would reach the browser, when EC finalization applies only the response headers the context holds. The admin lookup docs and the API reference entry for kv_key named the owning provider as the source of the row key, which does not hold for a deployment with no provider selected, where the built-in HMAC identifier format supplies the key. Three other comments now say precisely what they mean. The EcContext::kv_key_for doc names the function each reference points at, the EcContext::candidate_id comment no longer credits an unnamed caller with serving the response, and the dispatch_pull_sync doc names ec_hash as the input to the rate limit key. Before replacing the snapshot that generation bound, the publisher navigation preload compares that snapshot with its fresh read. The comparison is keyed by the canonical key, yet the navigation tests for a provider whose canonical key differs from the cookie value passed with it keyed by the identifier as issued, because their stores returned the row on the first read. The test for a newly created identifier's cookie now also runs against a store whose first point read misses the row generation just wrote. With the comparison keyed by the identifier as issued, the preload replaced the snapshot with that miss, EC finalization skipped the cookie and the test failed. With the canonical key the test passes.
A provider's own response headers, such as an evidence cookie, reached the browser even when generation discarded the candidate they came with. EcContext::candidate_id kept the headers before checking the identifier against the cookie bounds, and generate_with_provider left them on the context when the candidate collided with an existing row or its row could not be written. EC finalization applies whatever headers the context holds, and the publisher and integration proxies log a generation error and still serve the response, so the cookie went out with no identifier stored for it. candidate_id now keeps the headers only once the identifier has passed the bounds check, or when the provider produced no identifier at all, and generate_with_provider drops them with a colliding or unpersisted candidate. The reserved-surface check asked for in the review thread on provider response effects already ran before any header was kept, so the same rule now also holds when the identifier is rejected, when it collides and when its row cannot be written. A test covers each of those three cases, and all three failed before the change with the provider's cookie on the finalized response. HeaderSettingProvider now takes the identifier it returns, so a test can pair a permitted header with an identifier outside the cookie-safe alphabet. The finalization comment on applying provider headers now names candidate_id as the place they are checked, where it named generate_with_provider. The review thread is IABTechLab#1043 (comment)
The API reference said the explicit admin EC lookup route accepts an EC
ID in the bare {64 lowercase hex}.{6 alphanumeric} form. The route
accepts whatever AcceptedProviders::canonical_kv_key accepts, which is
an identifier created by the selected provider, such as the built-in
HMAC provider's hmac~ form, and the bare legacy form that provider
still reads, with both HMAC forms accepted when no provider is
selected. The hash may be given in either case, because
canonical_kv_key lowercases it before the check.
The note on retiring the legacy bare reader said a page view with
ts-eids or sharedId cookies runs ingest_eid_cookies in
ec_finalize_response and so restarts the row's one-year clock. Main's
change threading the EC KV read through the request (IABTechLab#885) moved
finalization to collect_eid_cookie_updates and
upsert_partner_ids_from_snapshot, which writes nothing unless a partner
ID is added or changed. The note now names that function and says only
such a view restarts the clock.
Brings upd/split/1-ec-provider at 1b88e42 into split/2-device-geo. The five commits this branch lacked, on top of the cb28ad3 it already had, answer two review threads on IABTechLab#1043 and correct what reviewing those answers found. - Pull sync, the admin lookup, /auction, the publisher navigation preload and /_ts/page-bids read and write identity-graph rows under the owning provider's canonical key (0e7f7eb and fb1bc28). - The docs on a rejected provider effect say generation returns an error and the page is served without an Edge Cookie, with a test that a rejected header never reaches the finalized response (0980b73). - A provider response's headers are kept only for a candidate that generation commits (cb6f717). - The API reference lists the admin lookup's accepted EC ID forms, and the bare reader note names the function that writes EID updates (1b88e42). The merge had no conflicts. The merged tree passes a native all-targets check, 2,763 core tests, the Axum, Cloudflare and Spin tests and the three wasm checks.
This was referenced Sep 14, 2026
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 #1043, the Edge Cookie provider seam, and depends on its
EdgeCookieProvidertrait and provider-code registry, which the host-signal provider below uses. The stack has six pull requests (#1043, #1044, #1045, #1046, #1047, #1094), each targetingmain, with this one second, and the first five decompose #838 as requested in the #986 review. Comparesplit/1-ec-providerwithsplit/2-device-geoto see only this pull request's change.The design specs for the series are carried by #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 geo providers together.What this pull request does
Device classification and geolocation become selectable providers, mirroring the Edge Cookie seam.
[device] providerselects the classifier. The defaultbuiltinreads the User-Agent alone and makes no host call. The opt-infastlyprovider strengthens the browser and bot gate with the host's TLS JA4 and HTTP/2 signals (crates/device/fastly).[geo] providerselects geolocation. In this pull request the host platform's lookup remains the default, matching the behavior before the selector existed, withprovider = "none"as an explicit opt-out that sends no client IP to any host geo service. The switch to no geolocation by default lands in 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.build_geo_providerselector, so[geo] providerbehaves the same everywhere rather than on Fastly alone.[device],[geo],[ec.providers.hmac],[ec.providers.host-signals]) reject unknown keys at startup, so a mistyped key fails loudly instead of silently selecting a default.hs00~code from the provider-code registry that Add a pluggable Edge Cookie provider seam with the built-in HMAC provider #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.providers.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 inpermissions.yamlrather than compiled into the build.How it was verified
Every job
main's CI runs was run locally against this head,37eadaed4, on Windows and under WSL, and all of them passed. That coverscargo fmt --all --check, Clippy with warnings denied on the Axum, Cloudflare, Spin and Fastly adapters and on both wasm targets, the Axum, Cloudflare and Spin adapter suites, the cross-adapter parity suite, the benchmark smoke run, the release wasm builds for Spin and Fastly, and the CLI, OpenRTB codegen,format-docsand template cache harness jobs that only run on Linux. The core suite passes with 2,763 tests natively and 2,757 under Viceroy.CI on this head is green across all 19 checks, being Run Tests, Run Format, Integration Tests and CodeQL Advanced.
References #780 and #781. Decomposes #838. Spec baseline from #986.