Identity
Lightweight client ids reconciled to a verified userId as early as possible — with identity providers that plug into the auth you already have.
Identity answers who is behind the event. It is Dotyc's third family of bricks, distinct from adapters: adapters consume events, identity providers resolve who sent them.
The model
An identity has three layers, only the first mandatory:
interface Identity {
anonymousId: string // lightweight id per DEVICE — cookie (localStorage fallback)
sessionId: string // lightweight id per SESSION — volatile, rotates on inactivity
userId?: string // the authenticated human — the reconciliation pivot
accountId?: string // the organization / account (B2B)
traits?: Record<string, unknown> // plan, role, …
verified: boolean // false = emitter claim, true = resolved at ingest
}Trust model: what the client declares is a claim; what the ingest resolves from a session or token is the truth. When both exist, the verified version wins.
Lightweight ids, early reconciliation
The client only ever generates cheap, PII-free ids: an anonymousId per device (persisted in a cookie — SSR-friendly, shared across subdomains, readable by same-origin ingest — with a localStorage fallback) and a volatile sessionId. Both are attached to every event, before any login.
The verified userId is the pivot that reconciles them — and reconciliation happens as early as possible. The moment an ingested event carries both lightweight ids and a verified identity, the anonymousId/sessionId ↔ userId link is signaled to adapters — without waiting for an explicit identify() call. Each device attaches itself to the pivot at its first authenticated event, so every session and device of a user becomes addressable in your destinations.
The merge itself belongs to the destinations: PostHog aliases, your CRM upserts. Dotyc stores nothing.
Emitter side
Manual control is always available:
dotyc.identify({ userId, accountId, traits }) // deduped — no-op if unchanged
dotyc.reset() // logout: new sessionId, identity purgedBut the intended path is zero identity code: plug an identity provider into the auth you already run —
import { createEmitter, http } from 'dotyc'
import { clerkIdentity } from '@dotyc/clerk/client'
export const dotyc = createEmitter({
catalog,
transport: http('/api/ingest'),
identity: clerkIdentity(), // reads the current user from Clerk, subscribes to changes
})Official providers are planned for Better Auth, Clerk, Supabase Auth, and Neon Auth, plus generic JWT and custom-function fallbacks. Each provider ships both facets — emitter and ingest — in one package.
Ingest side
The same mechanism, verification facet: the provider resolves the session or token from the incoming Request and overrides the claim.
import { createDispatcher } from 'dotyc/ingest'
import { clerkIdentity } from '@dotyc/clerk/server'
const dispatcher = createDispatcher({
catalog,
wiring,
identity: clerkIdentity(), // session → verified identity, overrides the claim
})Full-client apps are covered. No backend session? The provider verifies the JWT your auth issues (Clerk, Supabase, Neon Auth, and Better Auth all issue one) — verified identity without a server of your own. Dotyc never forces you server-side.
Signaling to adapters, without hammering them
Identity transitions reach adapters through the dedicated optional identify?() hook of the adapter interface — PostHog translates it to a native identify/alias, a CRM adapter to an upsert. Two dedupe caches keep the hook quiet:
- Emitter: a hash of the last identity is persisted alongside the
anonymousId, so a transition is only emitted when the identity actually changes (auth SDKs notify on every render/refresh; Dotyc absorbs it). - Dispatcher: a TTL cache of already-signaled
anonymousId ↔ userIdlinks, soidentify?()fires only on a new link or a changed trait.
An honest limitation: retro-merge depends on destinations
The merge belongs to the destinations — and not all of them can retroactively re-attach anonymous events already written. PostHog can (its alias mechanism merges the anonymous history); Matomo is weak at it; a naive custom adapter cannot at all: rows written under an anonymousId before login do not rewrite themselves when the userId arrives.
The pattern for custom adapters is to plan for it instead of fighting it:
- Store
anonymousId(andsessionId) in your rows from the very first event, even for pre-login traffic. - Resolve
anonymousId → userIdat read time. The link arrives through theidentifyhook — persist it once (a small mapping table), and your queries join through it.
That way pre-login history becomes attributable the moment the link exists, without ever rewriting rows.
identify() is not a catalog event
A deliberate decision: identity transitions do not flow through the wiring. The real-world needs — provider aliasing, CRM upserts — are covered by the dedicated identify?() channel, and routing identity through the catalog would create magic recursion.
If you want business logic on login, declare an explicit signed_in event in your catalog and track it. Explicit beats magic — consistent with everything else in Dotyc.
Schema evolution
Every event carries a stable schemaHash; the ingest never blocks on a version mismatch, and a committed schema history makes your repo the schema registry.
Adapters
The consumers: a minimal name + handle() contract, an optional lifecycle, guaranteed isolation — and six conventional roles that turn a wiring into an app's event backbone.