0166 — Tracking mechanics live in core; storage and domain stay outside
Decision
stitchkit/tracking (browser) owns the mechanics of visitor tracking: a
tab-shared outbox with reserved sequence numbers and a short flush lease,
delivery with one bounded retry, the page-leave beacon with a string body and
a queued copy, additive visible time, scroll milestones, declarative clicks,
UTM first/current touch, and the client that composes them behind a host
interface. stitchkit/tracking/server owns the decisions: batch dispositions,
the visit-lease algorithm over a store the application implements, active
intervals, the bot filter, process-local presence.
Neither owns a table, a transaction, an event type, a referrer list, a React component or a report. Both are evolving.
The evidence
On 2026-09-06 two consuming applications held the same browser code nearly
verbatim — delivery.ts differed by 18 lines, outbox.ts by exactly the
fixes one of them had made — and the same server algorithm written twice. The
second application had found and fixed four defects in the shared mechanics:
- a ten-second flush lease that a dying document never released, so the next document’s first flush waited the whole ten seconds;
- an event that got its sequence number when it was written, so the page-leave
event awaited IndexedDB inside
pagehideand the document died first; - a page-leave beacon with a JSON
Blob, which reports success and dies on the preflight it cannot have (ADR 0165); - a component-level guard against double mounting that a remount cannot see.
The first application still carried three of the four. That is the cost of a copy: a fix lands where it was found and nowhere else, and nothing tells the other copy. A third application had a third implementation of the same class with a different identity model (a bearer token, not a cookie); it shares the beacon defect and little else, and this decision does not pretend otherwise — it adopts the beacon and the scroll milestones, and keeps its own queue.
Why the boundary is here and not where the request put it
The request proposed the server half with the advisory lock, the
conflict-aware insert and “the shape of the tables as a schema contract, as
stitchkit/files does”. files does no such thing: it exports a path/ref
boundary. packages/core/src contains no Postgres, no Prisma and no advisory
lock; the only entries that own a database are agent-runtime/sqlite/{bun,node},
and those are optional adapters behind AgentRuntimeStore — the application
implements the interface, the runtime owns the algorithm. That is the pattern
here too: TrackingVisitStore is six methods the application writes over its
own tables inside its own transaction; issueVisitLease is the algorithm that
calls them in the right order under the lineage lock. dispositionTrackingBatch
takes the visits and stored hashes the application read and returns what to
write, including which anonymous visits the caller may adopt — so adoption
stays in the application’s transaction, where its SESSION_MERGE event and its
first-touch capture already live.
VISION says applications own their database adapter. Owning a Visit table
from inside the framework would make this the first entrypoint to dictate a
consumer’s schema, and there is no version of that which survives the second
consumer’s schoolId column.
ADR 0002, answered
The core is generic and carries no domain model. Two things in this module look like domain and are not; two things that would be domain are parameters.
Not domain: the event envelope (eventId, visitId, browserStreamId,
browserSequence, type, page, metadata, clientTimestamp) and the
dispositions (accepted, duplicate, identity-invalid, excluded-bot).
Both applications had them word for word before this module existed; they
describe the mechanics — idempotency, lineage, ordering — not a product.
Parameters: the event types (a z.enum the application passes, with an
optional eventExtras object for fields it carries beside the envelope), and
the referrer map (t.me → telegram / social is a marketing decision;
the framework ships no list, and an unmatched external referrer is a
referral). The data-track* attribute names are configurable for the same
reason. The names of the events the client itself emits (PAGE_VIEW,
PAGE_LEAVE, …) are a required builtin map, typed against the application’s
metadata map, so the client speaks the application’s vocabulary rather than
imposing one; CONVENTIONAL_TRACKING_EVENT_TYPES is the set both consumers
happen to use, offered, not assumed.
The host interface
Everything the client takes from a browser — page context, visibility, scroll
depth, pagehide and friends, intervals, clocks, storage — is one
TrackingHost object, and browserTrackingHost() is the tab. This is what
makes the client a plain object a test can drive without a DOM, and it is what
lets the mutation tests in this repository put each of the four defects back
and watch the named test redden. It is also the seam a non-tab host (a WebView
that never fires pagehide) would use.
What stays outside, and why
- GeoIP. A MaxMind reader with hot reload is a new runtime dependency and a file-watching lifecycle; all three consumers copy it too, and it deserves its own decision rather than a ride on this one.
- React. The provider is forty lines of glue that own the router and the session’s readiness — both the application’s. It is shown in the guide.
- Reports, labels, the analytics page, Socket.IO push. The reason the two applications’ tracking looks different.
- The consumers’ migrations. Both are several breaking minors behind; the migration begins with the upgrade and is each consumer’s task.
Consequence
- Entrypoints
stitchkit/tracking(browser + server) andstitchkit/tracking/server(server), both evolving, registered in the one entrypoint list. fake-indexeddbas a devDependency, so the IndexedDB adapter consumers run is the one the suite exercises — a memory-only test would leave the real adapter unexecuted.createTrackingContractsetssafelistedBody: trueontrack, so a consumer inherits ADR 0165’s requirement of an explicitcors.origin.
Related
- ADR 0165 — the safelisted body the beacon depends on.
- ADR 0002 — no domain model in the core.
- ADR 0161 — why the schema factory has two overloads instead of a conditional spread.