lytics — specification
the canonical specification and build plan. the product story lives in ../README.md. when this document and code disagree, this document is fixed first, then the code.
architecture
visitor browser server reader
┌─────────────────┐ HTTPS ┌──────────────────┐ ┌──────────────┐
│ loader.js ~2KB │ ───────► │ ingest (axum) │ │ dashboard │
│ capture + queue│ │ verify sig+pow │ │ leptos/trunk │
│ core.wasm ~26KB │ │ ua · geo · ref │ ───► │ d3 widgets │
│ keys·sign·pow │ │ cast signal │ └──────────────┘
└─────────────────┘ │ cell (cybergraph)│ ▲
│ bbg state·time │ │
│ query (inf) │──────────────┘
└──────────────────┘ HTTP /api/report/*
| component | language | role |
|---|---|---|
| loader | JS module, ~2 KB source | fires on page load, captures pageview + SPA route changes instantly, runs the attention sensor, queues events until the core is ready |
| core | Rust → wasm, ~31 KB gzip / ~26 KB brotli measured | keygen, per-domain derivation, secp256k1 signing, PoW; loaded async so page performance never waits on crypto |
| ingest | Rust, axum | signature + PoW verification, replay dedup, UA parsing, MaxMind geo (ip read, used, discarded), referrer→source attribution, adaptive difficulty, signal casting into the cell |
| store | in-process event log, cast towards cybergraph cell + bbg | events enter signed and hash-addressed; the cell/bbg wiring for provable state is phase 6 — reports today read the ingest process's own log |
| query | inf native (inf-parse/plan/eval) over a LocalSource built from the log |
every report — including sources, channels, passages and funnels, which group by passage — is a real inf rule; no report answers from a Rust loop over the event stream in the release binary |
| dashboard | static HTML + vanilla JS (static/dash.html) |
polls the report endpoints, no build step; the Leptos/Trunk/d3 skeleton below was never built |
the tracker splits in two on purpose: the loader guarantees plausible-grade capture latency and size; the wasm core carries the cryptography. events fired before the core loads are queued and signed retroactively before they leave the page.
the loader exists because the browser admits wasm only through JS: a wasm
module is fetched and instantiated by WebAssembly.instantiateStreaming —
a JS API — and every DOM, storage and network touch (pushState hooks,
localStorage, fetch) crosses a JS import boundary. the crypto and all logic
stay in Rust — JS is confined to the bootstrap the platform requires and the
sensor the platform exposes.
on core size: the original ≤64 KB budget did not survive contact with the primitives, but nine cuts brought the core from ~172 KB gzip to ~31 KB gzip (~26 KB brotli):
- the 2048-word list left the hot path — the secret is raw 32-byte entropy,
and the BIP39 mnemonic is a lazy backup in
words.js, loaded only on export/import. - k256 was trimmed to sign-and-derive only (no pkcs8/pem/serde/schnorr/ecdh, no precomputed tables).
- serde_json left entirely —
encode_bodyis a hand-written canonical encoder pinned byte-for-byte to the serde_json output by parity tests, so the signing path carries no JSON machinery and no float formatting. - BIP32 gave way to a hemera KDF (
d = Hemera(entropy ‖ 0x00 ‖ domain) mod n), dropping HMAC-SHA512 and bip32 — one hash where a derivation ladder was. - the signing path dropped core::fmt — hand-written decimal and \u00XX hex writers, static error strings.
- panics became
immediate-abort(build-std): a panic is a bare trap, so the whole panic/fmt machinery and its message strings left the binary — the native test suite carries the readable messages. - base64 and the allocator went hand-rolled/minimal (a ~40-line RFC 4648 codec pinned by parity tests; lol_alloc's free-list replaces dlmalloc — the tracker is single-threaded and allocates small short-lived strings).
- hex went hand-rolled too (~30 lines) — the crate's generic decode
iterator (
GenericShunt<Map<Enumerate<Chunks>>>) costs more than the table lookup it wraps. wasm-opt -Ozran over the result.
what remains is the honest floor: secp256k1 signing (k256, now the largest
piece) plus Poseidon2 (hemera, a mere ~3 KB) for the event hash, the KDF,
and PoW. it loads async and gates nothing — the loader captures the first
pageview and the whole attention stream before the core compiles, then signs
the queue retroactively. the deepest remaining lever is a hand-rolled
minimal secp256k1, or moving signing to @noble/secp256k1 in JS (~4 KB,
same curve) — deferred; the curve stays secp256k1 either way so the mudra
bridge and on-chain identity hold.
first-load transfer, measured: ~38 KB gzip / ~32 KB brotli total (wasm +
wasm-bindgen glue + loader), one time, then served from cache. the loader
is a JS module; words.js (~6 KB brotli, the wordlist) transfers only when
a visitor exports or imports their identity.
/api/query is owner-authenticated; public dashboards expose named,
parameterized reports only — raw datalog never faces the open internet.
identity pipeline
the secret is 32 bytes of entropy; each domain derives its own secp256k1 key from it through one hemera hash — the stack's own hash, no BIP32/BIP39 ladder on the signing path:
d = Hemera(entropy ‖ 0x00 ‖ domain) mod n per-domain secret scalar
pubkey = d·G SEC1-compressed (33 B)
│
bech32(hrp, ripemd160(sha256(pubkey))) = neuron (wire form)
Hemera(pubkey) = native neuron id (32 B)
entropy ◀──BIP-39 (lazy, words.js)──▶ 24-word backup (import/export only)
per-domain unlinkability comes from the hash: a distinct domain yields an
independent scalar, so two sites observe two unlinkable neurons and a
leaked per-domain key reveals nothing about the others — the same property
BIP32 hardening gave, without BIP32's HMAC-SHA512 weight in the wasm. the
0x00 separator keeps (entropy, domain) pairs from colliding by
concatenation. domain is the registrable domain (eTLD+1 per the public
suffix list), so one site's subdomains see one neuron. reducing the 32-byte
digest mod n carries ~2⁻¹²⁸ bias (n ≈ 2²⁵⁶) and is zero with probability
~2⁻²⁵⁶ — negligible; the reduced scalar is a valid key. the wire hrp
defaults to lytics and is a deployment setting.
why a hemera KDF, not BIP32: lytics neurons are freshly minted keys that
never lived in a BIP32 wallet, and the per-domain path was custom anyway
(account = Hemera(domain)), so no standard wallet reproduced these keys
regardless. dropping BIP32 for a single hemera hash is smaller (no
sha512/bip32 in the wasm), cyber-native (one hash across the whole stack),
and recovers with a one-line formula — d = Hemera(entropy ‖ 0x00 ‖ domain) mod n — that any tool with hemera and secp256k1 reproduces from the
entropy. the entropy backup stays standard BIP39 (words.js, lazy): the
phrase round-trips the 32-byte entropy with any BIP39 tool; only the
key-stretching is ours.
events are signed in ADR-036 shape, and the mudra claim is key-level (any
secp256k1 key qualifies — no derivation path is part of it), so the
existing bridge claim (legacy address → native neuron) carries any lytics
neuron into the native graph unchanged.
event schema
event {
neuron per-domain visitor neuron (bech32)
actor human | agent agent: self-declared machine reader
agent { name, operator } optional, declared agents only
kind pageview | attention | <custom>
navigation external | direct | internal pageview: how the neuron arrived
hostname, pathname
referrer, source, channel, utm{source,medium,campaign,term,content}
country, region, city derived from ip, ip discarded
browser, browser_version, os, os_version, device
attention { ms, scroll_depth } attention events: ms integer, scroll_depth percent 0-100
props { key: value, ... } typed custom properties
revenue { amount, currency } optional; amount integer, minor units
timestamp unix milliseconds
pow { nonce, difficulty } difficulty: the solved target, a record
pubkey base64 SEC1-compressed secp256k1 (33 B)
signature base64 compact secp256k1 over the ADR-036 doc
}
the client signs the body — every field above pow. pubkey rides outside
the body because the bech32 neuron is a hash of it and verification needs
the preimage. server enrichment (source, channel, geo, device) is
server-attested, derived at ingest, and never part of the signature.
canonical encoding
the signed bytes are the event body as canonical JSON — sorted keys, no whitespace, integers only — wrapped in the ADR-036 sign doc. the event hash is hemera over the body bytes; it doubles as the particle hash and as the dedup key.
proof of work
the PoW predicate is one hash: find nonce such that
Hemera(event_hash ‖ nonce) < target. verification is a single hash
against the server's current target — the client-reported
pow.difficulty is a record, never an input to acceptance. targets come
from the difficulty oracle (GET /api/difficulty), tuned so a median
phone spends ~0.042 s per event.
two targets, one knob: an unseen neuron's first event must meet the enrollment target — default 100× the event target, ~4 s of background work — so identity itself is minted with work. a neuron farm pays two orders of magnitude more than a visitor, while a real first visit absorbs the cost invisibly behind reading. PoW prices signals; it never authenticates humanity — that honesty stands.
replay and time
events are content-addressed: the event hash is the particle hash, so a replayed event is the same particle and appending it again is a no-op — idempotency is the dedup. timestamps bound skew, never history: events from the future (> +5 min) are rejected; stale events are accepted up to a deployment horizon (default 24 h) so offline queues survive flaky networks.
erasure
the cell is append-only, and erasure still must be real. event payloads
are encrypted at rest with a per-neuron data key held by the site;
DELETE /api/neuron destroys the key. the graph keeps its hashes, the
data becomes unreadable, already-published aggregates stay — the standard
crypto-shredding resolution of append-only against the right to erase.
erasure erases the past and forbids nothing forward: the neuron's next
event simply enrolls a fresh data key.
the attention model
the tracker is a sensor. the browser emits the real boundaries — visibilitychange, focus, blur, pagehide — and the wasm core integrates attention on-device: time accumulates while the page is visible and the neuron is active, and ships as signed attention events (on hide, on leave, on a heartbeat that bounds loss). what other tools infer from timestamp gaps, lytics receives as measurement.
agent attention arrives by declaration: an agent has no visibility or
focus to sense, so it states what it read in signed events with
actor: agent. sensed human attention and declared agent attention are
distinct streams; every report keeps them separable. the declaration is a
claim bound to the key: standing accrues to the neuron, names are labels.
binding operator to an external identity (an operator-signed delegation)
is deferred past v1.
grouping falls out of observables, never out of a clock constant:
- arrival — a pageview whose navigation is external: outside referrer, utm-tagged, or direct entry. attribution attaches to the arrival event itself; entry page is the arrival's page.
- passage — the run of a neuron's events from one arrival to the next (or to the end of the stream). exit page is the passage's last page. every question classically asked of a "session" — pages per visit, entry, exit, source — is asked of a passage, and a passage is bounded by what the neuron did, never by how long they paused.
- window — when a question needs a time span (funnels, retention, timeseries), the span is an explicit query parameter: hour, day, week, between arrivals. the analyst states the window; the engine never invents one.
arrivals, passages and windows are read-time projections over the append-only event stream — ingest stays pure append, the discipline the cybergraph demands.
one honest convention survives: heartbeat cadence (default 15 s) and the activity window (default: 5 s of input silence pauses the clock) are measurement granularity — they bound how much attention a killed tab can lose. they are tuning knobs of the instrument, never semantics of the visitor.
reuse map
| piece | source | how |
|---|---|---|
| query engine | inf native (inf-parse/plan/eval) |
live: inf_reports.rs builds a LocalSource from the event log per request and answers every report through real inf rules, each pinned to a #[cfg(test)]-only Rust reference by a differential test |
| datalog reference | inf/rs/cozo |
reference only: behavior and api shapes to check against, never a dependency |
| dashboard | static/dash.html |
plain HTML/JS, polling the report endpoints — no Leptos/Trunk build, shipped deliberately smaller |
| tracker injection | optica [analytics] config |
cyber.page pages already carry a snippet slot; point it at lytics |
| referrer→source engine | plausible core (AGPL) + snowplow referer db | clean reimplementation in Rust; behavior parity, fresh code |
| geo | maxminddb crate + GeoLite2 |
same lookup plausible uses |
| ua parsing | uaparser/woothee crate |
device class, browser, os |
| identity pipeline | mudra bridge (mudra/specs/bridge.md) |
entropy → Hemera KDF → secp256k1 → bech32, neuron = Hemera(pubkey), ADR-036 signing — the existing bridge claim carries a lytics neuron into the native graph |
| pow + hashing | hemera Poseidon2 | event hash and PoW share the stack's native hash — a lytics event hash is already a particle hash |
repo layout
lytics/
├── README.md product page
├── specs/ this document — canonical
└── rs/ cargo workspace
├── event/ shared crypto spine — canonical encoding, hash, pow, keys, adr-036
├── ingest/ axum service embedding the cell + reports + static dashboard
│ └── static/dash.html the symbolic dashboard
├── agent/ reference agent client + load generator
└── core/ wasm tracker core (phase: tracker) + loader.js
implementation plan
estimates follow the dev model: pomodoro = 30 min, session = 3 h.
phase 1 — identity + tracker (3 sessions)
wasm core: keygen, IndexedDB seed storage, per-domain derivation, BIP39 export/import, secp256k1 signing in ADR-036 shape, Poseidon2 PoW with adaptive difficulty, on-device attention integration (visibilitychange / focus / blur / pagehide + heartbeat), canonical event encoding, sendBeacon transport. loader.js: instant capture, SPA pushState hook, sensor hooks, queue-until-ready. size gate in CI: core ≤64 KB gzip, loader ≤2 KB.
phase 2 — ingest (3 sessions)
axum service embedding a cybergraph cell: POST /api/event — verify
signature, verify PoW, parse UA, geo lookup, referrer→source→channel
attribution (Rust port of plausible behavior over the snowplow referer
database, with assistant referrals as a first-class channel), cast the
event as a signed signal into the cell. difficulty oracle endpoint.
erasure endpoint (per-neuron data-key destruction). agent/: a reference
Rust client (canonical encoding + PoW + ADR-036) proving the API needs no
browser. acceptance gate: sustain 100 events/s on one core with the cell
embedded.
phase 3 — queries: the full report set (4 sessions) — done
every report is an inf rule: neurons/views/attention timeseries, top
particles, countries, devices, actors, retention matrix, return probability
within a stated window, and — sources, channels, passages, funnels. all
engine arithmetic is integer (inf has no floats; ratios render at the
dashboard edge). differential tests pin every migrated report to the Rust
implementation it replaced; that implementation is #[cfg(test)] only —
not present in the release binary at all.
sources/channels/passages/funnel group by passage (the run of a neuron's
events from one arrival to the next), which looked at first like it needed
an ordered scan carrying state between consecutive events — a window/lag
primitive inf's language does not have, confirmed directly against the
evaluator. it turned out not to: a passage boundary is just a count. the
passage id of an event is the number of that neuron's arrivals strictly
after its first-ever event and at-or-before this event's own timestamp — an
inequality self-join plus count, the same running-count technique
retention/returns already used for cohort/offset math. funnels needed no
new technique at all — "does an increasing-timestamp subsequence exist" is
what a chain of ordered existential joins already answers, provably
equivalent to the greedy single-pass scan the Rust reference used.
phase 4 — dashboard (3 sessions) — done, smaller than planned
no Leptos app: static/dash.html is plain HTML/JS polling the report
endpoints, deliberately — no build step, no wasm dashboard bundle on top
of the tracker's own. period picker, the classic widgets (neurons,
particles, sources, geo, devices) recast on attention metrics, retention
grid. comparison view and public dashboard links are still open.
phase 5 — integration + verification (1 session)
deploy beside cyber.page, flip the optica [analytics] snippet from
plausible.io to lytics, watch live events end to end, verify every widget
against raw queries. the phase ends when cyber.page runs on lytics and the
plausible subscription is off.
total to usable: ~14 sessions. retention, cohorts and funnels ship inside that number — they are the point, never an add-on.
phase 6 — the network and the proofs (blocked, by design)
events live in the cell from phase 2, so settlement needs no replay. what remains is the stack tail: cells syncing over the real transport (radio/iroh, unwired today), bbg QueryProof serde and the query wire protocol for provable reads, and zheng proving the aggregates — "this page truly had N visitors" verified by anyone. these wait on the stack's known blockers and land the moment the stack does.
confidence milestone
# a page with the snippet produces a signed, pow-carrying event
# forged signature and weak pow are rejected
# a replayed event is idempotent
# the premium tier answers — retention runs as a real inf rule, not a stub
# erasure destroys the neuron's data key
# cyber.page dashboard shows live visitors from lytics, plausible.io off
license
cyber license: don't trust. don't fear. don't beg.