Add a pluggable Edge Cookie provider seam with the built-in HMAC provider - #1043
Add a pluggable Edge Cookie provider seam with the built-in HMAC provider#1043jwrosewell wants to merge 36 commits into
Conversation
312a4fc to
73b40b9
Compare
83e551d to
e278981
Compare
e278981 to
4529151
Compare
The five-PR series (IABTechLab#1043 to IABTechLab#1047) opens the identity, device and geo seams. The nine vendor integrations already in core sit behind the integration registry instead, which is a private table, so none of them can move out until that table is opened. This spec defines the one core change that opens it: public registration builders with a second input on IntegrationRegistry, browser JavaScript carried on the registration, startup validation as a hook, the same treatment for auction providers and the bid renderer contract, and neutral replacements for the two places where a vendor reaches into core. It then sets out the migration of all nine existing integrations, one PR each. The change is complete in itself: after it, no vendor move needs a core change. Written against the series' tree with the file and line references for every claim about the current code. Documentation only.
aram356
left a comment
There was a problem hiding this comment.
Summary
This PR lands the Edge Cookie provider seam with the built-in HMAC provider, per the pluggable-providers design spec carried in the same change. The lifecycle contract (mint, recognition, KV keying), the global identifier bounds, startup validation, the deprecated-passphrase migration, and the partner-path envelope fix are substantially implemented, with strong test coverage, and CI is fully green.
The major blocker is architectural: vendor extensibility should lean on the existing integration system rather than introduce a parallel "provider" mechanism. The codebase has one established home for vendor code (the integration registry), and this PR adds a second seam, a second config namespace, and a second nomenclature for what a vendor ships. We want that resolved at spec level before PRs 2-5 of the series build on the current shape - see the first cross-cutting finding below.
Beyond that, changes are requested on: a reproduced bypass of the advertised 32-byte passphrase minimum on the deprecated configuration form, two points where the implementation does not do what the spec states (unknown-key rejection in the hmac block; canonical-key routing on identity-graph reads and withdrawals), and an egress guarantee the proxy paths do not honor.
4 of the inline comments below carry a one-click GitHub
suggestion. Use Commit suggestion (or Add suggestion to batch) to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change spans multiple files or non-contiguous lines and cannot be auto-applied.
Blocking
🔧 wrench
- Vendor identity should lean on the integration system, not a second extension mechanism - the major blocker; cross-cutting, below
- Legacy
[ec] passphrasebypasses the new 32-byte minimum - see inline atcrates/trusted-server-core/src/settings.rs:658(suggestion) [ec.providers.hmac]silently accepts unknown keys - see inline atcrates/trusted-server-core/src/settings.rs:726(suggestion)- Identity-graph reads and withdrawal tombstones bypass the provider's canonical key - cross-cutting, below
❓ question
- Spec says an unrecognized cookie value is "never used or egressed", but the proxy forwarding paths egress it - cross-cutting, below
Non-blocking
♻️ refactor / 🤔 thinking / ⛏ nitpick / 📌 out of scope
- ♻️
build_providersilently returnsOk(None)forprovider = "hmac"with no block - see inline atcrates/trusted-server-core/src/ec/provider.rs:304(suggestion) - ⛏ 22-space run inside the mint-rejection error message - see inline at
crates/trusted-server-core/src/ec/mod.rs:444(suggestion) - ♻️
EdgeCookieProvider's doc comment is fused intoProviderCode's, leaving the trait undocumented - see inline atcrates/trusted-server-core/src/ec/provider.rs:177 - ⛏
ec::get_ec_idis dead code, yet was modified to accept any provider code - see inline atcrates/trusted-server-core/src/ec/mod.rs:137 - ⛏ Module docs describe constructor injection that is not how
RequestInfoflows - see inline atcrates/trusted-server-core/src/ec/provider.rs:4 - 🤔 Cluster prefix listing splits across the envelope migration - cross-cutting, below
- ♻️ Magic strings
"hmac"/"none"scattered across four call sites - cross-cutting, below - 🤔
RequestInfoaccessors have no production consumer in this PR - cross-cutting, below - 🤔 Spec revision followed the implementation - cross-cutting, below
- 📌 Operator guides still document
[ec] passphraseas the current form - cross-cutting, below
Cross-cutting / body-level findings
-
🔧 Vendor identity should lean on the integration system, not a second extension mechanism (the major blocker). The codebase already has one home for vendor code: the integration registry (
IntegrationRegistration::builder(ID).with_proxy().with_head_injector()...), capability-based and config-namespaced under[integrations.<id>]. This PR adds a second vendor seam -RuntimeServices::ec_provider, a single-slotOption<Arc<dyn EdgeCookieProvider>>matched byid(), configured under[ec.providers.<key>]- and a second nomenclature ("providers").RuntimeServicesis otherwise the platform composition surface (KV store, geo, HTTP client, client info: things the host supplies); a vendor identity module is not a host capability, and a vendor realistically ships a JS integration and an identity function together, which this split forces into two mechanisms. Please rework the vendor seam onto the integration system: identity provision as a registration capability (for example.with_ec_provider(...)), with[ec] provider = "<integration id>"still supplying the select-exactly-one semantics; the built-in HMAC provider can stay hard-wired in core as the default, and geo/device rightly remain platform services. If there is a reason this cannot work, the spec should defend the separate provider mechanism against this alternative explicitly - and we want that settled at spec level before PRs 2-5 of the series build on the current shape. -
🔧 Identity-graph reads and withdrawal tombstones bypass the provider's canonical key. The spec's lifecycle table (section 3) routes identity-graph row reads and writes through
normalize_id_for_kv. Mint honors that:EcContext::generate_with_providerkeys the row withprovider_kv_key(ec/mod.rs:476). Buthandle_identifyreads with the raw cookie value (kv.get(ec_id),ec/identify.rs:89), withdrawal tombstones are written under the raw value (ec/finalize.rs, thewrite_withdrawal_tombstoneloop), and EID ingestion keys by the raw value. For the built-in HMAC provider raw and canonical coincide, so nothing misbehaves today; for the first provider whose canonical form differs from the cookie value (exactly theCanonicalizingProvidercase this PR's own test proves at mint), identify misses the row written at mint, and a withdrawal tombstone lands on a key no live row uses, so the revocation never takes effect. Proposed fix: compute the canonical key once inEcContext(for example anec_kv_key()accessor derived from the selected provider) and use it in identify, the finalize tombstones, and EID ingestion - or amend the spec to state that reads and withdrawals become canonical-form-routed only when the first canonicalizing provider ships, and track that as a follow-up. -
❓ Spec says an unrecognized cookie value is "never used or egressed", but the proxy forwarding paths egress it. Section 3's Recognize row states that a value the selected provider does not recognize "is never used or egressed."
append_ec_id(proxy.rs:1263) andhandle_first_party_click(proxy.rs:1609) forward the rawts-eccookie /x-ts-echeader value to origin and click-target URLs throughedge_cookie::get_ec_id, which checks only the character/length allowlist - so a foreign-coded value (zz00~...), or any cookie in a stateless (no-provider) deployment, is egressed on those paths. The looseness predates this PR, but the PR introduces the spec claim. Which should change - the spec (scope the guarantee to the EC lifecycle paths and note the proxy forwarding exception) or the code (route those call sites through provider ownership)? -
🤔 Cluster prefix listing splits across the envelope migration. Section 3 says the pre-epic IP-cluster prefix listing "continues unchanged." Fresh mints are now keyed
hmac~<hash>.<suffix>, soevaluate_cluster's prefix (ec_hash,ec/kv.rs:715) becomeshmac~<hash>for coded rows while legacy rows still list under the bare<hash>. Two rows for the same client IP that straddle the envelope migration therefore never count each other, andcluster_size(a NAT/fraud signal in identify responses) undercounts while both populations coexist. Worth a sentence in the spec, and possibly a follow-up to bridge the count during the migration window. -
♻️ Magic strings
"hmac"/"none"are scattered across four call sites (Ec::validate_provider_selection,build_provider,provider_owns_id'sprovider.id() == "hmac", andHMAC_PROVIDER_CODEinec/generation.rs). A typed selector, for exampleenum EcProviderSelection { None, Hmac, Vendor(String) }with a custom deserializer (vendor keys are open-ended, so a catch-all variant is needed), would centralize the vocabulary before #1044 adds more built-ins. Non-blocking: the string form works and is startup-validated. -
🤔
RequestInfoaccessors have no production consumer in this PR.path(),query(),query_param(),header_names(),user_agent(), andheader()are supplied by production code but consumed only by tests in this PR (the HMAC provider reads onlyclient_ip()). The spec's own minimalism rule (section 4) requires a production caller in the same change that introduces a method; the consumers arrive later in the stack. For a stacked series this can be acceptable, but the spec should say which PR consumes each accessor, or the accessors should land with their consumers. -
🤔 Spec revision followed the implementation. The spec is commendably candid that it is the 2026-07-31 draft "revised against the implementation" with a revision-record table, and that table is genuinely useful. The process consequence is worth naming, though: when the normative spec is restated to match landed code, divergences become ratifications rather than decisions, and questions like the extension-model one above surface at review time instead of design time. For the remaining PRs in the series, it would serve the spec-first intent better to land spec changes ahead of the implementing PR and let review happen against the spec before the code exists.
-
📌 Operator guides still document
[ec] passphraseas the current form.docs/guide/configuration.md:1933,docs/guide/key-rotation.md:31,docs/guide/error-reference.md:72, plusec-setup-guide.md/edge-cookies.md/fastly.mdpredate the provider layout, the deprecation, and the new stateless default intrusted-server.example.toml. A docs pass is needed in this series; a follow-up PR is fine.
CI Status
- cargo fmt: PASS (required)
- cargo test: PASS (required)
- format-docs: PASS (required)
- format-typescript: PASS (required)
- cargo test (axum native): PASS
- cargo test (cloudflare native + wasm32-unknown-unknown check/build): PASS
- cargo check/build/test (spin native + wasm32-wasip1): PASS
- cargo test (cross-adapter parity): PASS
- cargo test (ts CLI, native): PASS
- cargo check (cloudflare native + wasm32-unknown-unknown): PASS
- vitest: PASS
- Analyze (rust): PASS
- Analyze (javascript-typescript): PASS
- Analyze (actions): PASS
- CodeQL: PASS
- prepare integration artifacts: PASS
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- browser integration tests: PASS
| log::warn!( | ||
| "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ | ||
| set [ec] provider = \"hmac\"" | ||
| ); | ||
| self.provider = Some("hmac".to_owned()); | ||
| self.providers.hmac = Some(HmacProviderConfig { passphrase }); | ||
| Ok(()) |
There was a problem hiding this comment.
🔧 wrench - The advertised 32-byte passphrase minimum is bypassed on the deprecated form. finalize_deserialized runs derive validation before migrate_legacy_ec_layout(), and this deprecated field no longer carries a #[validate] attribute, so [ec] passphrase = "short" (or an empty value) migrates and starts successfully. Reproduced with a scratch test: Settings::from_toml returns Ok for a legacy 5-byte passphrase. That contradicts the PR description ("enforced wherever the passphrase is configured") and the commit message. Validating inside the migration keeps the enforcement self-contained for every construction path:
| log::warn!( | |
| "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ | |
| set [ec] provider = \"hmac\"" | |
| ); | |
| self.provider = Some("hmac".to_owned()); | |
| self.providers.hmac = Some(HmacProviderConfig { passphrase }); | |
| Ok(()) | |
| Self::validate_passphrase(&passphrase).map_err(|err| { | |
| Report::new(TrustedServerError::Configuration { | |
| message: format!( | |
| "[ec] passphrase (deprecated) is invalid ({err}): use a random secret \ | |
| of at least {} bytes, placed in [ec.providers.hmac]", | |
| Self::MIN_PASSPHRASE_LENGTH, | |
| ), | |
| }) | |
| })?; | |
| log::warn!( | |
| "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ | |
| set [ec] provider = \"hmac\"" | |
| ); | |
| self.provider = Some("hmac".to_owned()); | |
| self.providers.hmac = Some(HmacProviderConfig { passphrase }); | |
| Ok(()) |
Verified: with this applied, the legacy short passphrase fails startup, and the full local gate passes.
| #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] | ||
| pub struct HmacProviderConfig { |
There was a problem hiding this comment.
🔧 wrench - The spec (section 6) states deny_unknown_fields is on "both built-in provider config structs", but this struct has no such attribute, so [ec.providers.hmac] passphrase = "..." typo_key = "x" is silently accepted. (A typo'd block name is caught by the stray-block rule; a typo'd key inside the block is not.)
| #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] | |
| pub struct HmacProviderConfig { | |
| #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] | |
| #[serde(deny_unknown_fields)] | |
| pub struct HmacProviderConfig { |
Verified: with this applied, an unknown key in the hmac block fails startup, and the full local gate passes.
| "hmac" => ec | ||
| .providers | ||
| .hmac | ||
| .as_ref() | ||
| .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), |
There was a problem hiding this comment.
♻️ refactor - When provider = "hmac" is selected but the block is absent, this arm returns Ok(None) and the deployment silently runs stateless. Settings validation rejects that configuration at startup, but if this seam is ever reached with such a config (programmatic Settings, a future construction path), the result is the exact "silent identity outage" the spec's failure-mode table exists to prevent. The vendor arm fails loudly; this arm should too:
| "hmac" => ec | |
| .providers | |
| .hmac | |
| .as_ref() | |
| .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), | |
| "hmac" => match ec.providers.hmac.as_ref() { | |
| Some(config) => Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _), | |
| // Settings validation rejects a selected provider with no block; | |
| // if that is bypassed, fail loudly rather than silently running | |
| // stateless. | |
| None => { | |
| return Err(Report::new(TrustedServerError::EdgeCookie { | |
| message: "Edge Cookie provider `hmac` is selected but [ec.providers.hmac] \ | |
| is not configured" | |
| .to_owned(), | |
| })); | |
| } | |
| }, |
| if !ec_id_has_only_allowed_chars(&ec_id) { | ||
| return Err(Report::new(TrustedServerError::EdgeCookie { | ||
| message: format!( | ||
| "Provider `{}` produced an identifier that is empty, over {} bytes, or outside the cookie-safe alphabet", |
There was a problem hiding this comment.
⛏ nitpick - This string carries a 22-space run (a missing \ line continuation), so the logged error reads "...bytes, or outside the cookie-safe alphabet".
| "Provider `{}` produced an identifier that is empty, over {} bytes, or outside the cookie-safe alphabet", | |
| "Provider `{}` produced an identifier that is empty, over {} bytes, or \ | |
| outside the cookie-safe alphabet", |
| } | ||
| } | ||
|
|
||
| pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { |
There was a problem hiding this comment.
♻️ refactor - This trait has no doc comment: the paragraph written for it ("A strategy for deriving an Edge Cookie identifier...") is fused into the doc block of ProviderCode above (lines 58-77), where it reads as part of that struct's documentation. The stray text is also stale: it says a provider returns Ok(None) from generate, but generate returns GeneratedEdgeCookie { id: None }. Apply manually (two non-contiguous edit sites, so this cannot be a single suggestion): move the strategy paragraph here, reword the Ok(None) sentence to the id: None semantics, and leave ProviderCode with only its own registry-code doc.
| // Accept the coded form (any provider's `{code}~value` within the global | ||
| // identifier bounds) and the legacy bare HMAC form. Provider-aware | ||
| // ownership lives in `EcContext`; this helper only reads the string. | ||
| let ec_id = parsed |
There was a problem hiding this comment.
⛏ nitpick - This pub fn get_ec_id has no callers anywhere in the workspace (proxy.rs and testlight.rs use edge_cookie::get_ec_id), yet this PR loosened its filter to accept any {code}~ value without an ownership check against the selected provider. A future caller picking it up would adopt foreign-coded identifiers that EcContext deliberately treats as absent. Either delete the function or align its filter with provider_owns_id.
| //! Edge Cookie identity providers. | ||
| //! | ||
| //! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. Providers are | ||
| //! wired by dependency injection: a provider's constructor takes the services it |
There was a problem hiding this comment.
⛏ nitpick - The module doc says a provider's constructor takes the services it needs, with RequestInfo as the example, but RequestInfo is passed at generate call time, not at construction; the opening sentence is also garbled ("...for the client IP) (the adapter, through [build_provider]) supplies instances per request."). Same constructor-injection claim in evidence.rs lines 3-7. Worth a small rewrite so the first thing a vendor implementer reads matches the trait signature.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Reviewed the pluggable Edge Cookie provider changes at 0f5c063214ba1d46478311851f08fe9b10c2ccf8. I am requesting changes based on the inline findings. This review includes one P1, three P2s, and one non-blocking migration clarification. cargo test-fastly and all 18 GitHub checks passed at the reviewed head; these findings concern runtime and provider-contract behavior rather than test failures.
| .filter(|provider| provider.id() == other) | ||
| .map(|provider| Box::new(SharedProvider(provider)) as _); | ||
| if provider.is_none() { | ||
| return Err(Report::new(TrustedServerError::EdgeCookie { |
There was a problem hiding this comment.
P1: Portability adapters swallow unavailable-provider errors
build_provider correctly returns an error here, but Axum, Cloudflare, and Spin catch it from read_from_request_with_geo and replace the EC context with EcContext::default(), so the request continues without identity. That contradicts the provider contract, which says an unavailable required service or injected provider stops the request. Fastly already propagates the error. Please return an error response in those adapters or reject the selection while building adapter state, and add a regression test for an uninjected provider on each adapter.
| // generation (for example to request more client evidence). This is empty | ||
| // unless a provider produced headers, so it is safe on every path. | ||
| for (name, value) in ec_context.response_headers() { | ||
| response.headers_mut().insert(name, value.clone()); |
There was a problem hiding this comment.
P2: Provider response effects can overwrite core-managed state
These headers are inserted without checking names or cookie ownership. A provider can return Set-Cookie: ts-ec=..., including when it returns no identifier, and bypass core's identifier validation, graph-write requirement, and managed EC cookie code. It can also overwrite reserved x-ts-* or response-framing headers. Providers may legitimately need their own evidence cookies, so banning every Set-Cookie would be too broad. Please validate these effects or expose a typed response API that reserves the managed ts-* cookie names, the x-ts-* namespace, and framing or hop-by-hop headers while allowing provider-owned cookies.
| let mut parts = value.split('.'); | ||
| let bare = match split_provider_code(value) { | ||
| (Some(code), bare) if code == HMAC_PROVIDER_CODE => bare, | ||
| (Some(_), _) => return false, |
There was a problem hiding this comment.
P2: Partner paths reject identifiers from the next provider
is_valid_ec_id explicitly rejects every provider code except hmac, and pull sync, batch sync, and the admin lookup all call it. This is already concrete in the stacked work: PR #1044 adds the hs00~ host-signal provider without changing these consumers, so its valid identifiers work in organic read and write paths but are skipped or rejected by all three partner and diagnostic paths. Please separate global cookie bounds from provider-specific validation, dispatch validation and KV normalization by provider code, and cover a non-HMAC identifier in pull sync, batch sync, and admin lookup tests.
| // guards so a stateless deployment on a host with no client IP does not | ||
| // log spurious errors. The provider reads it borrowed at generate time | ||
| // (see [`generate_with_provider`]), so nothing is cloned here. | ||
| if self.client_ip.is_none() { |
There was a problem hiding this comment.
P2: Generic generation requires client IP before calling the provider
This check rejects the request before the selected provider can decide whether it needs client IP. RequestInfo::client_ip already defines an empty string as the unavailable state, and providers are meant to read only the request evidence they need. The generic check therefore blocks header-, cookie-, query-, and client-derived providers that can operate without IP. Please move the requirement into HmacProvider and any other provider that uses IP, then pass the documented empty value to providers that do not.
| /// carries, with the value part accepted by that provider's | ||
| /// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier | ||
| /// (no code prefix) belongs only to the built-in HMAC provider, which | ||
| /// dual-reads its pre-envelope form for one release cycle so deployed cookies |
There was a problem hiding this comment.
Non-blocking: Define when the bare HMAC reader can be removed
This comment promises one release cycle of bare-HMAC compatibility, but returning users do not have their bare cookie rewritten and the cookie lifetime is one year. The current code is safe while this reader remains. Before scheduling its removal, please define a retirement condition based on the maximum cookie and graph-row lifetime plus rollout skew and observed legacy-reader traffic. Otherwise, remove the one-release wording and keep the reader.
…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)
The pluggable-providers spec required, in its provider-switching section,
that switching must not strand the identities the previous provider
minted and "above all must not make a later opt-out unable to revoke
them". It then claimed old cookies stay recognized after a switch
whenever the newly selected provider accepts their shape.
That claim is false and cannot be made true here. Ownership is decided on
the `{code}~` prefix before any provider is asked about shape, and the
check is enforced twice: `AcceptedProviders::owner` dispatches on the
code, and `canonical_kv_key` re-checks the derived key through
`provider_owns_id`. So a newly selected provider rejects every identifier
the previous one minted, whatever its shape. The new test drives this and
shows the result: after a switch the retired identifier is never adopted,
withdrawal still expires the browser cookie, but the retired provider's
identity-graph row keeps `consent.ok = true` and is never tombstoned. It
then sits for the one-year entry TTL.
I changed the spec rather than the code. The mechanism the spec itself
names for carrying identities across a switch is the `legacy_providers`
reader list, which the same section marks as deferred to the migration
spec, and `AcceptedProviders` is already built as the seam for it. Even
once it lands the requirement would not hold on its own, because it would
depend on the operator listing the retired provider, so an unconditional
guarantee was never something the code could provide. The old wording
also contradicted section 5 of the same document, which already states
the true rule that a cookie carrying another provider's code is treated
as absent.
The replacement says plainly what a switch does to read-back, to the
browser cookie and to the graph rows, and what an operator must do about
revocation: deal with the retired provider's rows at the switch, since
they are identifiable by that provider's `{code}~` key prefix, or accept
that later withdrawals are recorded only in the browser until the TTL
expires. The `cookie_ec_kv_key` doc comment claimed the same reach the
spec did and is corrected to match.
The provider series design specs move to the spec-only PR (IABTechLab#1084) so they can be reviewed before the code that implements them. Three doc comments cited those files by repository path, which no longer resolves from this branch. Refer to each document by name instead, so the comment stays true whichever PR is read first.
The mint-rejection fix restored one collapsed continuation in ec/mod.rs and said the rest of that file was clean, which it was. The same fault exists in four more places on this branch, so fixing only the reported one leaves the pattern half addressed. Each was written across two source lines without the trailing backslash, so the source indentation became a run of spaces inside the message: ec/admin.rs:373 the invalid-EC-ID response an operator sees ec/finalize.rs:125 the skipped-response-write log line ec/provider.rs:635 the missing-client-IP error from the HMAC provider ec/pull_sync.rs:72 the skipped-dispatch log line integrations/testlight.rs:196 the no-recognized-EC-ID error The continuation is restored in each, so every message reads as one sentence. The whole of trusted-server-core was scanned for the same shape, matching runs of five or more spaces inside a string literal. The only remaining matches are TOML fixtures in settings.rs tests, where the embedded newlines are deliberate. Addresses: ec/mod.rs:444 follow-up, the same fault outside the file first reported
The commit that rewrote the module docs to match the trait signature left two item-level doc comments in the same file still describing constructor injection, so the claim that nothing passes evidence by constructor was contradicted three declarations further down. IdentityInput's doc said request data reaches a provider "through the services injected into its constructor". EdgeCookieProvider::generate's doc said the identifier is derived "from the provider's injected services". Neither matches the signature, which takes request_info: &dyn RequestInfo as a parameter and reads evidence from it. The built-in HMAC provider does exactly that at ec/provider.rs:632. Both now describe the parameter the evidence actually arrives on. The crate was searched for the same wording; the only other mention is in ec/mod.rs on a test-only helper, where it correctly describes how the provider itself is constructed rather than how request evidence reaches it. Addresses: ec/provider.rs:4 follow-up, item docs still describing constructor injection
`ProviderCode::new` is public and validated its argument with `assert!`, so any caller outside this workspace could take down a live request by passing a code that was not exactly four characters of [a-z0-9]. The doc comment claimed the panic "never" fires on a request path, which held only for as long as every caller happened to pass a literal, and nothing enforced that. A vendor Edge Cookie provider is exactly the caller the claim could not cover. `new` now returns `Option<ProviderCode>`, so it cannot panic whatever it is given, and a caller outside core has to handle a malformed code. The compile-time guarantee the codes in this workspace relied on moves into a new `provider_code!` macro, which runs the same check inside a `const` block, so a bad literal fails the build and the value it yields needs no unwrapping. Every code in the workspace, the built-in HMAC code included, now goes through the macro. Addresses: crates/trusted-server-core/src/ec/provider.rs, where `ProviderCode::new` could panic at run time while its documentation said it could not.
`resolve_named_provider` looked for a built-in provider before the one the adapter injects, so a vendor provider whose id is `hmac` was dropped in favour of core's own and nothing said so. Nothing reserved the name and nothing warned, which left an operator with a configured vendor provider that never ran and no way to see why. This is not only a missing warning. Once this work merges, IAB Tech Lab is itself a vendor shipping an HMAC provider while core still ships one, so two suppliers really can arrive under one name in a single deployment, and there is no correct way to pick between them. `build_provider` now refuses that pair through `ensure_no_name_collision` and the error names both claimants, core and the deployment's adapter, along with the contested name. The check runs before the selector is read, so selecting a different provider does not hide the clash, and because the adapters call it through `ensure_provider_available` while they build application state, an operator is told at startup rather than on the first request that happens to select the name. Addresses: crates/trusted-server-core/src/ec/provider.rs, where `resolve_named_provider` silently preferred the built-in `hmac` provider over an injected one of the same name.
`EC_RESPONSE_HEADERS` in the EC finalization module and the first four entries of `INTERNAL_HEADERS` in the constants module were the same four header names written out twice, in two files, with nothing keeping them in step. The two lists do different jobs, one is stripped from a response the request may not carry an identity on and the other is never forwarded to a third party, but every Edge Cookie output header has to be in both, so adding a fifth to one and forgetting the other would send Edge Cookie output to an origin that should never see it. `EC_RESPONSE_HEADERS` now lives once, in the constants module, and `INTERNAL_HEADERS` is assembled from it and the remaining internal names while the crate is compiled, so the Edge Cookie half cannot be edited in one place and missed in the other. EC finalization reads the same constant instead of keeping a copy. The new test in the constants module asserts the containment, the total, and that no name appears twice, so going back to two hand-written lists fails the build. Addresses: crates/trusted-server-core/src/ec/finalize.rs and crates/trusted-server-core/src/constants.rs, where one list of Edge Cookie response headers was maintained by hand in two places.
The composition root resolved `[ec] provider` and threw the provider away, keeping only the knowledge that the selection could be satisfied, and then the request path resolved the same settings again to get a provider it could use. On the Fastly, Cloudflare and Spin adapters that is twice for every request, because those three run a fresh instance per request and rebuild application state each time, which was confirmed by reading `run_app` in the matching edgezero adapters. The composition root now keeps what it resolved, in `AppState`, and hands the same instance to every request through the new `RuntimeServices::resolved_ec_provider`. Core reads it through `request_provider`, which returns the threaded instance when there is one and otherwise resolves exactly as before, so an adapter that threads nothing, the core tests and any embedder driving core directly included, keeps today's behaviour, the loud failure on a selected but uninjected provider included. Nothing about which provider is chosen changes, only how many times the choosing happens. The Axum adapter is deliberately left checking rather than keeping, because it is a long-lived process whose application state is built once at start-up, so it has no second resolution to save. Addresses: crates/trusted-server-core/src/ec/provider.rs and the Fastly, Cloudflare and Spin adapters, where `ensure_provider_available` and `EcContext::read_from_request` each built the provider once per request.
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.
0f5c063 to
11cc575
Compare
The five-PR series (IABTechLab#1043 to IABTechLab#1047) opens the identity, device and geo seams. The nine vendor integrations already in core sit behind the integration registry instead, which is a private table, so none of them can move out until that table is opened. This spec defines the one core change that opens it: public registration builders with a second input on IntegrationRegistry, browser JavaScript carried on the registration, startup validation as a hook, the same treatment for auction providers and the bid renderer contract, and neutral replacements for the two places where a vendor reaches into core. It then sets out the migration of all nine existing integrations, one PR each. The change is complete in itself: after it, no vendor move needs a core change. Written against the series' tree with the file and line references for every claim about the current code. Documentation only.
The review of IABTechLab#1043 asked that spec changes land before the code that implements them, so a divergence is a decision taken in review rather than a ratification of something already merged. PRs IABTechLab#1043 to IABTechLab#1047 each carried the design document for their own step, and IABTechLab#1043 carried a 607-line spec describing device providers, geo providers, the permission model and the browser resolve endpoint, none of which is in that PR. Move all six series documents here, so this PR carries the complete normative set and no code: - 2026-07-30-pluggable-providers-design.md (from IABTechLab#1043) - provider-code-registry.md (from IABTechLab#1043) - 2026-07-30-permission-model-design.md (from IABTechLab#1045) - 2026-07-30-client-cycle-ec-resolve-design.md (from IABTechLab#1046, later revised by IABTechLab#1047) - 2026-07-30-integration-response-header-hook-design.md (from IABTechLab#1047) - 2026-07-30-provider-migration-rollout-design.md (from IABTechLab#1047) Each file is taken verbatim at the tip of the stack, so the later revisions are preserved: the provider-switching continuity section, the geo requires-signal floor, and the code-envelope paragraph IABTechLab#1047 added to the client-cycle spec. The revision-record tables are unchanged. No document's substance was edited. The only edits are to this spec's own status line, which said the PR adds one document and that the series specs land with IABTechLab#1047, and a revision-record row recording the move.
|
This response was drafted with AI assistance and checked against the branches before posting. Thank you both. Twenty observations across the two reviews. Seventeen are answered in code on this branch and three are answered in the pull request of the chain where the answer belongs, named in the Addressed elsewhere table. Each fix is separately committed, so any one can be confirmed without reading a combined diff. Two further rows in the Addressed table are not yours, being things we found while answering and fixed in the same pass. A note on scope. Some of your observations reach past this PR into the ones before and after it, which is unavoidable because the work was split into a chain. Answering only within #1043 would be more confusing, not less, so this comment answers for the whole chain and says where each answer lives. #1043 is simply the PR the review happened on. Each commit's message ends with an The branch is rebased onto We also run the core library suite natively, at 2,463 tests, and #1047 adds that run to One CI note, and a small ask. CodeQL flags "Cleartext logging of sensitive information" on #1044 to #1047 and #1094. It is a false positive and we would ask you to dismiss it, since the alerts belong to this repository and we cannot. The passphrase it traces is held in a Where each piece is, and what changed between the PRsTwo things moved since Aram's review on 27 August that are not visible from this PR alone. The seam that the architectural finding asks this work to lean on now These are one block, and the order below is the order they should merge in. Splitting them is what creates the legacy this work exists to stop, because each one on its own leaves the core carrying a shape the next one removes. The last item is the point of the whole exercise, an unmerged vendor change landing without adding to the core, so no further legacy is added rather than removed later.
Rowena asked on 27 August whether #1044 must follow #1043, or whether #1045 could follow #1043 instead. The answer is that #1045 cannot move ahead of #1044, and here is the reason rather than the assertion. The permission model needs a jurisdiction baseline, which is the country whose rules apply when the geo lookup returns nothing. That baseline lives on the The rest of the order is the same kind of dependency rather than preference. #1046 is the browser-set path for an identity #1043 defines, and #1047 documents behavior the four before it introduce, so documenting it earlier would describe code that is not there. If a different order would help you, tell us what you need and we will say honestly whether it can be done, because we would rather rework the split than have the whole thing wait on the shape we happened to choose. Items 2 to 7 are one ordered chain, not six branches beside each other. #1043 is against The order we suggest is #1084 first, since it settles the design question and costs nothing, then #1043 to #1047 in sequence, then the implementation of #1084. That implementation is where identity, geo and device all become capabilities a registration declares, which is the architectural finding answered rather than deferred. It lands there and not here because a registration can only carry an Edge Cookie provider once that trait exists, and #1043 is what adds it, so the seam PR is the first point in the chain where both exist together. We would rather do it once, against a seam that exists, than rewrite five reviewed PRs onto a seam that did not exist when the review was written. Addressed
Addressed elsewhere in the chainEach is answered in the PR of the chain where the answer belongs rather than in this one.
Behavior changesFour, each called out deliberately rather than left to be found. Every one of them is necessary rather than incidental, and every one moves in the direction this project has already chosen, which is a core that is neutral between vendors and does nothing on a deployment's behalf that the deployment did not ask for. The last is the one an operator will feel most, so it is worth reading even if the rest are skimmed.
How providers see the requestApplying the minimalism rule to the evidence interface was the wrong call, and we are reversing it. Here is the design we are implementing instead, so the reasoning is on the record rather than arriving as a surprise in a later PR. A provider is given everything the request carries. The client IP, the User Restricting what a provider can see is the wrong lever. The right one is
That guards against a badly behaved provider twice over, without the interface deciding in advance what a vendor is allowed to look at. A permission describes what, not how, and that is the whole reason this boundary is the right one. A permission names a data use, being storage on the device, or personalized marketing. It never names a technology. There is no permission saying the User-Agent header may be read, or that a cookie may be used but local storage may not. Data protection works the same way round, because it governs the purpose data is put to rather than the mechanism used to achieve it. So restricting what a provider sees regulates the how, not the what. A provider blocked from one header can often reach the same purpose another way, and one allowed to see a header still may not use it for a purpose nobody granted. What stops the purpose is not running the provider at all, which is the first layer above. Drawing the boundary on the purpose rather than the mechanism also buys something we would like to build on. Every provider already declares the permissions its data use requires, so a build can be asked what it will do before it serves a single request. The core can emit a manifest for a given deployment listing every permission every module in it requires, derived from the modules themselves rather than from someone's notes. That is a machine-readable statement of what a deployment does with data, which is most of the work of writing a privacy notice, and it can be generated and kept current rather than maintained by hand and quietly going stale 🙂 And the claim can be checked, which is what makes it useful. A provider declaring the permissions its data use requires is, on its own, only a claim. What turns a claim into something a publisher can rely on is that the code is open, so anyone can read what a module actually does and hold it against what the module said it would do. That is a large part of what the word trusted in Trusted Server has to mean, because a trust nobody can verify is only a reputation. Checking used to be expensive enough that almost nobody did it. That has changed. An AI agent can read a module, read its declared permissions and report the difference in minutes, for very little, and can do it again on every release rather than once at onboarding. So a false declaration, or a module quietly doing more than it declared, moves from something findable in principle to something that will be found in practice. The consequence should follow the finding, and it should be plain. A vendor whose modules repeatedly do not do what they say should not have modules in this project, and should not remain a member of the organization that publishes it. Simple. That is the enforcement this model needs behind it, and it is available only because the code is open and the declarations are machine-readable. The caller is us, and it is the next step rather than part of this stack. We will use all of it, to the extent permissions allow, across the geo, device and Edge Cookie providers. We are deliberately not raising that pull request alongside these, because this stack is already a large change and a vendor module on top would make it harder to review. What that work needs is specific rather than speculative. The evidence interface #1043 carries already exposes the client IP, the User-Agent, headers read by name, header enumeration so a module sends a complete evidence set rather than working from an allowlist compiled into it, the path, and the query and its parameters, and it is whole on #1043 as of So the evidence interface is whole on #1043 as of We will prove the evidence actually arrives. A loopback provider that Two notes on sequencing. The advertise-and-gate half needs The gap this leaves, which we would like to fillThere is no conformance suite a provider can be run through. Core defends Found in
|
| The change | What it gives a publisher |
|---|---|
| A core that is neutral between vendors | No vendor's code sits inside the core everyone depends on, so no vendor's interests are built into it |
| Vendor modules owned and maintained by the vendor, with a maintainer recorded | You can see who stands behind the code carrying a vendor's name, and hold them to it |
| Permissions expressed as what data is used for, not which technology is allowed | The rule survives the next technology, because it never named one. It is also the way data protection law is written |
| A permissions manifest for a build | A deployment can state what it will do with data before it serves a single request, which is most of a privacy notice, generated from the modules rather than written by hand |
| The same evidence available to every vendor | Nobody gets a better view of the request than anybody else, so vendors compete on what they do rather than on access |
| Declarations that are machine-readable, in code that is open | A claim can be checked against actual behavior cheaply, by anyone, on every release rather than once at onboarding |
| A conformance suite any provider can be run through | A vendor can show their module behaves before shipping it, and the project can show it too |
| Startup that fails rather than falls back quietly | A misconfigured deployment stops instead of doing something nobody asked for and nobody notices |
Those are reasons for the wider ecosystem to engage with Trusted Server, not just reasons for us to like it. We would much rather arrive in New York with them shipped and running than describe them as a plan.
|
Feedback left on spec #1084 Implementation should match spec after spec feedback is resolved. |
First of five stacked PRs decomposing #838 as requested in the #986 review, where each PR carries one feature and its design spec. This PR is the Edge Cookie provider seam. The stack order is #1043, #1044, #1045, #1046, #1047. Each PR's own change is visible by comparing its head branch to the previous PR's head branch, and this first PR is independently mergeable to
main.Spec: docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, which is the Tech Lab 2026-07-31 draft revised to match this implementation, with a revision-record table listing every divergence and why. The spec covers both this PR (the EC seam) and #1044 (device and geo selection).
What this PR does
Edge Cookie identity generation becomes a selectable provider behind the
EdgeCookieProvidertrait incrates/trusted-server-core/src/ec/provider.rs, with the existing HMAC implementation as the built-in and configuration selecting it.[ec] providernames a block under[ec.providers.<key>]. Omitted means stateless with no Edge Cookie, andprovider = "none"spells the same choice explicitly (rejected if provider blocks are left configured). A selected provider with no block, an unreferenced stray block, and an unknown key in a block all fail at startup, so misconfiguration is loud.[ec] passphraseform still starts. It migrates toprovider = "hmac"plus[ec.providers.hmac]with a deprecation warning, so a fleet can move configuration and binaries independently. Both forms together are rejected.[A-Za-z0-9._~-], enforced at mint, cookie read-back, and cookie write. A violating identifier is rejected outright and never rewritten, so the cookie value and the identity-graph key can never silently diverge (the previous sanitize-by-stripping path is removed).accepts_id, and the identity-graph key through itsnormalize_id_for_kvcanonical form, so an opaque vendor identifier round-trips byte-for-byte. One test proves a non-default provider round-trips verbatim and another proves the graph is keyed by the canonical form.[ec.providers.<key>]blocks are captured as raw values in core and deserialized by the adapter that injects the vendor provider, so core never names a vendor.{code}~value, checks the code at read-back, and keys the identity graph with it, so identifiers from different providers can never collide and switching providers cannot silently adopt another provider's identities. The built-in provider mintshmac~<hash>.<suffix>and dual-reads its pre-envelope bare form for one release cycle, so deployed cookies keep working.hmac~identifier was skipped by pull sync, refused by batch sync and answered 400 by the admin lookup while CI stayed green on a bare seeded cookie.is_valid_ec_idnow accepts thehmac~envelope as well as the legacy bare form and rejects any other provider's code,normalize_ec_id_for_kvkeeps the envelope so the key matches the one written at mint, and each of the three call sites has a test with a coded identifier (commit6f15e50c0).Breaking change
A minimum HMAC passphrase length of 32 bytes is now enforced wherever the passphrase is configured. A shorter passphrase that previously started will fail startup validation with a direct message.
How it was verified
Full local gate on this branch, all clean.
cargo test-fastly(core plus adapter suites),cargo test-axum,cargo test-cloudflare,cargo test-spin, the integration parity suite,cargo fmt --check, and all six per-target clippy aliases.Framing
Privacy is a spectrum, and this change is neutral infrastructure. It does not decide whether identity is created, it makes that decision configurable and inspectable, and the deployer selects a provider (or none) according to the laws and policies that apply to them. Trust comes from that flexibility being respected and visible in configuration rather than hard-coded.
References #777. Decomposes #838 (kept as a draft reference until this series merges). Spec baseline from #986.
Produced with AI assistance under James Rosewell's direction, and flagged here so reviewers know to apply the usual scrutiny.