Skip to content

Add the client-set Edge Cookie value path - #1046

Open
jwrosewell wants to merge 199 commits into
IABTechLab:mainfrom
jwrosewell:split/4-client-resolve
Open

jwrosewell wants to merge 199 commits into
IABTechLab:mainfrom
jwrosewell:split/4-client-resolve

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

This description was updated on 7 October 2026, and on 8 October for the merge of main at 182fdf4. The branch now carries
the refreshed permission model (#1045 at fd51fe8) and, through it, main
at 182fdf4. Since the last version of this text:

  1. What an operator selects is a module. The demonstration module is
    selected with [ec] module = "client_fixed", the trait that gains
    resolve_from_client is EdgeCookieModule, and every name this pull
    request adds says module, as the seam's names do. Through the permission
    model the branch carries the module name rule, under which a module from
    a crate is named by its folder below crates/, and
    [permission-signal] modules. client_fixed is one of core's own
    bare names and is unchanged.
  2. The resolve endpoint answers with the module the request's own context
    selected, rather than building one again. On Fastly the composition root
    threads the built-in module into the request, so the rebuild found the
    built-in name claimed twice and answered 500 for a valid selection, where
    it should answer 204 for a module that creates nothing from a client post
    and 200 for client_fixed. Two tests read a context with the module
    threaded through the services, as Fastly does, and fail without the
    change.
  3. A resolve_allowed_origins entry that is not a bare origin (http or
    https, a host, a port if there is one, and nothing else) is refused
    when the settings load and by ts config validate and ts config push,
    with a message naming the entry and what is wrong with it, rather than
    being kept and matching nothing, which answered every resolve from that
    origin with 403.
  4. The old client-fixed spelling is no longer refused by name. No
    configuration uses it, and it now fails as any unknown implementation
    does, with the message listing the implementations the deployment has.
  5. The endpoint writes the cookie with set_ec_cookie, the one writer every
    Edge Cookie goes through. Its own copy of that function is gone.
  6. The tests that repeated one arrange are tables, and three cases the
    earlier text said were not driven now are: an identifier outside the
    cookie bounds answers 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 commits since the merge of #1045 (20af33c) are c4722aa (bare
origins), c87a986 (the selected module answers), 3b30859 (the old
spelling), 8ae43d1 (one cookie writer), 7de2f18 and bbda299 (the
resolve tests), c91b8c6 (the demonstration module's tests), b63eabc
(the merge of #1045's CI fixes) and ff56a5f (the client resolve path says
module).

On 7 October, e3aeeba restored the icu_provider crate name in the
permissions inspector's lockfile, which the merge of #1045 had turned into
icu_module. The inspector built regardless, because its build script runs
cargo without --locked and cargo re-resolved the crate each time, and
cargo check --locked now accepts the file as written. 9a37728 is the
merge of #1045 at fd51fe8, which brings the module name rule and the
permission signal modules named by their crate folders.
6b9f52b is the merge of #1045 at c0a0f3a, which brings comment and guide
wording from the three pull requests below and #1043's statement of what
normalize_id_for_kv decides, with its test. This pull request's own
files are unchanged.
59660c4 is the merge of #1045 at 613cd18, which brings #1043's wording
of the identity graph's comments as device records, not person records.

Stacks on the permission model (#1045), 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 (#1043),
whose EdgeCookieModule trait gains the client resolve method here. The
stack has six pull requests (#1043, #1044, #1045, #1046, #1047, #1094), each
targeting main, with this one fourth, and the first five decompose #838 as
requested in the #986 review.
Compare split/3-permissions with split/4-client-resolve
to see only this pull request's change.

The design specs for the series are carried by the spec pull request (#1084).
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.

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 the
edge 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

  • EdgeCookieModule gains resolve_from_client with a default that creates
    nothing, so server-side modules are untouched.
  • The resolve endpoint enforces the same rules as organic generation plus its
    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 Origin on the publisher's domain (403 otherwise) and
    a text/plain or application/json body (415 otherwise, 413 over 64 KiB,
    refused from the advertised Content-Length before the body is read). A
    created identifier must fit the global identifier bounds (400) and must not
    silently replace a different identity already on the request (409).
  • The endpoint answers with the module the request's own context selected
    when the page was served, so a module an adapter threads into the request
    is the one that verifies the posted value.
  • A created identifier is persisted to the identity graph before the cookie is
    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 the
    adapter's error response instead.
  • The Edge Cookie stays HttpOnly. A non-HttpOnly companion marker cookie
    (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_origins lists the extra origins the endpoint accepts
    beyond the publisher's own, each a bare origin, checked when the settings
    load.
  • The client_fixed demonstration module is compiled only behind the
    client-fixed-demo cargo feature, and production builds reject the selection
    at startup. A fixed shared word is not an identity. The demonstration
    module's cookie carries the registry code (cfix~an-ec), so even
    demonstration 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.
  • The Fastly adapter routes the endpoint with the same bot-gated identity graph
    as generation. The other adapters deliberately do not route the endpoint yet,
    matching identify and batch-sync, which need the same platform key-value
    wiring those adapters lack. The Spin adapter's route list records it.
  • The full anti-replay reservation design from the spec is deliberately not in
    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

The head this pull request shows now is e7623de78, which merges #1045 at 0afd715c5 and, through it, main at 182fdf4 into 59660c468. The merge brings the ts dev lint domains and ts dev install-hooks commands (#733) and a design document (#930), and changes none of this pull request's own code. No dependency of core or of an adapter changed version, and the one line of core the merge touches gains a comment. On e7623de78 these ran locally on Windows: cargo fmt --all --check, a locked dependency check (cargo tree --locked) and the core suite natively (3,140 tests).

59660c468 is two merges of #1045 above
9a3772871, which bring comment and guide wording, the identity graph's
comments, one doc comment and one test. On 59660c468 these ran locally on Windows: cargo fmt --all --check,
Clippy with warnings denied on all four adapters and the five permission
signal crates, the core suite natively (3,140 tests), the five permission
signal crates' own tests (55 tests), and the docs lint, Prettier and VitePress
build. On 9a3772871 the wider set ran locally, being the core suite under
Viceroy (3,377 across the test binaries the Fastly alias runs, the Fastly
adapter's included), the Axum (123 tests, the five permission signal crates
included), Cloudflare (56) and Spin (90) adapter suites, the cross-adapter
parity suite (17) and the permissions inspector's wasm build, and CI runs
all of them on this head. The CLI tests, the browser integration tests and
the Fastly Edge Cookie lifecycle test run only in CI.

CI on e7623de78: every check passes, being Run Tests, Run Format, Integration Tests, Permissions Inspector and CodeQL Advanced, with the Fastly Edge Cookie lifecycle test in the integration run. Run Tests passed on its second attempt. The first failed in one test, collects_gpt_slot_from_local_fixture, on a browser launch timeout, which #1256 describes.

Endpoint tests in
crates/trusted-server-core/src/ec/resolve.rs
cover 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_scenario drives a
client identifier through deferral, resolve, cookie set and verbatim
read-back.

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

@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch 3 times, most recently from 88f96f8 to 82cd70f Compare August 25, 2026 10:51
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch from 82cd70f to 0217e09 Compare August 25, 2026 13:37
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch 6 times, most recently from 8a67a9f to eff9f74 Compare August 31, 2026 12:50
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Aug 31, 2026
The review of IABTechLab#1043 asked that spec changes land before the code that
implements them, so a divergence is a decision taken in review rather
than a ratification of something already merged. PRs IABTechLab#1043 to IABTechLab#1047
each carried the design document for their own step, and IABTechLab#1043 carried
a 607-line spec describing device providers, geo providers, the
permission model and the browser resolve endpoint, none of which is in
that PR.

Move all six series documents here, so this PR carries the complete
normative set and no code:

- 2026-07-30-pluggable-providers-design.md (from IABTechLab#1043)
- provider-code-registry.md (from IABTechLab#1043)
- 2026-07-30-permission-model-design.md (from IABTechLab#1045)
- 2026-07-30-client-cycle-ec-resolve-design.md (from IABTechLab#1046, later
  revised by IABTechLab#1047)
- 2026-07-30-integration-response-header-hook-design.md (from IABTechLab#1047)
- 2026-07-30-provider-migration-rollout-design.md (from IABTechLab#1047)

Each file is taken verbatim at the tip of the stack, so the later
revisions are preserved: the provider-switching continuity section, the
geo requires-signal floor, and the code-envelope paragraph IABTechLab#1047 added
to the client-cycle spec. The revision-record tables are unchanged. No
document's substance was edited.

The only edits are to this spec's own status line, which said the PR
adds one document and that the series specs land with IABTechLab#1047, and a
revision-record row recording the move.
@aram356

aram356 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

The sequencing discussion for this series is on #1084. This PR is superseded rather than rejected. The design in §3.6 is accepted and most of the provider work carries over onto the reordered base. See #1084.

…ider

First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.

- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
  The deprecated [ec] passphrase form still starts for one release
  cycle: it maps to provider = "hmac" with a deprecation warning, and a
  configuration carrying both forms is rejected. provider = "none"
  spells explicit statelessness. A configured block that is not the
  selected provider is rejected at startup, as is a block with no
  selector.
- Global identifier bounds enforced by core at mint, read-back, and
  cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
  cap. An identifier outside the bounds is rejected loudly, never
  rewritten, so the cookie value and the identity-graph key can never
  silently diverge.
- The identity graph is keyed by the provider's canonical form of the
  identifier (normalize_id_for_kv), so equivalent representations of
  one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
  access to the client IP, headers (including cookies), URL path, and
  query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
  provider, so a vendor provider lives in its own crate and core never
  names it. A selected provider the adapter does not inject fails the
  request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
  proceeding stateless.

Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.

Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.

The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.

Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.

is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
CodeQL's cleartext-logging query treats a call whose name contains
"passphrase" as a sensitive source, and because the method mutates the
Settings it belongs to, every later log line that prints anything from
Settings (store names, timeouts, header names) is reported as writing a
secret to a log. The passphrase itself is a Redacted<String> and none of
the flagged lines prints it. The method now describes what it does,
migrate_legacy_ec_layout, and its behavior is unchanged.
A reviewer raised a P1 against the pluggable Edge Cookie provider work:
three of the four adapters broke the provider contract that an
unavailable required service or an uninjected provider stops the
request.

The Axum, Cloudflare and Spin adapters each read the Edge Cookie context
with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`,
logged a warning and continued with `EcContext::default()`. A deployment
whose selected provider could not be built therefore came up and served
every request with no identity, silently. The Fastly adapter already
kept the report and answered with an error response.

`build_ec_context` on the three adapters now returns
`Result<EcContext, Report<TrustedServerError>>` and every call site
propagates it to that adapter's own `http_error`, the same helper Fastly
uses, so all four answer with the same status and shape.

The design this implements has the composition root check a selected
provider's needs once at startup rather than per request, so
`ensure_provider_available` was added to `ec/provider.rs` and is called
from `build_state_with_settings` on all four adapters (Fastly included,
so the rule is uniform). Building a provider reads no request data, so
a selection an adapter can never supply now fails when application state
is built, and the three adapters answer every route from their existing
`startup_error_router` instead of coming up. Statelessness, meaning no
`[ec] provider` selector or the explicit `"none"`, still passes and
still serves.

The widening question was checked rather than assumed.
`read_from_request_with_geo` can only fail from two places: the provider
build, and a `Cookie` header that is not valid UTF-8. A malformed cookie
value is dropped with a warning by `request_ec_id_if_allowed`, consent
parsing returns a value rather than a `Result`, and the geo lookup is
already swallowed by the adapter before the call, so no ordinary parse
problem reaches the error path and none is turned into a failed request.

Tests: each of the three adapters gains a route test proving an
uninjected provider fails at startup, and an in-crate test proving
`build_ec_context` returns the error rather than a default context.
Core gains a test that the startup check rejects an uninjected provider
and still allows statelessness both ways.

Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1)
`Settings::finalize_deserialized` runs derive validation before
`Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase`
field carries no `#[validate]` attribute of its own, so the advertised
32-byte minimum was only enforced on the new `[ec.providers.hmac]`
location. A configuration still on the old form could start with
`passphrase = "short"`, or with an empty value, and mint identifiers
from keying material the new location rejects.

The migration now calls `Ec::validate_passphrase` on the value it is
about to move, before it logs the deprecation warning and writes the
`[ec.providers.hmac]` block, and reports a configuration error naming
the minimum length and the new location.

Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives
`Settings::from_toml` with the `[ec]` section rewritten to the
deprecated form and proves a short value and an empty value are both
rejected, and that a passphrase of adequate length still migrates to
`provider = "hmac"` with the passphrase in the hmac block. Removing the
new check makes that test fail, so it tests the fix rather than the
surrounding code.

Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench)
The docs format job also runs prettier over the Markdown outside docs/,
which rejected both files. This is prettier's own output, with no change
to the wording.
Three tests built the same ClientResolveInput and differed only in the
module, the payload and the identifier expected. They become one table
naming each case: client_fixed with the known word creates the
identifier, client_fixed with another word creates nothing, and hmac
creates nothing from any payload because a server-side module inherits
the no-op resolve_from_client default.
Brings the four commits that fixed the permission branch's CI: the
inspector no longer calls a removed method, its lock follows the
workspace's edgezero revision, and prettier's formatting of the two
permission guide pages, the agent guide and the permission signal README.

# Conflicts:
#	AGENTS.md
What an operator selects to supply a capability is a module, and provider
keeps the one meaning it has on main, an auction provider instance. This
renames the words this branch adds on top of the permission model, which
already says module, and only those words: every provider word was
compared with upstream main, and a word that exists there is untouched,
as is the icu_provider crate the permissions inspector's lock file names.

- ClientFixedProvider is ClientFixedModule, CLIENT_FIXED_PROVIDER_KEY is
  CLIENT_FIXED_MODULE_KEY, and check_named_provider_configuration is
  check_named_module_configuration.
- The test doubles ResolveHeaderProvider and TestIdProvider are
  ResolveHeaderModule and TestIdModule, HEADER_PROVIDER_ID is
  HEADER_MODULE_ID, and the test helpers and test names say module.
- The plain word says module wherever it meant an Edge Cookie module, in
  the resolve endpoint, the cookie and finalize code, the integration
  registry, the demonstration page script, the cargo feature's
  description and the API reference.
resolve_conflicts_when_a_different_identity_already_exists built its
context by hand with no module, so once the endpoint stopped rebuilding
one it answered 204 for no module where the test expects the 409
conflict. The context now carries the module the settings select, as the
other resolve tests' contexts do.
The merge of the permission model into this branch settled the inspector's
`Cargo.lock` through the same rename that turned provider into module in
the code, and the crate `icu_provider` became `icu_module`, a package that
does not exist. The inspector still built, because its build script runs
cargo without `--locked` and cargo re-resolved the crate from the registry
each time, so the fault was invisible to CI and visible to anyone reading
the file. The three lines name the crate again, and `cargo check --locked`
accepts the lockfile as written.
A module's name is the path of its crate below `crates/`, with `.` between
the parts, taken from `CARGO_MANIFEST_DIR` when the crate is built, so the
name cannot drift from the folder and no crate carries a hand-written id.
`module_name!()` gives a crate its own name, `module_name::resolve` finds
the name an operator wrote among the modules offered, as written or with
the section's type folder in front, and `module_name::short_form` gives the
name back without that folder. Core's own modules keep bare names.

The Edge Cookie side is the first to use it. The type folder of every Edge
Cookie crate is `edgecookie`, so `[ec] module` may name an injected module
in full or with that folder left off, and an `implementation` line may do
the same. The rule for a name written in `[ec]`, its blocks and their
`implementation` lines is the module name rule, parts joined by `.`, each
of lower case letters, digits, `_` or `-`, in place of snake_case.
Brings the naming module, so a module is named by its crate folder, and the
Edge Cookie side resolving a written name within its type folder. The
host-signal module's key and the type folder sit side by side.
Brings the naming module from the Edge Cookie seam, so a module is named by
its crate folder.
Each of the five modules takes its name from its crate folder through
`module_name!()`, so `crates/permission-signal/gpp` is `gpp` and
`crates/permission-signal/us-privacy` is `us-privacy`, where the two had
called themselves `gpp_sale_opt_out` and `us_privacy` by hand. The page is
told a signal's source by the short name, without the type folder.

The section is `[permission-signal]`, named exactly as its folder, and its
key is `modules`, a list, because signals compose where `[ec]`, `[geo]` and
`[device]` each name one module. A name in the list may be written in full,
as `permission-signal.gpp`, or with the type folder left off, and the
refusal of a name this build does not offer lists the short names. A
configuration still carrying `[permission_signal]`, a `module` key or the
retired `sources` key is refused with directions to the new spelling.
Brings the naming module and the permission signal modules named by their
crate folders, with [permission-signal] modules. The Edge Cookie module
file keeps the client_fixed key beside the type folder, and the agent
guide's capability table keeps this branch's rows with the permission
signal selector as [permission-signal] modules.
Comments on the adapters and two test notes in core described what the code
did before it changed. Each now says what the code does and why.

- The resolved Edge Cookie module held on the Cloudflare, Fastly and Spin
  application state is resolved when the state is built, because resolving
  reads no request data.
- The three tests that a selected module the adapter cannot build fails the
  request say what a default context would do.
- The Spin state builder reads its settings from the config store, and its
  test says why the failure has to be the absence of a config store.
- The test that keeps the internal header list in step with the Edge Cookie
  response headers, and the test for an unknown key in a module block, say
  what they guard.

No code changes.
- The Cloudflare region test says why the region header has to reach the
  privacy outcome.
- The Fastly geo module's re-export and the geo selector's default are
  described as they are.
- The integration fixture's note on the geo selector no longer promises a
  setting that a later change does not add.

No code changes.
- The integration fixture carried two notes on its geo selector that
  contradicted each other. The one that stays says that Viceroy maps the
  loopback client to US/CA, so the platform geo module resolves a real place.
- That fixture, the Viceroy template, the template cache harness and the
  permission model guide no longer mention a default country setting, which
  no released version had.
- Five code comments say what the code does without saying what it did
  before.

No code changes.
Core builds the identity-graph key from the module's code and whatever
`normalize_id_for_kv` returns, so the method decides what two visits must
have in common to be treated as the same visitor. Its documentation
described normalization only and advised a module with a case-sensitive
identifier to return the value unchanged. That is right for a stable
identifier and wrong for one that carries a signature, a nonce or a
timestamp, because each reissue then gets a row of its own and the identity
does not survive it.

The trait documentation now says that the returned value is the identity two
visits must share, and that a module whose identifier has a part reissued
each time must return the stable part. It also says that core asks
`accepts_id` about the returned string as well as about the identifier as
issued, so a module must accept its own canonical form, or no row is read or
written for it.

`two_issues_of_one_identity_share_one_identity_graph_key` drives a fixture
module whose identifier is a stable part and a part that changes on each
issue through `AcceptedModules::canonical_kv_key`. Two identifiers that
differ only in the reissued part reach one key, and a different stable part
reaches another. The test fails for a module that returns the value
unchanged.

No behavior changes.
…ords

`KvNetwork` said a low cluster count "indicates an individual or
household". A count of connections is a fact about a network, and reading
a low one as an individual describes a device record as a record about a
person. It now says a small network, such as a home connection.

`KvEntry::ids` now says what it holds, which is each partner's identifier
for the same browser on the same device, never an identifier for a person.

Comments only, with no change in behavior.

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.

3 participants