End to end
One device, five hops, and the hour that decides them
Before any of the code, watch the thing work — and watch it not work. The trace follows a single device through every stage of the spine. The only control is where the replay stops.
↳ Same device, same events. The only difference is whether the identify at 11:04 has been replayed yet.
Collection
An event is only trustworthy at the edge you control
Start at the edge, because everything downstream inherits its mistakes. The collector is a first-party server-side endpoint on a subdomain the brand controls — not a third-party pixel on someone else’s domain. It sets the device cookie itself, and because the server sets it, tracking prevention does not cap it at days the way it caps a cookie written from a page script.
- A first-party subdomain, not a pixel. The collector host is a record the brand controls, so the cookie is same-site and the request is not classified as third-party tracking.
- The cookie is HttpOnly. Page scripts cannot read or forge the device id, which means it is something your infrastructure asserts rather than something the page claims.
- The server stamps the time. The client clock is recorded and never trusted — clocks skew by hours across a real device population, and clients lie, both accidentally and on purpose.
- The handler does exactly two things. Validate the envelope, publish it. No lookups, no enrichment, no writes to anything with an index.
The pipeline
Five stages, one streaming backbone
Each stage reads a topic and writes a topic. That is the entire architecture, and it is deliberate: any stage can be stopped, rewritten and restarted from its own position without coordinating with the others.
| Topic | Key | Compacted | Contents |
|---|---|---|---|
| events.raw | brandId:deviceId | no | every collected event |
| identity.signals | brandId:deviceId | no | extracted match keys |
| identity.resolved | brandId:profileId | yes | current profile ↔ device/key set |
| identity.merges | brandId:profileId | no | merge and un-merge audit records |
| identity.deletions | brandId:profileId | no | deletion and revocation tombstones |
| audience.membership | brandId:audienceId | no | adds and removes per sync |
- The resolved topic is compacted on purpose. Keyed by profile, compaction keeps only the latest value per key — so the topic is the current state of the identity graph, not a change feed you have to fold. A new consumer rebuilds full state by reading it from the beginning. No backfill job, no snapshot export, no “please re-run the nightly.”
- Raw is never compacted. The raw event topic is the audit floor. You replay it to rebuild everything else, and you cannot replay history you have compacted away.
- The warehouse is the read side. Events land in a table partitioned by brand and month; traits land in a table that replaces a profile’s row on recompute instead of duplicating it.
- The config store holds definitions, not data. Audience definitions, destination credentials, brand config — small, edited by humans, versioned.
What the rest of the series does with this spine, briefly. Post 2 replaces the toy resolver with a real identity graph. Post 3 turns resolved profiles into traits and a propensity score. Post 4 takes the consent record seriously. Post 5 replaces the naive push below with diffing, batching, dead-lettering and match-rate instrumentation. Post 6 asks whether any of it caused revenue that would not have happened anyway. Each owns its stage; none of them re-derive this one.
Isolation
Brand and environment are partition keys, not afterthoughts
Two brands on the same cluster are two different companies, and a test environment must never leak into production. So scope is not a filter someone remembers to apply — it is in the key, the partition, and the namespace.
- The namespace is brand plus environment. Two brands in production do not share a partition, a topic key prefix, or a warehouse part.
- Every stream key is prefixed with the brand. One brand’s traffic spike cannot reorder another’s, and a per-brand replay is a key-range scan rather than a full-topic filter.
- Every table is partitioned by brand. Dropping a brand is dropping partitions. Querying one brand never touches another’s data files.
- Scope is constructed once, at the edge. It travels with the event. Nothing downstream reconstructs it from a header or infers it from a hostname a second time.
Partitioning keeps brands apart; it does not keep people safe. Consent as a join key, row policies bound to the executing role, and the external agency login that makes both necessary are post 4’s job.
The sync
The thin end: one audience, one destination
An audience is a document. The compiler turns it into warehouse SQL, the SQL returns hashed keys, and the adapter pushes them at a local mock destination. That is the minimum viable activation, and it is enough to prove the spine end to end.
- Definitions are data, not code. The audience is a document a marketer edits; the compiler is the only thing that turns it into SQL.
- The compiler emits parameters, never interpolated values. An audience definition is user input, and it is treated as such.
- Membership is written back to the stream. Keyed by audience, so post 5’s diffing has something to diff against.
- This push is deliberately naive. Full membership, one batch, no diff, no retries, no match-rate measurement. It is wrong in production and correct as a teaching step.
Diffing against the last snapshot, batching to a destination’s limits, dead-lettering the rows a success response quietly rejected, and instrumenting match rate all land in post 5. Here, the mock accepts everything and reports nothing — which is exactly the failure mode post 5 exists to fix.
Teaching-grade reference implementation, not a production customer data platform. It reproduces the ideas and the streaming/warehouse integration shape; bring your own data and destination credentials. Destination adapters run against a local mock by default. MIT-licensed. View the repo →
Explain it back
Reveal a model answer
They put an unbounded, stateful operation in the hot path of every page view. Identity resolution is a graph mutation whose contention grows with traffic — exactly the workload you must not synchronise with request handling. The collector’s one job is to accept and publish: validate the envelope, stamp the time, write to the raw topic, return. Anything that can be slow, fail, or need a database belongs behind the log, where backpressure shows up as consumer lag instead of as a broken storefront. Lag is a metric you alert on; a timeout at the edge is lost revenue you never recover, because the events were never durably written.
The bonus consequence is worse and quieter. An inline resolver couples your ability to replay to your ability to collect — the resolution decision lives only in the response you already sent. Fix a normalisation bug six months later and there is nothing to re-run. With the resolver as a consumer, you reset its position and rebuild the graph from raw.
The toy resolver becomes a real graph — links with provenance, un-merging as an ordinary operation, and the shared kiosk that quietly swallows four households into one customer.
See the merge storm →