Skip to content

Create and write the Edge Cookie on every adapter - #1411

Open
jwrosewell wants to merge 27 commits into
feature/52-request-filtersfrom
feature/53-edge-cookie
Open

jwrosewell wants to merge 27 commits into
feature/52-request-filtersfrom
feature/53-edge-cookie

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-adapter-spin/src 3 files, 606 lines added, 35 removed
crates/trusted-server-adapter-axum/src 3 files, 600 lines added, 22 removed
crates/trusted-server-adapter-cloudflare/src 3 files, 600 lines added, 25 removed
crates/trusted-server-core/src 4 files, 415 lines added, 104 removed
docs/superpowers 1 file, 130 lines added, 0 removed
crates/trusted-server-adapter-fastly/src 1 file, 111 lines added, 3 removed
docs/guide 7 files, 18 lines added, 8 removed
CHANGELOG.md 1 file, 2 lines added, 0 removed
crates/trusted-server-js/lib 1 file, 2 lines added, 2 removed

Closes

Part of #777

Spec

Spec: edge-cookie-on-every-adapter

Test plan

CI on this pull request at e28df81 passed all 27 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at e28df81

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on the request filters on every adapter (#1410).

What this pull request does

The design is edge-cookie-on-every-adapter. The Edge Cookie request state, the finalize step and the resolve route ran on the Fastly adapter alone. They now run on the Axum, Cloudflare and Spin adapters too, so a selected Edge Cookie module creates and writes its cookie on every adapter, under the public prefix and the path the cookies by path change gives it.

Core writes a created Edge Cookie with or without an identity graph. With a graph it records the row as before, and without one it skips the graph steps and still writes the cookie, because the Edge Cookie is the identifier, the identity graph is one store of the data associated with it, and the permission state comes from the permission model whatever its sources. So a Fastly service with no ec_store now writes a created cookie too, and the resolve route sets a client-side module's cookie as it does with a graph.

  • Core reads the bot gate from the device signals, through EcContext::passed_bot_gate(), so a crawler gets no created cookie on any path, where the gate on Fastly used to come from giving a crawler no graph.
  • On an adapter that cannot keep the identity graph, a startup warning says the cookie is written and the data the graph keeps with it, such as partner IDs, is not.
  • Core sends the identifier to the auction providers as user.id and in the auction request's id, behind the storage and personalized-ad selection permissions, so this change discloses it in three cases where nothing was sent before, which the design lists.
  • A graph configured later replaces a server-side module's cookie that it has no row for. A client-side module's cookie keeps its identifier with no row, and the design gives the cost.
  • For crates outside core, a crate that never sets device signals gets no created cookie.

How it was verified

Locally on Windows at 0a94453: cargo test-axum (1,366), cargo test-cloudflare (70), cargo test-spin (104) and the Fastly adapter tests under Viceroy (300) passed, with clippy refusing warnings on core and the three adapters and cargo fmt --all -- --check. On each of the three adapters the resolve route sets a client-side module's cookie with no graph, a withdrawal sent through the router expires the cookie, and the warning for a missing ec_store is checked. Core tests an orphan that a client-side module cannot replace. Each new test fails with its change reverted, and a crawler gets a cookie with the gate removed. With the rows below merged in, all 2,988 of core's tests passed at 23c5bf1.

Each of the three adapters now runs the Fastly adapter's Edge Cookie
steps. build_ec_request_state keeps the bot gate's answer, taken from the
device classification, beside the Edge Cookie context, and
dispatch_fallback creates an Edge Cookie for a real browser's page
navigation before the publisher path runs. Every response of the request
cycle carries an EcFinalizeState, and FinalizeResponseMiddleware runs
ec_finalize_response on it once the finalize headers are written, before
the request filters' effects. POST /_ts/api/v1/ec/resolve is a named route
in the request cycle, answered by handle_ec_resolve.

These adapters open no identity graph, because the graph needs strongly
consistent reads and conditional writes the key-value store they can
open does not offer. Core writes a created Edge Cookie only with its graph
row, so the middleware writes it with set_ec_cookie_on_response for a
request that passed the bot gate and whose permissions allow the Edge
Cookie. The cookie takes the request's public prefix and, on a request
addressed by path, the removed prefix as its Path. The graph's syncs are
skipped, the resolve route creates no cookie, and where [ec] ec_store
names a store each adapter logs once at startup that it opens none.

src/app/request_cycle_tests.rs in each adapter checks, with a reader in
the US and a stub origin, that a first page view addressed by path gets
ts-ec under that path, that a returning page view keeps its cookie, that
a crawler's page view gets none, that the resolve route answers 403 to a
foreign Origin and 204 to the publisher's without reaching the origin,
and that startup warns when ec_store is set.

cargo test-axum (1,362 passed), cargo test-cloudflare (66) and cargo
test-spin (100) pass, and so do the cross-adapter parity tests (17), the
Cloudflare wasm32 check, the Spin native and wasm32-wasip1 checks and its
release build, clippy with warnings denied on the three adapters and
cargo fmt.
docs/superpowers/specs/2026-10-17-edge-cookie-on-every-adapter-design.md
sets out the Fastly adapter's Edge Cookie steps, why the Axum, Cloudflare
and Spin adapters open no identity graph, how they write a created Edge
Cookie without one behind the same gates, what the resolve route answers
there, the startup warning, and the two open questions, being whether the
resolve route should write a client-side module's cookie without a graph
and whether a Fastly service with no ec_store should write one.
…adapter

The changelog gains a line under Changed. The route table of the API
reference lists the resolve route on every adapter, and its section says
what the route answers on an adapter with no identity graph. The Edge
Cookie guide and the ec_store row of the configuration guide say the Axum,
Cloudflare and Spin adapters open no graph, and their own guides say the
Edge Cookie is created and written there.
ec_finalize_response wrote a newly created Edge Cookie only once its
identity-graph row existed, so with no graph it wrote none, and
handle_ec_resolve answered 204 with no cookie when it had no graph. The
identity graph is one optional store of the data kept with the Edge
Cookie, such as partner IDs, so a missing graph no longer stops the
cookie. With a graph both record the row first, as before. Without one
both skip the graph steps and write the cookie, so the resolve route
answers as it does with a graph.

The bot gate held the cookie back from a request that does not look like
a browser only because the adapter gave that request no graph. Core now
reads the gate itself, from the device signals the adapter sets on the
context (EcContext::passed_bot_gate, which a context with no device
signals does not pass), in both places and at the step where the
missing graph stopped the cookie, so a crawler's answers are unchanged.

finalize_generated_ec_without_kv_skips_cookie_and_header becomes
finalize_generated_ec_without_kv_writes_the_cookie_and_skips_the_graph,
which shows that a response finalized with no graph gets the headers one
with a graph gets and that no graph step runs. New tests cover the bot
gate in the finalize step, in the resolve route and on the context, and
the resolve route with no graph. The tests that expect a created cookie
now set a browser's device signals, as every adapter sets them on every
request.
… core

Core now writes a created Edge Cookie with no identity graph, so the
FinalizeResponseMiddleware of each adapter no longer writes it after
ec_finalize_response, which would write it twice. EcFinalizeState loses
the bot gate's answer, which only that write read, and the request state
keeps it for creating an Edge Cookie on a page navigation. The resolve
route's comment says a created cookie is set with no graph row.

The startup warning, logged once where [ec] ec_store names a store,
now says the Edge Cookie is still written but the data the graph would
keep with it, such as partner IDs, is not.

The edge_cookie helper of each adapter's request cycle tests fails a
response that writes the Edge Cookie more than once, so the first page
view shows core alone writes it, and the resolve route's 204 is put down
to the server-side module, which creates nothing from a posted value.
with_no_ec_store_the_entry_point_writes_a_created_edge_cookie, run under
Viceroy, builds a browser's page view state with build_ec_request_state
from settings that name no [ec] ec_store, so no identity graph is opened,
creates the Edge Cookie as the publisher fallback does, and finalizes it
with the entry point's apply_edgezero_ec_finalize, which now writes the
cookie once with the created identifier.

The resolve route's comment says the bot-gated graph keeps an
unrecognized client's row out of the graph and core's bot gate keeps the
cookie from it.
docs/superpowers/specs/2026-10-17-edge-cookie-on-every-adapter-design.md
now says core writes a created Edge Cookie with or without an identity
graph, behind the bot gate it reads from the device signals, so the
cookie is written on every host and the graph is optional, and the
resolve route answers without a graph as it does with one. Its two open
questions, whether the resolve route should write a client-side module's
cookie on an adapter that opens no graph and whether a Fastly service
with no ec_store should write a created Edge Cookie, are both answered
yes, because the Edge Cookie is the identifier and the identity graph is
one store of the data associated with it. The
startup warning, the rollout, the tests and the acceptance follow, and
the revision record gains a row.
… graph

The changelog gains a line under Changed saying the Edge Cookie is
written without an identity graph as it is with one, so a Fastly service
with no [ec] ec_store now writes a created cookie and the resolve route
answers there as it does with a graph, and that a crate outside core
sets the device signals the bot gate reads. The line for the Axum,
Cloudflare and Spin adapters no longer says the resolve route creates no
cookie there.

The Edge Cookie guide says the store is optional and what is written
without it, the Fastly guide says the cookie is written without the
store, the API reference says the resolve route answers 204 to a request
the bot gate stops and sets the cookie with no row where there is no
graph, and the Axum, Cloudflare and Spin guides say the resolve route
sets the cookie there.
# Conflicts:
#	crates/trusted-server-adapter-axum/src/app.rs
Core now reads the bot gate from the device signals on the Edge Cookie
context, and a context with none gets no created cookie. The navigation
test that checks a new identifier keeps its cookie built its context with
no device signals, so the gate stopped the cookie and the test failed. It
now sets a browser's signals, as the other tests of a created cookie do.
@jwrosewell
jwrosewell added this pull request to stack #1408 October 18, 2026 01:31
…at a graph replaces it

The changelog line for writing the Edge Cookie without a graph says the
identifier auction providers get as user.id on a Fastly service with no
ec_store is now one value for the life of the cookie, where it was a new
value on each page view, that naming an ec_store later gives each browser
holding such a cookie a new identifier, and that reading the bot gate
from the device signals is breaking for crates outside core. The line for
the Axum, Cloudflare and Spin adapters says the bot gate there passes any
client that sends a browser's User-Agent, and that a real browser's
identifier reaches auction providers as user.id from its first page view
where storage and personalized-ad selection are both set.

The Edge Cookie guide says the identifier is used and shared without the
store as it is with it, and that adding the store later gives each
browser holding a cookie written without it a new identifier, because the
identity graph replaces an identifier it has no row for. The Fastly guide
says the same of adding the store to a service that ran without it.
…warning with no ec_store

The request cycle tests of the Axum, Cloudflare and Spin adapters send a
page view carrying ts-ec and a TC String that refuses storage, from a
reader with no location, so the permissions resolve at the top of the
sample rules, where storage needs a signal. The response expires the
Edge Cookie. The reader's services carry the permission signal modules
the adapter links, as the adapter's own services do, so the TCF module
reads the string, and the settings turn its expiry check off, because
the string is fixed.

The warning that an adapter opens no identity graph takes the flag it is
logged under, as the analytics warning does, so the startup test also
checks that the warning sets no flag when [ec] ec_store names no store.

On each adapter the withdrawal test fails when a response the
permissions do not allow is given no finalize state, and the warning
test fails when the warning does not need an ec_store.
…a graph replaces it

docs/superpowers/specs/2026-10-17-edge-cookie-on-every-adapter-design.md
answers the first review round. Consent and privacy no longer says the
Edge Cookie stays a first-party identifier without a graph. It says core
sends the identifier to the auction providers as user.id and in the
auction request's id, and lets module calls read it, behind the storage
and personalized-ad selection permissions, names the two cases where this
change discloses more, being the first page views on these adapters and
on a Fastly service with no ec_store, and says the disclosure is
intended. Rollout says a graph configured later replaces every cookie
written without one, through confirm_then_recover_orphaned_ec, and why
the graph keeps that rather than adopt such a cookie, and Decisions says
the same.

It also says the three adapters already ran the integration proxy's
creation, what the bot gate is on these adapters, where the resolve route
checks it, what the store offers, what a deployment with no module and a
crate outside core now see, what the Fastly test does, and which answer
no adapter test covers. The new withdrawal test and the startup warning's
case with no ec_store are listed, three items join What is not in this
change, the revision record gains a row and a review log records round 1.
…module's cookie

A client-side module creates nothing at the edge, so orphan recovery has
no replacement for its cookie and leaves it, and its page script posts to
the resolve route only while the resolved marker is absent. So once a
graph is configured, such a cookie written without one keeps its
identifier with no row, and each page view pays for an extra read and a
strongly consistent existence check. The changelog, the Edge Cookie guide
and the Fastly guide now limit the replacement to a server-side module and
state that case.

The changelog also says a client-side module gave no identifier before
this change, and that its identifier reaches auction providers from the
requests after its resolve. The API reference says the resolve route asks
the module before it checks the bot gate, and the page script says the row
is persisted only where there is an identity graph.
…client-side orphan

Each adapter's request cycle tests register a test Edge Cookie module
that creates nothing at the edge and takes its identifier from the value
the page posts, as a client-side module does, and select it with
[ec] module. The publisher's post of that value gets 200 through the
router with the Edge Cookie, its resolved marker and Cache-Control
no-store, and nothing reaches the origin. With the resolve route put
back to answering 204 when there is no identity graph, the test fails on
all three adapters.

Core's test of an orphan recovery that cannot rotate gains a client-side
module's case. A client_fixed cookie whose row is missing keeps its
identifier, no cookie is written and the miss is recorded as failed,
because the module creates nothing at the edge to rotate to. Without the
failed snapshot on that exit the case fails, and no other test reaches
that exit.
…e's cookie

Fixes from the second review round. Once a graph is configured, a
client-side module's cookie written without one keeps its identifier with
no row, because the module creates nothing at the edge for recovery to
rotate to and its page script posts again only once the resolved marker
is gone, and the design now says what each page navigation then pays.
The replacement is limited to a server-side module's cookie, and a third
case where the disclosure grows is added, a client-side module on a host
with no graph, whose readers now get an identifier from the resolve
route.

The design also says the 2026-07-10 invariant that a cookie follows its
row now holds only with a graph, that orphan recovery reads the recovery
flag rather than the bot gate for a crate outside core, and which tests
cover the resolve route's 200 through each adapter and a client-side
orphan in core. Giving such a cookie its row is listed beside a graph that
adopts a cookie. The review log gains the round 2 entry and names the
public branch round 1 was judged against.
# Conflicts:
#	CHANGELOG.md
#	crates/trusted-server-adapter-axum/src/app/request_cycle_tests.rs
#	crates/trusted-server-adapter-cloudflare/src/app/request_cycle_tests.rs
#	crates/trusted-server-adapter-spin/src/app/request_cycle_tests.rs
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@jwrosewell jwrosewell closed this Oct 18, 2026
@jwrosewell jwrosewell reopened this Oct 18, 2026
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell jwrosewell changed the title The Edge Cookie is created and written on every adapter Create and write the Edge Cookie on every adapter Oct 19, 2026
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:24
@jwrosewell jwrosewell mentioned this pull request Oct 20, 2026
13 of 14 tasks

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant