diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c0dcdf1673..3f9c95386c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -66,8 +66,12 @@ jobs:
run: BID_DELAY=3 ./scripts/template-cache-local-test.sh purge
test-axum:
- name: cargo test (axum native)
- runs-on: ubuntu-latest
+ name: cargo test (axum native, ${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
@@ -110,16 +114,29 @@ jobs:
run: |
cargo test --package trusted-server-openrtb-codegen --target "$(rustc -vV | sed -n 's/host: //p')"
+ # The core library's unit tests otherwise run only on the WebAssembly
+ # targets, which build with panic=abort, so their harness stops at the
+ # first failing test and reports every later one as never run. A run that
+ # looks like a single failure can hide many more. This native run reports
+ # them all at once, which is what makes a red build readable.
+ - name: Run host-target core library tests
+ run: |
+ cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/host: //p')"
+
- name: Verify Fastly WASM release build
run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
+ # The guard's two steps are written for bash and run on Linux alone,
+ # because the WASM they check is the same on every system.
- name: Check that no permission policy is compiled into the Fastly WASM
+ if: runner.os == 'Linux'
run: ./scripts/check-no-compiled-policy.sh target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm
# The sample carries both markers the guard looks for, so a guard that
# stops finding them passes the sample and fails the job here. Exit 1 is
# the refusal; exit 2 means the guard could not run.
- name: Check that the policy guard refuses a file that carries a policy
+ if: runner.os == 'Linux'
run: |
status=0
./scripts/check-no-compiled-policy.sh config/permissions/sample.yaml || status=$?
@@ -129,8 +146,12 @@ jobs:
fi
test-cloudflare:
- name: cargo check (cloudflare native + wasm32-unknown-unknown)
- runs-on: ubuntu-latest
+ name: cargo check (cloudflare native + wasm32-unknown-unknown, ${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index ce2b3a6935..17ed0d9122 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@
/spin
/spin.sig
+# logs
+*.log
+
# EdgeZero local KV store (created by edgezero-adapter-axum framework)
.edgezero/
/dist/prebid/
diff --git a/crates/geo/README.md b/crates/geo/README.md
index 1f556812d2..0847dc6667 100644
--- a/crates/geo/README.md
+++ b/crates/geo/README.md
@@ -12,8 +12,9 @@ implementing the `PlatformGeo` trait from `trusted-server-core`:
Whatever the source, a module returns the same `GeoInfo` coding. The country
is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision
-code with no country prefix (`CA`), so the Fastly and other modules feed the
-same downstream rules without translation.
+code with no country prefix (`CA`). The permission model keys its country and
+region rules on these codes, matched case-insensitively, so the Fastly and
+other modules feed the same rules without translation.
The platform-neutral `PlatformGeo` trait and `DisabledGeo` both live in
`trusted-server-core`. With no `[geo] module`, or `module = "platform"`, the
diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs
index c02bf79ba8..acafcdd31b 100644
--- a/crates/trusted-server-core/src/ec/mod.rs
+++ b/crates/trusted-server-core/src/ec/mod.rs
@@ -1653,6 +1653,7 @@ pub(crate) mod tests {
#[derive(Debug, Default)]
struct EvidenceCapturingModule {
seen: std::sync::Mutex>,
+ seen_client_ip: std::sync::Mutex >,
}
impl EdgeCookieModule for EvidenceCapturingModule {
@@ -1672,6 +1673,11 @@ pub(crate) mod tests {
let query_id = request_info.query_param("id").unwrap_or_default();
let cookie = request_info.header("cookie").unwrap_or_default().to_owned();
*self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie));
+ *self
+ .seen_client_ip
+ .lock()
+ .expect("should lock the seen client IP") =
+ Some(request_info.client_ip().to_owned());
Ok(GeneratedEdgeCookie {
id: Some("evidence-ec".to_owned()),
response_headers: Vec::new(),
@@ -1847,7 +1853,10 @@ pub(crate) mod tests {
// The requirement for a client IP belongs to the module that uses
// one, not to core. A module deriving identity from the request
// query and cookies runs on a host that cannot determine a client IP,
- // and receives the documented unavailable value, the empty string.
+ // and still creates an identifier. This also pins what the module is
+ // handed in that case, which is the documented unavailable value rather
+ // than something else, because a module cannot decide how to behave
+ // without knowing what absence looks like.
let module = Arc::new(EvidenceCapturingModule::default());
let mut settings = create_test_settings();
settings.ec.module = Some(EcModuleSelection::from("evidence"));
@@ -1874,6 +1883,15 @@ pub(crate) mod tests {
Some("t0ev~evidence-ec"),
"the identifier should be committed with no client IP available"
);
+ assert_eq!(
+ module
+ .seen_client_ip
+ .lock()
+ .expect("should lock the seen client IP")
+ .clone(),
+ Some(String::new()),
+ "a host that cannot determine a client IP should hand the module the documented unavailable value, which is the empty string"
+ );
}
#[test]
diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs
index fe6be30d61..826735da56 100644
--- a/crates/trusted-server-core/src/evidence.rs
+++ b/crates/trusted-server-core/src/evidence.rs
@@ -7,13 +7,35 @@
//! per-request is stored on the module, so the same module serves every
//! request.
//!
-//! These traits are those views. Request-scoped data outlives the live request
-//! only when snapshotted, so an implementation owns its data where needed.
-//! [`BorrowedRequestInfo`] is the borrowed view core builds on the request
-//! path, and [`OwnedRequestInfo`] is the built-in owned snapshot.
+//! These traits are the service interfaces. Concrete implementations live with
+//! the host or vendor that supplies them (for example `FastlyHostSignals` in
+//! `trusted-server-device-fastly`). An injected service outlives the borrow of
+//! the live request, so an implementation owns its data ([`OwnedRequestInfo`] is
+//! the built-in owned snapshot, while [`BorrowedRequestInfo`] is the borrowed
+//! view core builds on the request path and must not outlive the request).
+//!
+//! [`RequestInfo`] describes what a request carries, not what code in this
+//! repository happens to read today, so it carries the whole of it whether or
+//! not anything here reads it yet. What a module may see is not the control.
+//! What a module may do with what it sees is the control, and that is the
+//! permission model.
use http::HeaderMap;
+/// Host-computed client signals that are not carried in request headers.
+///
+/// A host that can compute them supplies an implementation (Fastly exposes the
+/// TLS JA4 and HTTP/2 signals). A module that needs them takes
+/// `Arc` in its constructor, and on a host that supplies none
+/// the module cannot be built and the request stops.
+pub trait HostSignals: Send + Sync + core::fmt::Debug {
+ /// The full JA4 TLS signal, or `None` when unavailable.
+ fn ja4(&self) -> Option<&str>;
+
+ /// The raw HTTP/2 SETTINGS signal, or `None` when unavailable.
+ fn h2(&self) -> Option<&str>;
+}
+
/// Read-only access to the current request's basic information.
///
/// The request data any host can supply: the normalized client IP, the
@@ -253,20 +275,6 @@ impl RequestInfo for BorrowedRequestInfo<'_> {
}
}
-/// Host-computed client signals that are not carried in request headers.
-///
-/// A host that can compute them supplies an implementation (Fastly exposes the
-/// TLS JA4 and HTTP/2 signals). A module that needs them takes
-/// `Arc` in its constructor, and on a host that supplies none the
-/// module cannot be built and the request stops.
-pub trait HostSignals: Send + Sync + core::fmt::Debug {
- /// The full JA4 TLS signal, or `None` when unavailable.
- fn ja4(&self) -> Option<&str>;
-
- /// The raw HTTP/2 SETTINGS signal, or `None` when unavailable.
- fn h2(&self) -> Option<&str>;
-}
-
#[cfg(test)]
mod cookie_tests {
use http::HeaderMap;
diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs
index 6a2fcf2721..092b46c6aa 100644
--- a/crates/trusted-server-core/src/settings.rs
+++ b/crates/trusted-server-core/src/settings.rs
@@ -8003,6 +8003,21 @@ source_domain = "partner.example.com"
);
}
+ #[test]
+ fn from_toml_and_env_loads_while_the_git_version_variable_is_set() {
+ temp_env::with_var(
+ format!(
+ "{}{}GIT_VERSION",
+ ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR
+ ),
+ Some("v1.0.0"),
+ || {
+ Settings::from_toml_and_env(&crate_test_settings_str())
+ .expect("should load settings while the build's git version variable is set");
+ },
+ );
+ }
+
#[test]
fn test_override_env() {
let toml_str = crate_test_settings_str();
diff --git a/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts
index 4e24335b1a..e7af850bc3 100644
--- a/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts
+++ b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts
@@ -3,7 +3,8 @@
// Client and server share one fixed, known word. When the resolved marker is
// absent, this posts that word to the resolve endpoint. With the `client_fixed`
// module selected, the server verifies the word and, on a match, persists the
-// identity-graph row and sets the word as an HttpOnly Edge Cookie on the
+// identity-graph row and sets the coded form of the word (cfix~an-ec) as an
+// HttpOnly Edge Cookie on the
// response, together with a non-HttpOnly marker cookie. The Edge Cookie itself
// is HttpOnly, so this script can never see it; the marker is what tells it a
// resolve already succeeded, so it does not post again on every page view.
diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md
index 817b01e574..c5a01ad6a6 100644
--- a/docs/guide/api-reference.md
+++ b/docs/guide/api-reference.md
@@ -428,11 +428,11 @@ mappings are accepted even though the second A appears after B in the input.
Resolve endpoint for client-side Edge Cookie modules. The page posts a value that the module verifies and creates the Edge Cookie value. Used only when a client-side module is selected (for example the `client_fixed` demo). Server-side modules such as HMAC do not use it.
-**Auth:** None. This is a first-party POST from the page. The module is responsible for verifying the posted value before trusting it.
+**Auth:** None, but the request must carry an `Origin` on the publisher's own domain (a foreign or missing `Origin` answers `403`). This is a first-party POST from the page. The module is responsible for verifying the posted value before trusting it.
**Request Body:** the module's value, opaque to the core. For the `client_fixed` demo this is the fixed known word sent as `text/plain`.
-**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) and the status is `200`. When the gate is closed, no client-side module is configured, or the module produces no identifier, the response is `204` with no cookie. An oversized body is rejected with `413`.
+**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the identifier is written to the identity graph first, then the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) together with the `ts-ecr` marker cookie the page script can read, and the status is `200`. When the gate is closed, no client-side module is configured, no identity graph is available, or the module produces no identifier, the response is `204` with no cookie. Rejections: `403` for a missing or foreign `Origin`, `415` for a content type other than `text/plain` or `application/json`, `413` for an oversized body, `400` when the created identifier is outside the identifier bounds, `409` when the request already carries a different identity, and `503` when the identity-graph write fails. Every response the handler builds carries `Cache-Control: no-store`.
---
@@ -913,7 +913,7 @@ curl -u 'admin:' \
### GET /\_ts/admin/eids
-Parses the request's `ts-eids` and `sharedId` cookies and previews which configured partner IDs cookie ingestion would match or drop. It performs request inspection only: it does not read or write KV and is available on every adapter. The preview lists candidate matches from the cookies alone; it does not apply the TCF Purpose 1 + 4 consent gate on identity-graph writes, does not see `/auction` request-body EIDs, and does not account for partner UIDs already stored in KV.
+Parses the request's `ts-eids` and `sharedId` cookies and previews which configured partner IDs cookie ingestion would match or drop. It performs request inspection only: it does not read or write KV and is available on every adapter. The preview lists candidate matches from the cookies alone; it does not apply the permission gate on identity-graph writes, does not see `/auction` request-body EIDs, and does not account for partner UIDs already stored in KV.
After successful authentication this endpoint always returns `200 OK`; missing or malformed cookies are represented by `cookie_present`, `sharedid_present`, and `parse_error`. The `ingest.matched` and `ingest.unmatched` arrays show the ingestion preview. Each unmatched entry contains its `source` and either a `no_partner` reason when no configured partner recognizes it or `no_valid_uid` when the partner exists but every supplied UID is empty or exceeds the storage limit.
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
index 767b4bd8a7..97cb55004c 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -26,6 +26,9 @@ origin_url = "https://origin.publisher.com"
proxy_secret = "publisher_proxy_secret"
[ec]
+module = "hmac"
+
+[ec.hmac]
passphrase = "ec_passphrase"
[permissions]
@@ -48,7 +51,8 @@ export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com
export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com
# Secret overrides, when needed, are key names—not secret values.
export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret
-export TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase
+export TRUSTED_SERVER__EC__MODULE=hmac
+export TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase
# Replace the rejected placeholder values in trusted-server.toml, then validate.
ts config validate --permissions standard.yaml
@@ -196,6 +200,7 @@ fail and the service will return its startup-error response.
| File | Purpose |
| --------------------- | ------------------------------- |
| `trusted-server.toml` | Main application configuration |
+| `permissions.yaml` | Country/region permission rules |
| `fastly.toml` | Fastly Compute service settings |
| `.env.dev` | Local development overrides |
@@ -208,10 +213,13 @@ fail and the service will return its startup-error response.
| `[consent]` | Consent interpretation, forwarding, and conflict resolution |
| `[creative_opportunities]` | Server-side page ad opportunities and templates |
| `[debug]` | Explicit non-production diagnostics |
+| `[device]` | Device classification module selection |
| `[ec]` | Edge Cookie identity, persistence, and partner sync |
+| `[geo]` | Geolocation module selection |
| `[[handlers]]` | Ordered HTTP Basic-auth rules |
| `[image_optimizer]` | Reusable Fastly Image Optimizer profiles |
| `[integrations.*]` | Typed partner and browser integration settings |
+| `[permission-signal]` | Permission signal schemes and the order they run in |
| `[permissions]` | The publisher's permission policy file, by name |
| `[proxy]` | Proxy allowlist, TLS policy, and asset routes |
| `[publisher]` | Publisher domain, origin, and proxy signing key |
@@ -235,6 +243,9 @@ origin_url = "https://origin.publisher.com"
proxy_secret = "publisher_proxy_secret"
[ec]
+module = "hmac"
+
+[ec.hmac]
passphrase = "ec_passphrase"
[permissions]
@@ -649,17 +660,30 @@ Legacy consent-store records are not read or migrated into `ec.ec_store`. Their
### `[ec]`
+| Field | Type | Required | Description |
+| ------------------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `module` | String or null | No | Name of the active Edge Cookie module: `"hmac"` (built-in), `"host_signals"` (opt-in), or `"none"` (explicitly stateless). Omit to run statelessly with no Edge Cookie. The `"client_fixed"` demo needs the `client-fixed-demo` build feature |
+| `resolve_allowed_origins` | Array | No | Extra exact origins allowed to POST the client resolve endpoint, beyond `https://{publisher.domain}` |
+| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state |
+| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response |
+| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions |
+| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps |
+| `partners` | Array | No | Static partner registry entries |
+
+Each module that has settings is configured in its own `[ec.]` block, and the `module` selector names which block is active. A block may set `implementation = ""` to say which module it configures, which makes the block name a label of your choosing, so `module = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in module under a name that means something to your deployment. A module from a crate is named by its folder below `crates/`, so it may be written in full, as `edgecookie.`, or with that type folder left off, and core's own modules such as `hmac` take bare names. A name is parts joined by `.`, each of lower case letters, digits, `_` or `-`.
+
+A module has a block only when it has settings of its own. Both modules that derive an identifier at the edge take a passphrase, so selecting `hmac` or `host_signals` without its block fails at startup, while the `client_fixed` demonstration module needs no block at all. A block the selector does not name also fails at startup, so a stale block cannot sit unnoticed.
+
+### `[ec.hmac]`
+
+The built-in HMAC-over-client-IP module, keyed `hmac`.
+
`passphrase` is a key name in `trusted_server_secrets`; the resolved value must
be at least 32 bytes. Keep it stable to preserve EC identifier continuity.
-| Field | Type | Required | Description |
-| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------- |
-| `passphrase` | String | Yes | Publisher passphrase used as HMAC key |
-| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state |
-| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response |
-| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions |
-| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps |
-| `partners` | Array | No | Static partner registry entries |
+| Field | Type | Required | Description |
+| ------------ | ------ | --------------------------- | ---------------------------------------------------------- |
+| `passphrase` | String | Yes when `hmac` is selected | Secret-store key name whose resolved value is the HMAC key |
::: tip Partner keying
`source_domain` is the canonical partner key. It matches incoming OpenRTB EID `source` values and is also used as the EC KV `ids` map key.
@@ -674,9 +698,12 @@ outbound pull sync, but cannot authenticate to those inbound APIs.
```toml
[ec]
-passphrase = "ec_passphrase"
+module = "hmac"
ec_store = "ec_identity_store"
+[ec.hmac]
+passphrase = "ec_passphrase"
+
[[ec.partners]]
name = "Mocktioneer SSP"
source_domain = "mocktioneer.example"
@@ -688,15 +715,24 @@ bidstream_enabled = true
**Environment Override**:
```bash
-TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase
+TRUSTED_SERVER__EC__MODULE=hmac
+TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase
TRUSTED_SERVER__EC__EC_STORE=ec_identity_store
```
+These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environment values into the published configuration (for example test harnesses building an app-config blob). The running server reads its settings from the platform config store, so module selection changes take effect when a new configuration is pushed, not per request.
+
### Field Details
-#### `passphrase`
+#### `module`
+
+**Purpose**: Names the active Edge Cookie module by its key. Omit to run statelessly with no Edge Cookie.
-**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation.
+**Validation**: Application startup fails if the name is not a module name (parts joined by `.`, each of lower case letters, digits, `_` or `-`), if it names a key the `[ec]` section reads as its own setting, if the selected implementation needs an `[ec.]` block it has no block for, or if a block the selector does not name is configured.
+
+#### `hmac.passphrase`
+
+**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation, read when `module = "hmac"`.
**Security**:
@@ -707,6 +743,66 @@ TRUSTED_SERVER__EC__EC_STORE=ec_identity_store
**Validation**: Application startup fails if:
- Empty string
+- Shorter than 32 characters
+
+## Device Configuration
+
+Selects how a request is classified into the coarse device signals the Edge Cookie bot gate uses, mirroring the Edge Cookie module selection. These signals serve identifier gating and bot detection, not bid enrichment.
+
+### `[device]`
+
+| Field | Type | Required | Description |
+| -------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `module` | String or null | No | Key of the device-detection module. Defaults to `builtin` (User-Agent only, no host-specific call). Set `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers |
+
+The default `builtin` module classifies from the User-Agent alone and makes no host-specific call, so the default path stays host-neutral. Selecting an unknown module key fails at startup.
+
+**Example**:
+
+```toml
+[device]
+module = "builtin" # or "fastly" to add TLS and HTTP/2 evidence
+```
+
+**Environment Override**:
+
+```bash
+TRUSTED_SERVER__DEVICE__MODULE=builtin
+```
+
+## Geo Configuration
+
+Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie module selection. The resolved country also feeds the [permission model](/guide/permission-model).
+
+### `[geo]`
+
+| Field | Type | Required | Description |
+| ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `module` | String or null | No | Key of the geo module. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. |
+| `assume_single_jurisdiction` | Boolean | See description | With no geo module, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie module without a geo module acknowledges that by setting this to `true`. |
+
+No module is the default, so a default deployment is not tied to any host geo service. Selecting an unknown module key fails at startup. A failed geo lookup at request time resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively.
+
+**Example**:
+
+```toml
+[geo]
+module = "platform"
+```
+
+**Environment Override**:
+
+```bash
+TRUSTED_SERVER__GEO__MODULE=platform
+```
+
+## Module Permissions
+
+A module advertises the technical permissions its data use requires, and Trusted Server runs the module only when every required permission is set. This separates legal policy from the core, so the deployer brings the policy that decides how permissions are established. See the [Permission Model](/guide/permission-model) for the concept, the permission vocabulary, and how a request resolves.
+
+### Country and region rules (`permissions.yaml`)
+
+The country and region permission rules are defined in the publisher's permission policy file, a human-editable YAML document that the settings name in `[permissions] policy` and that `ts config push --permissions ` supplies when the service is deployed, as [Permission Policy Configuration](#permission-policy-configuration) describes. No adapter build carries a policy. The repository sample is `config/permissions/sample.yaml`, which is for testing and evaluation only and is neither a production policy nor legal advice. To change the policy, push the settings again with the changed file. The file defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule resolves at the top of the rules tree. See the [Permission Model](/guide/permission-model) for the schema and the repository sample.
## Consent Configuration
@@ -755,7 +851,7 @@ legal advice.
`[permissions]` names the publisher's permission policy file, which decides
which permissions each request has. The
[permission model](/guide/permission-model) describes what the file says. No
-build carries a policy, so every deployment names its own file and supplies it
+adapter build carries a policy, so every deployment names its own file and supplies it
with `ts config push --permissions `.
### `[permissions]`
@@ -1049,7 +1145,7 @@ Startup fails when no handler covers an admin route. The dynamic
`/_ts/admin/ec/{id}` route accepts any segment after `/_ts/admin/ec/`, and
Basic Auth runs on the raw path before routing, so coverage cannot be inferred
from ID-shaped samples: a pattern such as
-`^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level
+`^/_ts/admin/ec/hmac~[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level
matcher (`^/_ts/admin`, or `^/_ts/admin/ec/` alongside the other admin
patterns).
@@ -1899,8 +1995,7 @@ unchanged.
Persisting a resolved ID into the Edge Cookie identity graph additionally
requires a matching `[[ec.partners]]` entry whose `source_domain` equals the
-module's OpenRTB EID source, and, when a TCF signal is present, consent to
-TCF Purpose 1 and Purpose 4.
+module's OpenRTB EID source, and the storage and personalised-ad permissions.
`managed_user_ids` is an array of tables, so it cannot be set through a
`TRUSTED_SERVER__` environment variable; the scalar overlay only replaces leaves
@@ -2997,7 +3092,8 @@ Configuration is validated at startup:
**EC Validation**:
-- The `passphrase` key name is non-empty at push time
+- `module`, when set, is a module name and has the `[ec.]` block its implementation needs, and no unselected block is left configured, or startup fails
+- The `hmac.passphrase` key name is non-empty at push time
- The resolved passphrase is at least 32 bytes at runtime
- Known placeholder values are rejected after resolution
diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md
index cccb04daee..a2321309f7 100644
--- a/docs/guide/ec-setup-guide.md
+++ b/docs/guide/ec-setup-guide.md
@@ -25,9 +25,12 @@ The `replace-with-*` values below are intentionally rejected placeholders:
```toml
[ec]
-passphrase = "ec_passphrase"
+module = "hmac"
ec_store = "ec_identity_store"
+[ec.hmac]
+passphrase = "ec_passphrase"
+
[[ec.partners]]
name = "Mocktioneer SSP"
source_domain = "formally-vital-lion.edgecompute.app"
@@ -44,6 +47,7 @@ do not call either API may omit it. Provision high-entropy values under
Required behavior assumptions:
+- `module = "hmac"` selects the built-in HMAC module, and its `passphrase` lives under `[ec.hmac]`
- The value stored under `ec_passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters
- `ec_store` is linked to the active Fastly service version
- `ec_store` is the only KV-backed EC lifecycle store; it contains identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones
@@ -99,13 +103,13 @@ curl -si "${TS_BASE_URL}/" \
Look for:
-- `Set-Cookie: ts-ec=<64hex.6chars>`
+- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the module code, and a bare value with no prefix, as a browser may already hold, still resolves)
## 5) Batch Sync (S2S)
Endpoint: `POST /_ts/api/v1/batch-sync`
-Important: request field is `ec_id` (full `{64hex}.{6alnum}` value). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Within one request, valid mappings are grouped by normalized EC ID and the last valid UID for each group is applied once; unchanged UIDs are accepted without a write. Group outcomes are reported for every original mapping, and infrastructure failures abort the remaining groups. See the [API Reference](/guide/api-reference) for the complete accounting and failure contract.
+Important: request field is `ec_id` (the full value as issued, `hmac~{64hex}.{6alnum}`, with the bare form that has no prefix also accepted). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Within one request, valid mappings are grouped by normalized EC ID and the last valid UID for each group is applied once; unchanged UIDs are accepted without a write. Group outcomes are reported for every original mapping, and infrastructure failures abort the remaining groups. See the [API Reference](/guide/api-reference) for the complete accounting and failure contract.
```bash
BATCH_UID="${PARTNER_UID}-batch"
diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md
index a99bc9d120..87be81b8eb 100644
--- a/docs/guide/edge-cookies.md
+++ b/docs/guide/edge-cookies.md
@@ -8,12 +8,9 @@ permit EC use.
## Policy Posture
-Trusted Server is technology. It is neutral on policy. The Edge Cookie
-gives the deployer a cookie slot and configuration over the surrounding
-attributes. The deployer determines the policy posture based on the
-laws and contractual arrangements that apply to their deployment.
-Privacy outcomes follow from that configuration, not from the cookie
-mechanism itself.
+Trusted Server is technology. It is neutral on policy. The Edge Cookie gives the deployer a cookie slot and configuration over the surrounding attributes. The deployer determines the policy posture based on the laws and contractual arrangements that apply to their deployment. Privacy outcomes follow from that configuration, not from the cookie mechanism itself.
+
+An Edge Cookie (EC) is a first-party identifier that the built-in module derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server keeps the current EC ID in the first-party `ts-ec` cookie. For the routes that use the cookie, see the [API Reference](/guide/api-reference).
For full operational onboarding (partner configuration, batch sync, identify, and auction verification), use the [EC Setup Guide](/guide/ec-setup-guide).
@@ -44,7 +41,7 @@ maps to a stable base.
### Request Lifecycle
-Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction) — read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds consent from request-local cookies, headers, geolocation, and policy defaults; it does not load consent from a separate KV store.
+Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction). Read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds the consent context from request-local cookies, headers, geolocation, and policy defaults. It does not load consent from a separate KV store.
```mermaid
sequenceDiagram
@@ -62,7 +59,7 @@ sequenceDiagram
Note over TS: Phase 3: Finalize Ingest Prebid EID cookies
TS-->>B: Response + Set-Cookie: ts-ec=...
else Return Visit (EC cookie present)
- Note over TS: Phase 2: Routing EC exists — skip generation
+ Note over TS: Phase 2: Routing EC exists, skip generation
Note over TS: Phase 3: Finalize Ingest Prebid EID cookies
TS-->>B: Response (no cookie refresh)
end
@@ -72,11 +69,11 @@ sequenceDiagram
### Response Finalization
-After routing completes, the server evaluates consent state and cookie presence to decide what to do with the EC cookie on the response.
+After routing completes, the server evaluates the permission state and cookie presence to decide what to do with the EC cookie on the response.
```mermaid
flowchart TD
- Start[ec_finalize_response] --> ConsentCheck{Consent allows EC?}
+ Start[ec_finalize_response] --> ConsentCheck{Permissions allow EC?}
ConsentCheck -- "No" --> ExplicitWithdrawal{Explicit withdrawal?}
ExplicitWithdrawal -- "Yes" --> CookiePresent{Cookie was present?}
@@ -89,44 +86,71 @@ flowchart TD
WasPresent -- "No, just generated" --> NewEc["Ingest Prebid EID cookies Set ts-ec cookie"]
```
-When consent cannot be verified for the current request — for example, unknown jurisdiction or missing/undecodable consent signals in a regulated region — Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC.
+When the required permissions are not set for the current request (for example a location lookup that failed, or a baseline that requires a signal no module has answered), Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC.
+
+## Permission Gating
+
+EC creation is gated through the [permission model](/guide/permission-model), not by a jurisdiction rule baked into the core. The Edge Cookie module advertises the permissions its data use requires, and Trusted Server creates an Edge Cookie only when every required permission is set. The built-in HMAC module requires `necessary.operations.storage` (TCF Purpose 1), because the `Set-Cookie` operation stores information on the device.
+
+The Edge Cookie code never reads consent. It checks only whether the required **permission** is set. Consent is one of the sources that _set_ a permission, not something the gate reads directly, so the Edge Cookie logic does not change when a consent framework changes. Two sources combine for each request:
+
+- **A country and region baseline.** The country, and an optional region such as a US state, that the geo module returns. A region rule takes precedence over its country, and when no country is identified, or neither the region nor the country has a rule, the baseline on the top node of the rules tree applies.
+- **Consent and privacy signals.** TCF, GPP, a US Privacy string, GPC and the Model Terms for Marketing preference (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`, `__mtm_pref`), each read by its own [permission signal module](/guide/permission-signals) and mapped onto permissions as a **grant or a revoke** on top of that baseline, in the configured order. There is no separate consent KV fallback.
+
+Every permission is resolved this way, and the Edge Cookie is created only when each permission its module requires is set. A location lookup that fails resolves at the requires-signal floor, so the cookie is not created unless a signal grants the permission. The core encodes no jurisdiction's law. The deployer brings the policy, and the per-country and per-region rules live in the policy file rather than in core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. Writing browser-supplied Prebid EIDs into the identity graph, and server-side pull sync, additionally need the storage and personalised-ad permissions both set, the pair that also gates EIDs in the bid request. With storage alone the Edge Cookie stays first-party and partner sharing is withheld, so the EC ID is not sent to pull-sync partners. See [Prebid EID Flow](#prebid-eid-flow).
+
+```mermaid
+flowchart TD
+ Start[Resolve country and region] --> Baseline[Country or region rule, else the top of the rules tree]
+ Baseline --> Signals[Apply consent/privacy signals as a grant or revoke]
+ Signals --> Check{Module's required permissions all set?}
+ Check -- "Yes" --> Allow([Create EC])
+ Check -- "No" --> Deny([No EC])
+```
+
+The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, withdrawal tombstones, and completion markers that prevent stale point-read misses from rewriting completed tombstones. A marker key records the original tombstone's validity bound and is ignored at or after that time, even if its KV row has not expired yet. If the clock is unusable, the marker is ignored and withdrawal falls back to a strong root-existence check; only a confirmed existing root can be written. This prevents a stale marker from suppressing withdrawal when an expired EC key is created again. With a healthy store, repeated withdrawal of an already tombstoned EC ID leaves the row unchanged instead of refreshing its 24-hour TTL. Permission resolution for each request is based on the live request signals listed above.
+
+## Module Types: Server-Side and Client-Side
-## Consent Model
+The Edge Cookie identifier comes from a configurable module, selected by `[ec] module`. A module is one of two types, and the permission gate above applies to both. The two reach the **same outcome** (a `ts-ec` cookie set and carried on every later request) by **different routes**.
-EC creation is gated by jurisdiction. The server detects jurisdiction from geolocation data attached to the request and applies the corresponding consent rules. Live consent comes from request-local signals (`euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy`, `Sec-GPC`) plus geolocation and policy defaults; there is no separate consent KV fallback.
+- **Server-side** (for example the built-in HMAC module, or the built-in `host_signals` module that creates the identifier from the host's TLS JA4 and HTTP/2 signals on a host that supplies them). The module derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved.
+- **Client-side** (for example the `client_fixed` demo). The module cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the module's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The module creates the identifier from that value in `resolve_from_client()`, and the **resolve response** sets the cookie.
```mermaid
flowchart TD
- Start[Detect Jurisdiction] --> J{Jurisdiction?}
-
- J -- "GDPR (EU/UK)" --> TCF{TCF string present?}
- TCF -- "Yes" --> P1{Purpose 1 granted?}
- P1 -- "Yes" --> Allow([Allow EC])
- P1 -- "No" --> Deny([Deny EC])
- TCF -- "No" --> Deny
-
- J -- "US State" --> GPC{GPC header set?}
- GPC -- "Yes" --> Deny
- GPC -- "No" --> USTCF{TCF from CMP e.g. Didomi?}
- USTCF -- "Yes" --> USP1{Purpose 1 granted?}
- USP1 -- "Yes" --> Allow
- USP1 -- "No" --> Deny
- USTCF -- "No" --> USP{US Privacy string?}
- USP -- "Yes" --> OptOut{Opt-out sale?}
- OptOut -- "No" --> Allow
- OptOut -- "Yes" --> Deny
- USP -- "No" --> Deny
-
- J -- "Non-regulated" --> Allow
- J -- "Unknown (no geo data)" --> Deny
+ Start(["Page request, no Edge Cookie"]) --> Type{"Module type"}
+
+ Type -->|"Server-side (e.g. HMAC)"| SGen["generate() creates the identifier at the edge from request data"]
+ SGen --> SSet["Page response sets ts-ec"]
+
+ Type -->|"Client-side (e.g. client_fixed)"| CDefer["generate() defers, returns no identifier"]
+ CDefer --> CPage["Page response, no cookie, delivers the module JS"]
+ CPage --> CBox[["Module JS (black box): runs in the browser and does its work"]]
+ CBox --> CPost["JS posts the result to POST /_ts/api/v1/ec/resolve"]
+ CPost --> CResolve["resolve_from_client() verifies and creates"]
+ CResolve --> CSet["Resolve response sets ts-ec"]
+
+ SSet --> Same(["Same outcome: ts-ec set, carried on every later request"])
+ CSet --> Same
```
-- **GDPR**: Opt-in required. TCF Purpose 1 (store/access device) must be explicitly consented. Writing browser-supplied Prebid EIDs into the identity graph additionally requires TCF Purpose 4 (personalized ads); see [Prebid EID Flow](#prebid-eid-flow). Without Purpose 4, server-side pull sync is also skipped, so the EC ID is not sent to pull-sync partners.
-- **US State**: Opt-out model with three-tier fallback — GPC always blocks, then TCF if a CMP uses it, then US Privacy string, then fail-closed.
-- **Non-regulated**: EC always allowed.
-- **Unknown**: Fail-closed when jurisdiction cannot be determined.
+The two types differ only in route and in the methods they use:
+
+| Feature | Server-side | Client-side |
+| -------------------- | ------------------------- | ---------------------------------------------- |
+| Example | HMAC (`hmac`) | `client_fixed` (demo) |
+| Created in | `generate()`, at the edge | `resolve_from_client()`, from the posted value |
+| `generate()` returns | the identifier | no identifier (defers) |
+| Client JavaScript | none | the module JS (black box), which posts back |
+| Endpoint | none | `POST /_ts/api/v1/ec/resolve` |
+| Cookie set on | the page response | the resolve response |
+
+The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back, and a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client_fixed` demonstration module is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup.
+
+Because the posted value comes from the browser, **verification is the module's responsibility**. A client-side module must verify the payload (for example a signature) before creating the identifier, or a client could forge an Edge Cookie. The endpoint itself is module-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the module, and writes the cookie.
-The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, withdrawal tombstones, and completion markers that prevent stale point-read misses from rewriting completed tombstones. A marker key records the original tombstone's validity bound and is ignored at or after that time, even if its KV row has not expired yet. If the clock is unusable, the marker is ignored and withdrawal falls back to a strong root-existence check; only a confirmed existing root can be written. This prevents a stale marker from suppressing withdrawal when an expired EC key is created again. With a healthy store, repeated withdrawal of an already tombstoned EC ID leaves the row unchanged instead of refreshing its 24-hour TTL. Consent interpretation for each request remains based on the live request signals listed above.
+A built-in `client_fixed` module demonstrates the client-side type end to end with no vendor coupling. Client and server share one fixed, known word. When no Edge Cookie is present, the page script (shipped in the tsjs bundle when that module is selected) posts that word, the server verifies it matches, and on a match sets it as the Edge Cookie. The value is verifiable because it is a known constant, which is the point of the demo. It is useless in production, because a fixed value is not an identity, so it is for demonstration and testing only.
## Partner Sync Channels
@@ -158,7 +182,7 @@ Browser-resolved Prebid EIDs reach the identity graph when Trusted Server finali
The `/auction` body carries the full EID set Prebid.js resolved, so `/auction` ingestion is not limited by the size cap TSJS applies to the `ts-eids` cookie. The cookie still bridges Prebid user ID modules with the identity graph on requests that carry no EID body, such as `GET /_ts/page-bids` and page navigations.
-These Prebid EID writes follow the same consent rule as bidstream EID forwarding: when a TCF signal is present, Purpose 1 (store/access device) and Purpose 4 (personalized ads) must both be consented, and when GDPR applies without a TCF signal the writes are withheld. A user who consents to Purpose 1 but not Purpose 4 keeps their EC, but no EIDs from the request are persisted.
+These Prebid EID writes follow the same permission rule as bidstream EID forwarding: the storage and personalised-ad permissions must both be set. A reader who allows storage but not personalised ads keeps their EC, but no EIDs from the request are persisted.
```mermaid
sequenceDiagram
@@ -178,7 +202,7 @@ sequenceDiagram
Note over B,TS: Next eligible EID-sync request
B->>TS: Document navigation, POST /auction, or admitted GET /_ts/page-bids
TS->>TS: Base64 decode → parse OpenRTB-style EIDs match source domains to partners
- TS->>KV: Add missing partner IDs in one conditional write skip when UIDs already match (requires TCF Purpose 1 + 4 under GDPR)
+ TS->>KV: Add missing partner IDs in one conditional write skip when UIDs already match (requires the storage and personalised-ad permissions)
```
Current TSJS writers preserve the full OpenRTB-style `{source, uids:[...]}` shape in `ts-eids`. The server remains backward-compatible with earlier flattened `{source, id, atype}` cookies during rollout, but new cookies use the structured `uids[]` form.
@@ -227,7 +251,7 @@ sequenceDiagram
DSP-->>PS: OpenRTB bid response
PS-->>TS: OpenRTB seatbid response
TS-->>B: Auction response + x-ts-eids header when available
- TS->>KV: Add missing partner UIDs from request eids[] + ts-eids/sharedId cookies (TCF Purpose 1 + 4 under GDPR)
+ TS->>KV: Add missing partner UIDs from request eids[] + ts-eids/sharedId cookies (storage and personalised-ad permissions)
```
The relevant OpenRTB structure forwarded to Prebid Server and downstream partners is:
@@ -312,7 +336,7 @@ sets `Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, and a `Max-Age`.
- Returning requests with consent and an existing `ts-ec` do not refresh the EC cookie or KV TTL.
- Newly generated ECs receive `Set-Cookie: ts-ec=...`.
-- When consent is blocked but not explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal.
+- When the permission is not set but nothing was explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal. Withdrawal is deliberately narrow: a TCF record refusing storage in a jurisdiction whose baseline did not grant it. US-style opt-outs (GPC, a GPP sale opt-out, or a US Privacy opt-out) suppress use for the request but never expire the cookie or write a tombstone, so lifting the opt-out restores the identity.
- `/_ts/api/v1/identify` is read-oriented and returns identity enrichment for the authenticated partner. It computes `cluster_size` only when the EC entry does not already store one.
- `/_ts/api/v1/batch-sync` validates every input, groups valid mappings by normalized EC ID, and applies the last valid UID for each group once. Group outcomes still account for every original input; infrastructure failures reject the failing and remaining groups. Mapping timestamps remain required for API compatibility but do not order writes. See the [API Reference](/guide/api-reference) for the complete contract.
- Pull sync fills missing partner UIDs only. Existing partner UIDs are not periodically refreshed because EC entries no longer store per-partner sync timestamps.
diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md
index abc38dc51c..c77e9e3e89 100644
--- a/docs/guide/error-reference.md
+++ b/docs/guide/error-reference.md
@@ -75,7 +75,7 @@ TOML file.
- `publisher.domain`
- `publisher.origin_url`
- `publisher.proxy_secret`
-- `ec.passphrase`
+- `ec.hmac.passphrase` (when `ec.module = "hmac"`)
---
@@ -181,10 +181,13 @@ Failed to generate EC ID: HMAC error
**Solution:**
-1. Ensure `passphrase` names a secret-store entry in `trusted-server.toml`:
+1. Ensure the `hmac` module is selected and its `passphrase` names a secret-store entry in `trusted-server.toml`:
```toml
[ec]
+module = "hmac"
+
+[ec.hmac]
passphrase = "ec_passphrase"
```
@@ -192,7 +195,8 @@ passphrase = "ec_passphrase"
passphrase value:
```bash
-TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase
+TRUSTED_SERVER__EC__MODULE=hmac
+TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase
```
3. Provision a high-entropy value of at least 32 characters under
diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md
index 23503b4703..bec742c55e 100644
--- a/docs/guide/fastly.md
+++ b/docs/guide/fastly.md
@@ -398,8 +398,11 @@ Configure the secret-store key name in `trusted-server.toml`:
```toml
[ec]
-passphrase = "ec_passphrase"
+module = "hmac"
ec_store = "ec_identity_store"
+
+[ec.hmac]
+passphrase = "ec_passphrase"
```
Store the high-entropy passphrase under that key in the physical store
diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md
index 18db20e237..f8d68b9b6c 100644
--- a/docs/guide/integrations/prebid.md
+++ b/docs/guide/integrations/prebid.md
@@ -814,7 +814,7 @@ consent (vendor 97) gate IdentityLink resolution and storage. Purpose 3 has no
standalone default rule. Purpose 4 controls user-provided-data activity, but
denying it alone does not block IdentityLink resolution or browser-side storage.
Persisting the RampID into the Edge Cookie identity graph is a separate,
-server-side step that requires TCF Purpose 1 and Purpose 4.
+server-side step that requires the storage and personalised-ad permissions.
Default EID transmission accepts a qualifying purpose and vendor basis from any
of Purposes 2–10. Publishers can require Purpose 4 specifically by enabling
@@ -944,7 +944,7 @@ Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions
4. The merged result is forwarded downstream as `user.ext.eids` in the OpenRTB request.
5. After the response, Trusted Server adds matched partner UIDs that are missing from the EC identity graph, reading the `ts-eids` cookie, then the `/auction` request-body EIDs, then the `sharedId` cookie. Because the body carries the untrimmed EID set, `/auction` ingestion is not limited by the size-capped `ts-eids` cookie.
-The `ts-eids` cookie still matters for requests without an EID body, such as `GET /_ts/page-bids` and page navigations, where it is the source for both EID fallback and identity-graph ingestion. Identity-graph EID writes follow the same consent rule as bidstream forwarding: under GDPR, TCF Purpose 1 and Purpose 4 must both be consented.
+The `ts-eids` cookie still matters for requests without an EID body, such as `GET /_ts/page-bids` and page navigations, where it is the source for both EID fallback and identity-graph ingestion. Identity-graph EID writes follow the same permission rule as bidstream forwarding: the storage and personalised-ad permissions must both be set.
This means Prebid auctions get same-request transparency for browser-resolved IDs without giving up the durability of the server-managed EC identity graph.
@@ -966,7 +966,7 @@ sequenceDiagram
T->>P: OpenRTB request\nuser.ext.eids = merged set
P-->>T: OpenRTB bid response
T-->>B: Auction response
- T->>K: Ingest body eids + ts-eids/sharedId cookies\n(TCF Purpose 1 + 4 under GDPR)
+ T->>K: Ingest body eids + ts-eids/sharedId cookies\n(storage and personalised-ad permissions)
```
### Merge and deduplication rules
diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md
index 3f8b6f4d96..cde347612b 100644
--- a/docs/guide/key-rotation.md
+++ b/docs/guide/key-rotation.md
@@ -28,7 +28,7 @@ Key rotation is the process of generating new signing keys and transitioning fro
## Edge Cookie HMAC Passphrase
-The Edge Cookie `ec.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters; shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph.
+The Edge Cookie `ec.hmac.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters, because shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph.
## Prerequisites
diff --git a/trusted-server.example.toml b/trusted-server.example.toml
index 916dddf48e..5a8e35875d 100644
--- a/trusted-server.example.toml
+++ b/trusted-server.example.toml
@@ -126,6 +126,14 @@ pull_sync_concurrency = 3
# implementation = "hmac"
# passphrase = "ec_passphrase"
+# Built-in host-signal module block. Creates the identifier from the host's TLS
+# JA4 and HTTP/2 signals together with the client IP, so it needs a host that
+# supplies those signals (Fastly does). Uncomment it together with
+# `module = "host_signals"` above, and give it its own secret on the same
+# terms as the hmac block.
+# [ec.host_signals]
+# passphrase = "ec_host_signals_passphrase"
+
# Example partner configuration. Provision referenced keys in
# trusted_server_secrets before validating/pushing.
# [[ec.partners]]
@@ -148,16 +156,19 @@ pull_sync_concurrency = 3
# OPTIONAL — Core features (disabled/omitted by default)
# =============================================================================
-# Device detection module selection. The built-in module classifies from
-# the User-Agent alone and makes no host-specific call. The opt-in "fastly"
-# module strengthens the browser/bot gate with the host's TLS/H2 signals.
+# Device-detection module. Selects how a request is classified into device
+# signals (browser/bot gating). Default "builtin" classifies from the User-Agent
+# alone and makes no host call, so a default deployment stays host-neutral. The
+# opt-in "fastly" module adds the host's TLS/H2 signals to the gate.
+# Override at deployment with TRUSTED_SERVER__device__module.
# [device]
-# module = "builtin"
+# module = "builtin" # or "fastly" to add TLS/H2 signal evidence
[geo]
-# Geo / IP intelligence module selection. No module is the default:
-# Trusted Server resolves no location and makes no host geo call. The host
-# platform's own lookup is opt-in.
+# Geo / IP intelligence module. No module is the default: Trusted Server
+# resolves no geolocation and makes no host geo call, so a default deployment is
+# not tied to any host geo service. Opt into the host lookup with
+# module = "platform". Override with TRUSTED_SERVER__geo__module.
# module = "platform"
#
# The permission baseline for a request the geo module leaves unmatched