Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/trackingv0.7.1· Specification

spec-identity — Anonymous Id Lifecycle

The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.

1. Principles

  • The anonymous id identifies a browser/device, not a person. Its only valid shape is a random UUID (RFC 4122), pattern ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ (case-insensitive).
  • The client mints; the server never does. Minting happens lazily, only at the moment an event is actually allowed to send (spec-tracker §2) — never at library init, never as an import/startup side effect, never while the consent gate is closed.
  • The server's only roles are to persist/prolong an id the client hands it and to expire it on withdrawal.

The id lives in a cookie (not local-only storage) so a future server-owned event could read the same id from the request.

  • Name: a token matching ^[A-Za-z0-9_-]+$ (conservative subset of the RFC 6265 token). Builders MUST reject any other name.
  • Value: the UUID.
  • Attributes: Path=/; Max-Age defaulting to one year (31536000 seconds); SameSite=Lax; never HttpOnly (the client tracker must read the id back). Client-side writes add Secure on HTTPS origins; server-issued Set-Cookie headers are always Secure (on a plain-HTTP dev origin the browser may drop it — the client-written cookie still covers that session).
  • Reading a cookie string (name=value; name2=value2 wire format): the name must match exactly; a longer prefix sibling (myidX when reading myid) MUST NOT match.

3. Client-side id manager

Operations and their semantics:

  • getOrCreate() — return the existing cookie value, else mint a fresh UUID, write the cookie, and return it. The live cookie always wins over any in-memory cache: another tab's withdrawal expires the cookie without notifying this instance, and a re-grant MUST NOT revive the pre-withdrawal identity from memory.
  • refresh() — roll an existing id's expiry forward without ever minting; a no-op for visitors who have no id. Safe to call on page load for returning consented visitors.
  • clear() — expire the cookie (Max-Age 0) and drop any cached copy. Called on consent withdrawal (spec-consent §9).
  • Implementations MAY cache the id after the first read so a per-event callback does not re-parse the cookie header; the cache MUST be invalidated by clear() and never trusted over the live cookie.

4. Server persistence (the ITP re-issue)

Script-written cookies are capped (e.g. 7 days by Safari ITP); a server Set-Cookie re-issue is what lets the id live its full max-age. A server-persisted id manager wraps the client one:

  • Optimistic local write first — the current event never waits on a round trip; a failed persist degrades to plain client-side behavior.
  • At most one persist request per page load, fired only after an id exists client-side. The re-issue also fires when the cookie already existed (it upgrades a script-written cookie to a server-set one and rolls expiry forward). refresh() shares the same once-per-load budget — it MUST NOT add a second request.
  • If the cookie disappears out-of-band (cross-tab withdrawal), the once-per-load flag resets so the next minted id is persisted again. Likewise after a local clear() — a fresh id after a re-grant must be persisted again.
  • Persist/clear round-trip failures MUST be swallowed (tracking never breaks the app).
  • clear() also requests the server-side expiry — but only when the cookie actually exists, so a second withdrawal from another mounted consent surface in the same tick costs no extra request.

Server endpoint requirements. The persist endpoint is public and echoes its input into a response header, so:

  • It MUST validate the incoming id against the exact UUID pattern (§1) and reject anything else — a non-UUID value is a header-injection attempt or corruption, never a valid id.
  • It MUST validate the cookie name against the token pattern (§2).
  • On success it answers with a Set-Cookie header persisting the id (§2 attributes); the clear endpoint answers with the same cookie at Max-Age=0.
  • The server MUST only ever re-set an id the client handed it — never generate one.

5. Withdrawal

On a decision with analytics == false: client-side clear() (cookie expiry + cache drop) plus the server-side expiry per §4. Destination identifier cookies are cleared per spec-destinations §4.

Conformance vectors

V1 — id shape. "6f1c2a4e-9b0d-4c3e-8f5a-1d2e3c4b5a69" valid; uppercase hex valid; "not-a-uuid", "", "6f1c2a4e-9b0d-4c3e-8f5a-1d2e3c4b5a69; Path=/evil" invalid — the persist endpoint rejects them.

V2 — Set-Cookie build. (name anon_id, id U, default max-age) → anon_id=U; Path=/; Max-Age=31536000; SameSite=Lax; Secure. Clear → anon_id=; Path=/; Max-Age=0; SameSite=Lax; Secure. Name bad name → error.

V3 — exact-name cookie read. Cookie string anon_idX=zzz; anon_id=abc read as anon_idabc; string anon_idX=zzz → none.

V4 — lazy mint. With the gate closed, no track() call ever creates a cookie. With the gate open, the first permitted track() mints exactly one id; subsequent events reuse it.

V5 — cross-tab withdrawal. After the cookie is expired externally, getOrCreate() mints a new UUID (never returns the cached old one), and the persist request fires again for the new id.

V6 — once-per-load persist. Three permitted events on one page load → exactly one persist request. A refresh() after getOrCreate() on the same load → still one request.

License

Released under the MIT License.