Skip to content

Add the provider documentation set and finish the decomposition - #1047

Open
jwrosewell wants to merge 5 commits into
IABTechLab:mainfrom
jwrosewell:split/5-response-hook-docs
Open

Add the provider documentation set and finish the decomposition#1047
jwrosewell wants to merge 5 commits into
IABTechLab:mainfrom
jwrosewell:split/5-response-hook-docs

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fifth and final PR of the stack decomposing #838 as requested in the #986 review. Stacks on #1046. Compare split/5-response-hook-docs to split/4-client-resolve to see only this PR's change.

Specs carried by this PR:

What this PR does

  • The IntegrationResponseMutator response-header hook from the earlier draft of this PR is removed. It had no consumer, which is the spec set's own rule against speculative surface. It returns together with the first integration that needs it, and its spec stays in the tree as that design's starting bar.
  • Documentation for the provider model: the configuration reference for the [ec], [device], and [geo] sections (including the required default_country, the assume_single_jurisdiction acknowledgment, and the requires-signal floor on a failed lookup), the Edge Cookie guide rewritten around providers and the permission model (including the narrow withdrawal semantics, the hardened resolve endpoint, and the resolved-marker cookie), setup and error-reference updates, and the permission-model guide joining the docs navigation.
  • The example configuration documents every provider selector in one place, with the [geo] baseline explained and the single-jurisdiction acknowledgment shown commented next to the key it concerns.
  • The Fastly EC lifecycle integration-test job joins the test workflow.

What happens to #838

Once these five PRs merge, #838 is closed. It stays open as a draft reference for the review period only.

How it was verified

Full local gate on this branch, all clean. cargo test-fastly, cargo test-axum, cargo test-cloudflare, cargo test-spin, the integration parity suite, docs prettier check, cargo fmt --check, and all six per-target clippy aliases.

References #777 and #778. Decomposes #838. Spec baseline from #986.

Produced with AI assistance under James Rosewell's direction, and flagged here so reviewers know to apply the usual scrutiny.

@jwrosewell
jwrosewell force-pushed the split/5-response-hook-docs branch 3 times, most recently from 7ebce99 to 700c913 Compare August 25, 2026 10:51
@jwrosewell jwrosewell changed the title Add the integration response-header hook and the provider documentation set Add the provider documentation set and finish the decomposition Aug 25, 2026
@jwrosewell
jwrosewell force-pushed the split/5-response-hook-docs branch from 700c913 to 6fa27ba Compare August 25, 2026 13:37
…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.
…e provider

Second slice of the PR 838 decomposition. Device classification and
geolocation become selectable providers, mirroring the Edge Cookie
provider seam:

- [device] provider selects the classifier. The built-in default reads
  the User-Agent alone and makes no host call; the opt-in fastly
  provider strengthens the browser/bot gate with the host's TLS JA4 and
  HTTP/2 signals (crates/device/fastly).
- [geo] provider selects geolocation. The host platform's lookup is the
  default, matching the behavior before the selector existed, and
  provider = "platform" spells the same choice explicitly
  (crates/geo/fastly wraps the Fastly host lookup behind the
  PlatformGeo trait). provider = "none" opts out entirely, so a client
  IP is never sent to any host geo service. The disabled-by-default
  flip ships with the permission model in the next slice, which adds
  the jurisdiction baseline that makes a no-geo deployment viable.
- Every adapter routes its host geo through the same build_geo_provider
  selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider
  identically, so the selector is not a Fastly-only behavior.
- The provider configuration sections ([device], [geo],
  [ec.providers.hmac], [ec.providers.host-signals]) reject unknown
  keys at startup, so a mistyped key fails loudly instead of silently
  selecting a default.
- The host-signal Edge Cookie provider arrives with the capability it
  needs: the Fastly adapter injects the TLS/HTTP-2 signals as a
  HostSignals service, and the provider mints from them plus the client
  IP. With no host signals at all it defers with a warning rather than
  degrading to an IP-only identifier.
- Device signals move to a field-based DeviceSignals derived in the
  adapter (derive_ua_only for hosts without host signals).
- The new crates join the fastly cargo aliases so they build, lint, and
  test in CI rather than compiling only transitively.
Third slice of the PR 838 decomposition. Permissions become the
primitive that gates identity features; consent is one of several ways
a permission is established:

- permissions.rs resolves a per-request PermissionState from the
  country/region baseline in permissions.yaml augmented by the session's
  signals (TCF, GPP, GPC, US Privacy). Permission names follow the
  Privacy Taxonomy Data Uses.
- Signal precedence is fixed in code, most restrictive first: a US-style
  opt-out (GPC, GPP sale opt-out, US Privacy) suppresses the Data Uses
  the policy revokes even when a TCF record consents, a present but
  undecodable record blocks baseline grants (fail-closed), and only then
  does a TCF record decide its mapped Data Uses. The yaml authoritative
  flag governs the TCF record's own grants and revokes, never whether an
  opt-out can be overridden. Pinning tests cover each opt-out source
  against a consenting TCF record.
- Destructive withdrawal is narrow: only a TCF record refusing storage
  in a jurisdiction whose baseline did not grant it expires the cookie
  and writes the identity-graph tombstone. Opt-outs suppress use
  (headers stripped, nothing egressed) but never destroy an issued
  identifier, so lifting an opt-out restores the identity.
