Repository navigation
Add the client-set Edge Cookie value path - #1346
Open
jwrosewell wants to merge 60 commits into
Open
jwrosewell wants to merge 60 commits into
jwrosewell wants to merge 60 commits into
Conversation
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.
The spec used em dashes throughout; they are replaced with plain punctuation. The resolve handler's doc claimed every response carries Cache-Control: no-store, but a provider or configuration error leaves through the error path to the adapter's own response, so the doc now says every response the handler builds.
`ec/provider.rs` documents that core checks every provider response header against its reserved surface before applying it, so a provider may set its own cookies and headers but cannot reach into the surface core manages. The organic mint path honours that in `EcContext::generate_with_provider`. The resolve endpoint applied provider headers with no check at all, on both the 204 path where the provider mints nothing and the 200 path where it does. That let a browser-side provider set the managed `ts-ec` cookie directly and bypass the identifier bounds check, the conflict check and the row-before-cookie rule that sit a few lines below it. Removing the new check and re-running the tests shows the 204 response going out as `204` with `set-cookie: ts-ec=forged-value; Path=/` and no identity-graph row behind it, which is an external reviewer's own finding on this stack, answered on the Edge Cookie provider branch and reintroduced here. His words were that a provider can set the managed cookie "including when it returns no identifier", which is exactly that path. The check is the same call the organic path makes, placed immediately after `resolve_from_client` and before the identifier is read, so one check covers both paths for the same reason the organic one sits there: a provider can return headers with no identifier at all. A breach fails the request rather than producing a status, matching the organic path and the module's existing rule that a provider error propagates to the adapter's error response, because a provider reaching into the reserved surface has broken its contract rather than sent a bad request. Tested on both paths: a managed `ts-ec` cookie with nothing minted, and an `x-ts-ec` header on the minted path.
The two sites in the resolve handler had the same defect the EC finalization loop had: `HeaderMap::insert` replaces every existing value for a header name, so a provider setting more than one cookie kept only the last, and a provider setting a header this handler already wrote silently replaced it. On this path the replaced header is core's own rather than an origin's, and the one that matters is `Cache-Control: no-store`. Every response the handler builds carries it, because an identity response must never be cached, and an inserting write let a provider drop it. Removing the fix and re-running the new test shows both: the provider's first cookie gone, and `no-store` replaced by the provider's `max-age=600`. Both sites now go through the shared `apply_provider_response_headers`, which carries the rule and is the same helper EC finalization uses, so the two paths cannot drift.
`ProviderCode::new` is fallible and returns `Option<ProviderCode>`, but this test provider passed its result straight back from `code()`, which returns `ProviderCode`. The test module therefore did not compile. Nothing caught it because the only checks run against this branch were `cargo check`, which builds the library and not the `#[cfg(test)]` modules, so the branch looked healthy while its own tests could not be built. The macro is the established form for a fixed code and is already used by the neighbouring test provider in this same file. It resolves the code in a `const` block, so a malformed code fails the build rather than the request, and there is no `Option` left to unwrap. Addresses: crates/trusted-server-core/src/ec/resolve.rs, where the `ResolveHeaderProvider` test double could not compile.
The Edge Cookie context carried the raw injected provider so the resolve endpoint could rebuild it later. That slot is gone, so the context now carries the provider the composition root threaded, which is the one seam section 3.6 specifies.
…e resolve cycle on a provider switch Two review findings from Christian Pavilonis on #1084. The resolve endpoint authorized any origin that was a suffix match on the publisher domain, and it also accepted an http:// origin and discarded the port before comparing. The Edge Cookie is scoped to the parent domain, so a delegated or compromised sibling subdomain could fix an identity that lands on the apex and every sibling with it. Authorization is now a comparison of the whole serialized origin, being the scheme, the lowercased host and the effective port. The default accepted origin is https://<publisher.domain> and nothing else, and [ec] resolve_allowed_origins adds further exact origins for a publisher whose pages are served elsewhere. The resolved marker is not namespaced by the provider code the way the cookie value, the identity-graph key and withdrawal are, so after a switch between client-cycle providers it outlived the identity it was set for and the new provider's page script would skip the resolve it should perform. Core now expires the marker whenever the request carries an identifier the selected provider does not own, which does not depend on any vendor page script comparing a marker value correctly.
The client resolve endpoint compared the request Origin and each allowed origin as lowercased strings, which matched what browsers send but was not the RFC's rule, so a configured https://www.example.com:443 could never match the https://www.example.com a browser serializes. origins_match now parses both sides as RFC 6454 §6.1 serialized origins and compares the scheme, host and port triple of §4 under the §5 same-origin test, with a missing port standing for the scheme's default and the opaque null origin never matching. A unit test covers the equal and unequal cases, and the settings and example config wording cite the RFC.
The client-fixed page script posted the known word whenever the resolved marker was absent, relying on the server's gate alone. A page module is treated like any other provider: it declares the permissions it requires and checks them against the resolved state the server hands the page before it does anything. The script now declares necessary.operations.storage, the same Data Use the server-side provider requires, waits on tsjs.whenPermissions() so the check works whether the state arrived at head open or at the body seam, and posts only when that permission is set. With no permission state on the page it does not post. The server still enforces the same gate on the resolve endpoint, so this is the page's half of one decision rather than a second policy. The Rust test that keeps the script's constants in step with the provider now also asserts that REQUIRED_PERMISSION matches Permission::StoreOnDevice.as_str(). Vitest covers the permission unset, no state, and state-arrives-later cases.
Brings the permission signal work on split/3-permissions up to this branch, so the client-set value path builds on the permission model as it now stands. The merge has no conflicts.
Brings the documentation and assertion message fixes up from split/3-permissions. The merge has no conflicts.
Brings upd/split/3-permissions at 3c9f4b0, which carries main at 066ea3c through split/1, split/2 and split/3, into split/4-client-resolve. Two files conflicted. Each resolution keeps both sides' changes. ec/finalize.rs takes main's imports for identity-graph writes and EID collection, and keeps this branch's expire_ec_resolved_marker beside them. This branch's two resolved-marker tests from ee07418 sit beside main's orphan-recovery tests from #885, and now pass main's mutable context to ec_finalize_response. docs/guide/api-reference.md keeps this branch's section for the POST /_ts/api/v1/ec/resolve endpoint and drops the POST /third-party/ad section, which main's #1016 removed.
Brings upd/split/3-permissions at e3bb4b9 into split/4-client-resolve. That carries the split/1 review follow-ups (0980b73, 0e7f7eb, fb1bc28, cb6f717 and 1b88e42), their merge into split/3 under that branch's permission gate, and split/3's be0ed2d, which lets the Cloudflare and Spin startup tests from main's #1016 load under the single-jurisdiction rule. This branch carries the same four settings blocks, so it takes that fix here. The merge had no conflicts. The resolve endpoint already applies a provider's response headers only with the 200 that sets the cookie or the 204 when the provider creates nothing, which is the rule cb6f717 brings to organic generation, so nothing in resolve.rs changes. Tests. The merged tree passes a native all-targets check, 2,885 core tests, the Axum, Cloudflare and Spin adapter tests, the permission signal crate tests, and the Fastly, Cloudflare and Spin wasm checks.
Brings upd/split/3-permissions at 88a7482 into split/4-client-resolve. That carries the one commit which makes `tools/permissions-inspector/wasm` pass the request evidence and the signal providers to `assemble_permissions`, and which formats the crate. This branch carries the same inspector and the same `Permissions Inspector` workflow, so it failed the same way. The merge had no conflicts and touches only the four files under `tools/permissions-inspector/wasm`. Tests. `./scripts/build-inspector-wasm.sh` builds clean for wasm32-unknown-unknown on this tree, and `cargo fmt --check` on the crate is clean.
Brings upd/split/3-permissions at a2a1dfc into split/4-client-resolve. That renames `config/permissions/vanilla.yaml` to `config/permissions/sample.yaml` and says in the file, in the guide and on the constant that the sample is for testing and evaluation only, is not a production policy and is not legal advice. The merge had no conflicts. `trusted-server.example.toml` auto-merged, because this branch changes other parts of the same file. Tests. `cargo fmt --all --check` is clean and the core suite passes 2,885 tests.
Every name an operator types into configuration is snake_case, so the demonstration provider is now selected with `[ec] provider = "client_fixed"`. The key constant, the two startup messages that spell the key, the page script's log lines, the cargo feature comment, the provider table in AGENTS.md and the resolve endpoint in the API reference all follow. This commit changes only that one name. The cargo feature that compiles the provider in keeps the name `client-fixed-demo`, because a cargo feature name is not a configuration value. The tsjs module id `ec_client_fixed` is unchanged as well, being already snake_case and never compared against the key. The old name is refused when settings load, which every adapter does through `settings_from_config_blob` before it serves a request. The message names `client_fixed` and says what to write, so a deployment still configured with `client-fixed` stops at startup rather than being told to add a provider block that this provider does not need. A block written under the old name does not make the old name acceptable either, because the name is refused before any block is looked for. That is a breaking change, accepted for a major release. Tests. `cargo fmt --all -- --check` is clean and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` passes. The core suite passes 2,886 tests and 8 doc-tests, with and without the `client-fixed-demo` feature, the new test among them. The Axum adapter passes 62 tests, `cargo clippy-axum` is clean, and `cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1` builds. The page script's own vitest file passes 6 tests and ESLint is clean on it.
…rom split/3 Brings up the Edge Cookie provider block layout from split/1, the host_signals rename from split/2 and the permission signal provider key from split/3, under the client_fixed rename this branch already carried. All four are the same decision, so this branch's demonstration provider now sits in the same layout as the rest. Two files conflicted. crates/trusted-server-core/src/ec/provider.rs. Both branches added constants and resolution arms. The client_fixed constants and its resolution arm are kept, listed in BUILTIN_PROVIDER_KEYS beside the two providers that derive an identifier at the edge, under split/3's wording, which calls these implementation ids rather than names. The arm now matches on the implementation the selected block names rather than the selector itself, which is how the other arms read since the block layout landed, so the demonstration provider can be configured under a label as any other provider can. check_named_provider_configuration needed a real decision. This branch wrote it to answer two questions, which are whether the build compiles a name in and whether the name needs a settings block, the second by looking every name up in [ec.providers]. The block layout removed that lookup and moved the block question back into the settings, where it is now asked only of the implementations core knows take settings, because a provider an adapter injects has its block read by that adapter and core cannot say whether it needs one. Reinstating a block requirement for every name would undo that, so the function keeps only the question this branch added it for, which is whether the demonstration provider is compiled into this build, and the settings ask the block question. The function's own reasoning still holds for what it keeps, which is that only the resolution knows what is compiled out. The refusal of the old client-fixed spelling moved with it into the settings, beside the refusal of the old host-signals spelling, and both now run before anything else. They have to, because the block layout holds every provider name to snake_case and both old spellings break that rule, so leaving either refusal later meant an operator got the general rule instead of the message naming the spelling to write. Both refusals cover a block left behind under the old name as well as the selector, through one names_retired_provider helper. crates/trusted-server-core/src/settings.rs. The Ec struct keeps this branch's resolve_allowed_origins field and loses the providers field the block layout replaced. resolve_allowed_origins is a fixed key of the [ec] section, so it joins EC_SECTION_KEYS, without which the section would read it as a provider block. Four of this branch's tests followed the new layout. The one that proves a provider with no block validates now says why the settings no longer demand one, the old-spelling test builds its configuration through the [ec] section helper rather than by deleting the hmac block by text, the missing-block error names [ec.hmac], and the stale-block test builds its block through the shared test helper. Tests. cargo fmt --all -- --check is clean and cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings passes. The core suite passes 2,909 tests and 8 doc-tests with 4 ignored. The Axum adapter passes 64 tests across its five binaries, cargo clippy-axum is clean, and cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 builds.
Carries up the resolutions from split/3. Nothing on this branch touches the code main changed, so the merge needed no decision of its own.
Brings the refreshed permission model branch, and through it the device and geo seams, the Edge Cookie seam and upstream main at 7a0ecb4, into the client-resolve branch. The lower branches' names say module, so every line here that used one of those names says module too, and most conflicts settled as that rename applied to this branch's own text. The rest, settled by hand: - The Fastly entry point keeps this branch's resolve route import beside upstream's EcContext and EidSyncSource imports, and constants keeps the resolved-cookie marker beside upstream's pull-complete cookie. - The seam keeps this branch's client_fixed implementation id and its retired spelling, with the built-in list naming all three ids, under the trimmed docs the seam now carries. - The settings keep this branch's refusal of the client-fixed spelling and its startup compilation check, with the host-signals refusal gone as the device and geo branch removed it. This branch's own names still say provider, and are renamed in a commit of their own.
An [ec] resolve_allowed_origins entry such as "https://www.example.com/" loaded cleanly, then matched no request, because origins_match treats any value that is not a serialized origin as matching nothing. Every POST to the resolve endpoint from that origin answered 403, with only a warn line saying why. The parse inside origins_match is now parse_serialized_origin in ec/resolve.rs, and it returns the reason a value is not a serialized origin. origins_match reads the request's Origin and each entry through it, and Ec::validate_resolve_allowed_origins refuses an entry it rejects, naming the entry and the reason. The check runs in Settings::finalize_deserialized beside the module selection check, which covers from_toml, from_json_value and the runtime config store load, and in validate_settings_for_deploy_with, so ts config validate and ts config push refuse the entry before it can stop an instance loading its settings. parse_serialized_origin is stricter than the old closure on both sides. It requires http:// or https:// and then nothing from a slash, a backslash, a question mark, a hash, an at sign, whitespace or a control character before handing the value to the url crate for the host and port. The old closure let through userinfo, the ftp, ws and wss schemes, a path that normalizes to a slash, a backslash, a missing double slash and surrounding spaces, each of which matched itself. A browser sends only http or https as the Origin of a page's POST, so no real request that matched before stops matching. The field doc, docs/guide/configuration.md and trusted-server.example.toml say each entry must be a bare origin and is refused otherwise.
handle_ec_resolve rebuilt its module with build_module, passing the module the composition root had threaded into the request as the injected one. On Fastly that is the built-in module itself when hmac is selected, so build_module found the built-in name claimed twice and the endpoint answered 500 for a valid selection, where it should answer 204 for a module that creates nothing from a client post. The same happened to client_fixed in a build that compiles it, which is the one built-in module whose page script posts here. The endpoint now uses EcContext::selected_module, the module the request's own context resolved. The context's host signal and injected module fields existed only for the rebuild, so they and their accessors are removed. The resolve tests built contexts with no module and relied on the rebuild, so their helper now resolves the selected module the way a request's context does. Tests resolve_answers_204_for_a_threaded_module_that_creates_nothing and resolve_sets_the_cookie_for_a_threaded_client_fixed_module read a context with the module threaded through the services, as Fastly does, and fail without the change.
The Edge Cookie settings refused the old client-fixed spelling with a message naming the spelling to write. No configuration in this repository uses it, so the constant, the refusal in validate_module_selection, names_retired_provider, the test of the refusal and the doc paragraphs describing it are gone. An old spelling now fails as any unknown implementation does when the module is built at startup, with a message listing the implementations the deployment has, which an_unknown_implementation_fails_naming_the_known_ones covers.
set_provider_ec_cookie validated and wrote the same cookie as set_ec_cookie, which no longer checks the HMAC identifier shape either, so the resolve endpoint had a second copy of one function. The copy is deleted, handle_ec_resolve calls set_ec_cookie, and the client_fixed module's doc links to it. The three tests of the copy fold into the set_ec_cookie tests, as two tables. One writes an HMAC identifier, an opaque value and a base64url value verbatim with the security attributes, and the other refuses a semicolon, a carriage return and line feed, a line feed and a space with no cookie at all. The second fails when set_ec_cookie stops validating.
Ten resolve tests built the same request and context and differed only in a value and the answer they expected. They become two tables that name the case in every assertion and keep every case. resolve_answers_204_with_no_cookie_when_it_creates_nothing covers the closed permission gate, no module, no identity graph and a value that fails verification. resolve_refuses_a_request_it_cannot_take covers no Origin, a foreign origin, a sibling subdomain, plain http, a port, a form body and a body over the limit, and now also asserts that each sets no cookie. Both fail when the origin and gate checks are taken out.
The header double's mint flag becomes the identifier it returns, so a test can post an identifier the cookie cannot carry, and a second helper takes a request the test builds itself. Three tests are added, each failing with the behavior it covers broken: an identifier outside the cookie bounds is refused with 400 and leaves no cookie and no identity-graph row, a failed identity-graph write answers 503 with no cookie, and a Content-Length over the limit is refused before the body is read. The reserved-effect tests say created where they said minted, and the comments say what the code does rather than what it once did.
This was referenced Oct 17, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
crates/trusted-server-core/srccrates/trusted-server-js/libcrates/trusted-server-adapter-fastly/srcdocs/guidetrusted-server.example.tomlcrates/trusted-server-core/Cargo.tomlcrates/trusted-server-adapter-spin/srcAGENTS.mdCloses
Closes #778
Spec
Spec: client-cycle-ec-resolve
Test plan
CI on this pull request at 04d9284 passed all 18 checks.
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code (clippy deniesunwrap_usedin CI)logmacros as AGENTS.md asks (notprintln!)Stacks on the permission model (#1345), and depends on it because the resolve
endpoint applies the same permission gate as organic generation. Through the
stack this pull request also depends on the Edge Cookie module seam (#1343),
whose
EdgeCookieModuletrait gains the client resolve method here. It is the fourth of the five pull requests (#1343 to #1347) that decomposethe combined module change (#838), as the review of the earlier design specs
(#986) asked.
The design specs for the series are carried by the spec pull request (#1342).
The spec for this pull request is
2026-07-30-client-cycle-ec-resolve-design.md,the threat-model draft revised to the implemented state. Its revision record
maps each requirement to what v1 implements and what deliberately waits for the
first vendor scheme.
What changed after the 51Degrees PR review
Agreed at the review on 23 October, point 1. No configuration or permission policy is compiled into the WASM, and a WASM without a policy does not start. Since #1345 a loaded domain carries its permission policy and every services graph needs one, so the tests below give theirs the sample policy.
Kept current
mainat 7aa5c99 and the changes made lower in the stack.Why this path matters
A client-cycle module establishes the identifier through a browser round
trip. The page script obtains or derives a value, which for a real vendor is a
signed envelope from that vendor's identity system, posts it to
POST /_ts/api/v1/ec/resolve, and the module verifies that value before theedge sets it as the Edge Cookie. The vendor Edge Cookie module proposed in the
51Degrees module issue (#1072) works client-side by design, so this endpoint is
on the series' critical path rather than deferred.
What this pull request does
EdgeCookieModulegainsresolve_from_clientwith a default that createsnothing, so server-side modules are untouched.
own. The permission gate applies unchanged, being the module's complete
declaration rather than a hard-coded storage check, and the module's
response headers pass the same reserved-surface check before any is kept. The
request must carry an
Originon the publisher's domain (403 otherwise) anda
text/plainorapplication/jsonbody (415 otherwise, 413 over 64 KiB,refused from the advertised
Content-Lengthbefore the body is read). Acreated identifier must fit the global identifier bounds (400) and must not
silently replace a different identity already on the request (409).
when the page was served, so a module an adapter threads into the request
is the one that verifies the posted value.
set, keyed by the module's canonical form, so withdrawal reaches a
client-set identity the same way it reaches an edge-created one. With no
graph available the endpoint sets no cookie at all, which is stricter than
organic generation, where the identifier is kept and only the row write is
skipped. A graph write failure answers 503 with no cookie. A closed
permission gate, no configured module, no graph, or a module that creates
nothing answers 204 with no Edge Cookie. The module's own response headers
go out only with the 200 that sets the cookie, or with the 204 when the
module creates nothing. Every response the handler builds carries
Cache-Control: no-store, and a module or configuration error goes to theadapter's error response instead.
(
ts-ecr=1, no identity content) tells the page script a resolve succeeded,fixing the earlier defect where the script checked for a cookie it could
never read and so posted on every page view. The marker expires together with
the Edge Cookie on withdrawal, and when the request carries an Edge Cookie
the selected module does not own, so a switch between client-cycle modules
resolves again. A Rust test checks that the marker name and the
demonstration's fixed word match the page-script source.
[ec] resolve_allowed_originslists the extra origins the endpoint acceptsbeyond the publisher's own, each a bare origin, checked when the settings
load.
client_fixeddemonstration module is compiled only behind theclient-fixed-democargo feature, and production builds reject the selectionat startup. A fixed shared word is not an identity. The demonstration
module's cookie carries the registry code (
cfix~an-ec), so evendemonstration identities are module-namespaced. The cargo feature keeps its
own spelling, because a build feature is not something a deployment writes
in
trusted-server.toml.as generation. The other adapters deliberately do not route the endpoint yet,
matching
identifyandbatch-sync, which need the same platform key-valuewiring those adapters lack. The Spin adapter's route list records it.
v1. The reservation design needs the real vendor envelope's unique id and
session binding, and the spec keeps the design verbatim as the bar for that
implementation.
How it was verified
Endpoint tests in
crates/trusted-server-core/src/ec/resolve.rscover the origin, content type, advertised and actual body size, identifier
bounds, conflict, no-graph, failed graph write, marker and threaded-module
behaviors, and
client_set_value_round_trips_through_the_ec_scenariodrives aclient identifier through deferral, resolve, cookie set and verbatim
read-back.
References #778. Decomposes #838. Spec baseline from #986.