- Sharing beyond the edge requires storage plus personalised-ad
  selection, the same pair that gates bidstream EIDs, at every egress:
  the auction endpoint's user.id, the publisher navigation and
  page-bids auction requests, the identify response, and partner
  pull-sync. A storage-only grant keeps first-party use while
  withholding partner sharing.
- The Edge Cookie gate moves from raw consent to the permission model:
  a provider declares required_permissions() and core executes it only
  when every one is set.
- [geo] default_country becomes required: it names the permissions.yaml
  rule that applies when the geo provider leaves a request unmatched. A
  failed geo lookup is distinct: it resolves at the requires-signal
  floor instead of the default, and is logged at error level. The
  lookup moves into EcContext::read_from_request_resolving_geo so every
  adapter reports the distinction identically.
- Geolocation is now off by default ([geo] provider unset resolves no
  location); the host lookup is opt-in via provider = "platform". A
  deployment that runs an Edge Cookie provider with no geo provider
  must set [geo] assume_single_jurisdiction = true, acknowledging that
  every request is treated as the default jurisdiction.
- permissions.yaml rules use an explicit per-permission acquisition map
  (granted / requires_signal / denied) instead of +/- sigils, unknown
  keys in a detailed rule are rejected, and two rule keys naming the
  same location in different case are rejected at parse.
- The consent module keeps building the ConsentContext; its EC-specific
  gating helpers move behind the permission model. An EU-27 plus EEA
  coverage test locks the gdpr-eu mapping.
- The design spec for this slice lives at
  docs/superpowers/specs/2026-07-30-permission-model-design.md, the
  2026-07-31 draft revised to match this implementation with a
  revision-record table of every divergence.
Fourth slice of the PR 838 decomposition. A client-cycle Edge Cookie
provider defers at the edge and lets the page derive the identifier in
the browser; the page posts it to POST /_ts/api/v1/ec/resolve and the
provider verifies it before the edge mints it as the Edge Cookie:

- EdgeCookieProvider gains resolve_from_client with a no-op default, so
  server-side providers are untouched. ClientResolveInput carries the
  posted payload plus the request's resolved permissions and consent.
- The resolve endpoint enforces the same rules as organic generation
  and several of its own. The permission gate applies unchanged. The
  request must carry an Origin on the publisher's domain (an
  identity-setting POST must not be drivable from a foreign page) and a
  text/plain or application/json body. A minted identifier must fit the
  identifier bounds (400), must not silently replace a different
  identity already on the request (409), and is persisted to the
  identity graph before the cookie is set, keyed by the provider's
  canonical form, so withdrawal reaches a client-set identity the same
  way it reaches an edge-minted one. With no graph available nothing is
  minted, matching the organic rule against phantom cookies; a graph
  write failure is 503. Every response carries Cache-Control: no-store.
- The Edge Cookie stays HttpOnly. A non-HttpOnly companion marker
  cookie (ts-ecr=1, no identity content) tells the page script a
  resolve succeeded, so it does not re-post on every page view; the
  marker expires together with the Edge Cookie on withdrawal. A Rust
  test asserts the marker name and the demo's fixed word stay in sync
  with the page script source.
- The client-fixed demonstration provider (fixed shared word,
  verify-before-mint) is compiled only behind the trusted-server-core
  client-fixed-demo cargo feature. Production builds reject the
  selection at startup: a fixed shared word is not an identity.
- The Fastly adapter routes the endpoint and passes the same bot-gated
  identity graph as generation, so unrecognized clients cannot mint
  through resolve either. The other adapters deliberately do not route
  it yet, matching identify and batch-sync, which need the same
  platform KV wiring those adapters lack.

The design spec for this slice lives at
docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md,
the 2026-07-31 threat-model draft revised to the implemented state,
with the deferred reservation design retained verbatim as the bar for
the first vendor scheme.
Fifth and final slice of the PR 838 decomposition:

- Documentation for the provider model: configuration reference for the
  [ec], [device], and [geo] sections (including the required
  default_country, the assume_single_jurisdiction acknowledgment, and
  the requires-signal floor on a failed lookup), the Edge Cookie guide
  rewritten around providers and the permission model (including the
  narrow withdrawal semantics, the hardened resolve endpoint, and the
  resolved-marker cookie), setup and error-reference updates, and the
  permission-model guide joins the docs navigation.
- The example configuration documents every provider selector in one
  place, with the [geo] baseline explained and acknowledged.
- The HostSignals trait moves to the top of the evidence module with
  its service framing documented.
- The Fastly EC lifecycle integration-test job joins the test workflow.

The earlier draft of this slice carried an IntegrationResponseMutator
response-header hook. It shipped with no consumer, so it is not
included: the hook returns together with the first integration that
needs it.

Two specs land with this slice: the migration and rollout spec
(docs/superpowers/specs/2026-07-30-provider-migration-rollout-design.md),
its sign-off ledger kept intact with each row the series implements
marked with its PR, and the response-header hook spec
(docs/superpowers/specs/2026-07-30-integration-response-header-hook-design.md),
retained as the design bar for the hook when its first consumer
arrives.
@jwrosewell
jwrosewell force-pushed the split/5-response-hook-docs branch from 6fa27ba to 278a006 Compare August 25, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant