git:20260925.8e3cadb to git:20260925.0b0d118

34 added, 9 removed. Audit A to A.

# Events Module — AI Agent Instructions
Signal capture. One pipe: client event → `events:track` hook → signal provider →
`/api/_signals/ingest` → capped ring buffer → external consumer pulls
`/api/_signals/export`. There are no analytics providers, no outbound webhooks, and no
logging library — anything that isn't the signal pipe was removed on purpose.
## File Map
```
layer/modules/events/
├── index.ts # Module setup (auto-imports, routes, plugins)
├── runtime/
│ ├── types/
│ │ ├── events.ts # Event types, interfaces, module options
│ │ └── signal.ts # SignalRow envelope (events AND logs)
│ ├── composables/
│ │ ├── useEvents.ts # Main event tracking API
- │ │ ├── useSignalQueue.ts # Client batch queue → /api/_signals/ingest
- │ │ └── useUserIdentity.ts # Anonymous UUID generation/persistence
+ │ │ └── useSignalQueue.ts # Client batch queue → /api/_signals/ingest
│ ├── plugins/
│ │ ├── events.client.ts # Core plugin (hook infra + `debug` console echo)
│ │ ├── errors.client.ts # window.onerror / rejections / Vue errors → signals
│ │ └── identity.client.ts # Always-on ui.click / ui.section / ui.page emitter
│ ├── providers/
│ │ └── signal.ts # The only provider: events → signal queue
│ ├── components/
│ │ └── DevEvents.vue # Dev tools modal for manual testing
│ └── utils/
│ ├── identity.ts # Content-free click/section resolvers + emit throttles
│ ├── locations.ts # Event metadata and categorization
│ └── signal.ts # EventPayload → SignalRow, page/utm/review context
└── server/
├── handlers/
│ ├── webhook.post.ts # POST /api/v1/webhook (form capture)
│ ├── signals-ingest.post.ts # POST /api/_signals/ingest (client rows)
│ └── signals-export.get.ts # GET /api/_signals/export (cursor pull)
├── middleware/
│ └── page-request.ts # Every document GET → `page_request` row (non-JS visitors)
├── plugins/
│ ├── signal-errors.ts # Nitro request errors → signal buffer (+ 404 → http.not_found)
│ └── signal-storage.ts # Mounts `signals` to fs when NUXT_SIGNAL_STORE_DIR is set
└── utils/
├── page-request.ts # Page-visit filter rules + fire-and-forget append + 404 noise filter
├── signal-buffer.ts # Capped, time-retained ring buffer (unstorage) + appendSignal
├── signal-env.ts # Pure env:'local' precedence rule (dev default vs explicit override)
├── signal-export.ts # Bearer auth check + query parsing
- └── visitor-class.ts # UA → human/agent/bot + agent sub-class
+ ├── visitor-class.ts # UA → human/agent/bot + agent sub-class
+ ├── visitor-device.ts # UA → device/browser/os family (bowser)
+ └── visitor-hash.ts # Daily-salted visitor hash + salt rotation
```
## Key Architecture
### Hook-Based Tracking
```
useEvents.trackEvent() → nuxtApp.callHook('events:track', payload)
↓
signal.ts → useSignalQueue().enqueue() → POST /api/_signals/ingest
```
The hook stays because it decouples components from transport and gives DevEvents.vue a
read point. It is not a plugin system: `signal.ts` is the only listener, plus a
`console.info` echo in `events.client.ts` when `events.debug` is on.
### Signal Flow (the important one)
Everything a site captures — analytics events AND error/warning logs — becomes one
`SignalRow` (`runtime/types/signal.ts`) in a capped server-side ring buffer. An external
consumer (Polaris) pulls it with a cursor.
```
client events → signal provider ─┐
client errors → errors.client ───┼→ POST /api/_signals/ingest ─┐
│ ├→ ring buffer (unstorage)
form submits → webhook.post.ts ─┘ │ ↓
server errors → server/plugins/signal-errors.ts ───────────────┤ GET /api/_signals/export
MCP tool calls → server/middleware/mcp-request.ts ───────────────┤ ?since=<seq>&limit=<n≤2000>
page GETs → server/middleware/page-request.ts ─────────────┘ → { rows, cursor, site }
```
`server/middleware/mcp-request.ts` and `server/utils/mcp-request.ts` live in the root layer
(`layer/server/`, alongside `layer/server/mcp/tools/` and `layer/server/utils/mcp-signal.ts`),
not inside this module — this module's own File Map above only covers `layer/modules/events/`.
- **Envelope:** `{ id, seq, ts, site, kind: 'event'|'log', name, severity?, visitor?, page?, referrer?, utm?, review?, data?, env? }`,
- where `visitor` is `{ anonId?, class?, subclass? }`.
+ where `visitor` is `{ hash?, class?, subclass?, device?, browser?, os? }`. Rows from before the ev2.3 cutover
+ carry `visitor.anonId` instead of `hash`.
- **Review binding:** `review` is its own top-level field, read from a `?polaris_review=<token>`
query param by `pageContext()`. It is **never** merged into `utm` and **never** persisted —
only rows emitted while that tagged URL is current carry it. That is the whole design: an
- external reviewer needs exactly one tagged row to bind the token to a `visitor.anonId`, and
- persisting it would stamp every later row on every later page instead.
+ external reviewer needs exactly one tagged row to bind the token to a `visitor.hash`, and
+ persisting it would stamp every later row on every later page instead. The hash rotates at
+ UTC midnight, so a binding is only good for the day it was made.
- **Visitor classification:** every ingest/append path stamps `visitor.class:
'human' | 'agent' | 'bot'` server-side from the request's `User-Agent`
(`server/utils/visitor-class.ts`) — a hand-rolled matcher, checked against
`isbot` first but skipped since it can't separate AI agents from classic
crawlers and isn't in this monorepo's lockfile. Its AI user-agent list is
`layer/shared/ai-agents.ts`, shared with the robots.txt policy in
`layer/modules/ai-robots.ts` so the two can't drift. Client-supplied `visitor.class`
is always overwritten, never trusted. Left unset only when a path has no
request context to read a UA from (e.g. the Nitro error hook on a crash with
no event). Agentic vs human traffic split is a core KPI (VISION.md) — this is
itself signal, not metadata.
- **Agent sub-class:** `visitor.subclass` splits agent traffic by *what the fetch
is for*, from the same taxonomy file's purpose grouping
(`AI_AGENT_PURPOSE_GROUPS`), so the robots.txt split and the analytics split
are the same split by construction:
| `subclass` | source list | meaning |
| --- | --- | --- |
| `search` | `AI_SEARCH_AGENTS` | answer-engine index crawlers — presence here is distribution |
| `live-user-fetch` | `AI_USER_FETCHERS` | the `*-User` family; a human is waiting on this fetch right now |
| `training` | `AI_TRAINING_CRAWLERS` | corpus crawls; no click, no citation back |
| `automation` | `AI_AUTOMATION_AGENTS` | headless browsers / test runtimes; never in robots.txt |
**`subclass` is strictly additive — `class` stays `'human' | 'agent' | 'bot'`.**
Polaris already groups exported rows on `class`, so widening that enum would
silently reclassify historical traffic; a consumer that ignores `subclass`
keeps working unchanged. Set only for `class: 'agent'`, and only when a
*published* token matched: an agent recognised solely by a loose vendor
substring (`AI_VENDOR_HINTS`) carries no subclass, because the vendor doesn't
say what the fetch was for. Published tokens are matched longest-first, so
`Applebot-Extended` beats a shorter `Applebot` and `Ai2Bot-Dolma` beats
`AI2Bot`. `describeVisitor()` returns `{ class, subclass? }`;
`classifyVisitor()` remains the class-only shorthand.
+ - **Device / browser / OS:** `appendSignal()` stamps `visitor.device` (`'mobile' | 'tablet' |
+ 'desktop'`), `visitor.browser` and `visitor.os` from the request UA on every row that has a
+ request (`server/utils/visitor-device.ts`). Family names only (`Chrome`, `iOS`), never
+ versions — less fingerprinting surface, and no feature needs them. Parsed by `bowser` (MIT,
+ bundled types, ~8 KB gzipped, server bundle only); `ua-parser-js` was rejected — v2 is AGPL,
+ v1 ships no types. Crawlers, TVs and bare HTTP clients get none of the three:
+ `visitor.class` already says what they are. Client-supplied values are dropped.
- **Local debugging against Polaris:** `env: 'local'` is stamped on every row by
`appendSignal()` (`server/utils/signal-buffer.ts`) — the single choke point every
capture path funnels through — whenever the server is running in dev mode
(`import.meta.dev`). It exists so rows produced on a developer's machine and pulled
into a local Polaris store stay **permanently** distinguishable from production
traffic, even after Polaris has stored them verbatim; there is no way to tell them
apart after the fact otherwise. Strictly additive — Polaris's contract is "absent
when it does not apply", so the key is simply missing on a production row, not
`env: 'production'` (zero migration, no schema change on the consumer side). A
locally-**built** (non-dev) server can still opt in via `NUXT_PUBLIC_SIGNAL_ENV=local`,
and setting it to `production` suppresses the stamp even under dev mode (escape
hatch). Explicit config always wins over the dev-mode default — see
`resolveSignalEnv()` (`server/utils/signal-env.ts`) for the precedence rule, unit-tested
in isolation from Nitro.
- **Cursor:** `seq` is monotonic. Consumers send back the last `cursor` they saw.
- **Buffer:** `useStorage('signals')` — memory by default; `server/plugins/signal-storage.ts`
mounts it to the unstorage `fs` driver when `NUXT_SIGNAL_STORE_DIR` is set (a Railway
Volume mount path), so a redeploy/restart doesn't lose unpulled rows. Capacity
`events.signals.capacity`, default 170 000 rows; oldest evicted. Sized for the identity
stream: ~60 rows per visit × ~200 sessions/day ≈ 12k rows/day, so the buffer covers the
full 14-day retention window (below) with headroom, and a consumer can be down for the
whole window without losing rows.
- **Retention:** rows older than 14 days (`DEFAULT_SIGNAL_RETENTION_MS`,
`server/utils/signal-buffer.ts`) are pruned on append, independent of the capacity cap —
a quiet site doesn't hold rows forever just because it's nowhere near full. Both eviction
rules (capacity + retention) advance one `oldest` cursor, persisted in `meta` alongside
`head` so it survives a restart too. Pruning only ever removes exact `row:<seq>` keys —
never a blanket `storage.clear()` — since the `signals` mount also carries other keys
(`meta`, and the visitor-hash salt from `product-validator-ev2.3`).
- **Capacity warning:** at ≥90% of capacity, `appendSignal()` appends one `quota_low`
log Site Event (`kind: 'log', severity: 'warning', data: { used, capacity, ratio }`),
edge-triggered so it fires once per crossing rather than on every append while full.
- **Auth:** export requires `Authorization: Bearer <NUXT_SIGNAL_EXPORT_TOKEN>`. Missing/wrong
token → 401. Token not configured → 503 (never open by default).
- **Client batching:** `useSignalQueue()` debounces POSTs (~800 ms, max 50 rows) and flushes
with `navigator.sendBeacon` on pagehide. The ingest handler caps a request at 100 rows / 128 KB.
- **MCP tool calls:** every `tools/call` JSON-RPC request reaching `/mcp` (or a
named `/mcp/<handler>` route) is parsed once by Nitro middleware
(`layer/server/middleware/mcp-request.ts` + pure JSON-RPC extraction in
`layer/server/utils/mcp-request.ts`), which appends via `captureMcpToolCall()`
(`layer/server/utils/mcp-signal.ts`) →
`{ kind: 'event', name: 'mcp_tool_called', visitor: { class: 'agent' },
data: { tool, args, userAgent } }`. `visitor.class` is stamped `agent` from the
transport, not the UA — MCP is agent-only, and MCP clients send inconsistent
User-Agents that `classifyVisitor()` would read as `bot`. Fire-and-forget: a
failed append never surfaces to the agent. The middleware reads the raw POST
body *before* `defineMcpTool`'s `cache` option (`@nuxtjs/mcp-toolkit`, which
wraps a tool's `handler` in Nitro's `defineCachedFunction` outside the tool
code) can serve a cached response, so a repeat call inside a tool's cache
window is counted exactly like a cache miss — this used to be the gap here.
Tool handlers under `layer/server/mcp/tools/` no longer call
`captureMcpToolCall()` themselves, so a cache *miss* isn't double-counted
either: the middleware is the only call site.
### Server-Side Page Visits (`page_request`)
`server/middleware/page-request.ts` (rules + append in `server/utils/page-request.ts`)
appends one row per document GET **before any Vue code runs**, so visitors that never
execute JavaScript still leave signal. GPTBot, ClaudeBot and PerplexityBot do a plain GET,
parse the HTML and leave — without this they were invisible, and the every-visit-is-signal
claim was false for exactly the traffic the agentic-vs-human KPI is about.
```
{ kind: 'event', name: 'page_request', page, referrer?,
visitor: { class, subclass? }, data: { userAgent, format: 'html' | 'markdown' } }
```
`data.format` (product-validator-m0f.15) distinguishes the served representation —
`'markdown'` for a `.md`-suffixed content route, an `Accept: text/markdown` negotiated
fetch, or `/llms.txt` / `/llms-full.txt`; `'html'` for everything else. Additive field on
the existing free-form `data` bag, no envelope/schema change: it exists so a consumer can
compute the agent-native-document share of `page_request` without inferring format back out
of `page` (a bare `.md` suffix is visible there, but negotiated and `llms*.txt` requests
aren't). It never affects `isPageRequest()`'s admit/reject decision — computed separately by
`pageRequestFormat()` only after a candidate has already passed the filter.
**Double-count policy — `page_request` and `ui.page` are different facts. Do not sum them.**
| | `page_request` (server) | `ui.page` (client) |
| --- | --- | --- |
| fires on | every document GET reaching the server | every route the client rendered |
| SPA route change | no (no request is made) | yes |
| no-JS visitor | yes | never |
| use it for | documents served; the agent/bot traffic split | pages a real browser rendered |
A JS-capable human doing a hard navigation legitimately produces one of each. That is two
facts, not one fact double-counted, and the distinct `name` is what lets a consumer choose:
`ui.page` for browser page-views, `page_request` filtered on `visitor.class` /
`visitor.subclass` for agent traffic. The alternative — emitting `page_request` only for
non-humans — was rejected: it would make a no-JS *human* invisible, which is the same bug
one class over.
**Filtering** (all in `isPageRequest()`, pure and unit-tested). A request is a page visit
only if it is a `GET`, is not the prerenderer crawling us during `nuxi generate`
(`import.meta.prerender` / `x-nitro-prerender`), is not under `/api/`, `/_nuxt/`, `/_ipx/`,
`/_scripts/`, `/_signals/`, `/_health`, `/_vercel/`, `/.well-known/`, `/@vite/`, `/@id/`,
`/@fs/`, `/node_modules/`, `/__nuxt*`, has no non-`.html`/`.md` file extension (favicon.ico,
robots.txt, sitemap.xml, *.js, *.css, *.png all drop out — except `/llms.txt` and
`/llms-full.txt`, admitted by an exact-path allowlist since `.txt` otherwise stays an asset
extension), and looks like a document fetch: `Sec-Fetch-Dest` decides when present
(`document`/`iframe` only, so browser `$fetch` and prefetches drop), otherwise an `Accept`
header that names a type must name an HTML- or markdown-ish one (`text/html`,
`application/xhtml`, `text/markdown`) — a wildcard or missing `Accept` passes, because that
is what a bare crawler GET looks like. `.md` suffixes, `Accept: text/markdown` negotiation
and the two `llms*.txt` documents are agent-native surfaces `markdown-rewrite` and
`nuxt-llms` serve (product-validator-m0f.15) — real document GETs, not assets, even though
none of them are HTML.
Fire-and-forget like `captureMcpToolCall()`: the middleware returns nothing, awaits nothing,
and swallows every failure. A dropped row costs a data point; a thrown one costs the page.
### 404s (`http.not_found`)
`server/plugins/signal-errors.ts` special-cases a thrown `statusCode: 404` (the catch-all
page, `layer/app/pages/[...slug].vue`, is the only surface that throws one): instead of the
usual `server_error` log row, it appends `{ kind: 'event', name: 'http.not_found', page,
referrer?, visitor }` — product-validator-ev2.6. A 404 isn't a crash, so it gets its own
Site Event rather than inflating the error-log stream; the point is catching a campaign link
that silently lands on a missing page, which `server_error` alerting isn't shaped for.
`isNotableNotFound()` (`server/utils/page-request.ts`) decides whether a 404 is worth the
row: it reuses `isPageRequest()`'s asset/internal-route filtering (`/_nuxt/`, `favicon.ico`,
`/api/*`, …), then additionally drops `visitor.class: 'bot'` traffic and a short list of
vulnerability-scanner probe paths (`/wp-admin`, `/wp-login.php`, `/.env`, `/.git`,
`/phpmyadmin`, `/administrator`, `/cgi-bin`, …) that 404 on every public server by
construction and say nothing about a broken link. Matching is prefix-on-a-path-boundary
(`/wp-admin` or `/wp-admin/…`, never a bare substring), so a legitimate page like
`/wp-admin-consulting` isn't mistaken for a probe.
### Identity Events (always on, content-free)
`runtime/plugins/identity.client.ts` emits a behaviour stream for **every** visitor through the
normal `events:track` pipeline, so each row already carries `page`, `ts`, `visitor` and `review`:
| name | `data` |
| --- | --- |
| `ui.click` | `{ target, label?, section? }` |
| `ui.section` | `{ section, visible }` |
| `ui.page` | `{ from? }` — route change, and the first load |
| `ui.outbound` | `{ host, kind: 'outbound' \| 'download', ext? }` |
**Content-free is a hard rule:** no pixel coordinates, no input values, no keystrokes. `target`
is built from authored attributes only (`data-signal-target` → `tag#id` → `tag[data-testid]` →
`tag`), and `label` (aria-label, else collapsed text, 80 chars max) is never read off a form
field. Add `data-signal-ignore` to an element or ancestor to exclude a subtree.
**Section identity convention.** A section is any element matching `[data-section]` or
`section[id]`; its stable id is `data-section` when present, else the element's `id`.
`SectionWrapper` stamps `data-section` from its `sectionId` prop, so layer-built pages are
covered for free; a hand-rolled section on a consuming site opts in with `data-section="pricing"`
or a plain `<section id="pricing">`.
**Volume control.** Clicks dedupe on `target|section` within 400 ms. Sections report settled
*transitions* only — a state must hold for 400 ms before it emits — so scrolling straight past
ten sections emits nothing rather than twenty rows, and an on-screen section never re-emits.
**No client-side bot filtering, on purpose:** `visitor.class` is stamped server-side from the
request UA at ingest and consumers filter on it. A second, weaker client verdict would only be
overwritten. Emission is also fully guarded — a dropped row costs a data point, a thrown one
costs the visit.
**`ui.outbound`** (product-validator-ev2.5) fires on a click that either targets a different
host than the current page (`kind: 'outbound'`) or names a document/archive/installer
extension — `pdf`, `zip`, `csv`, `xlsx`, `pptx`, `docx`, `dmg`, and siblings
(`DOWNLOAD_EXTENSIONS`, `runtime/utils/identity.ts`) — or carries the `download` attribute
(`kind: 'download'`). `data.host` is the target's hostname only — never the full URL, path
or query string. `mailto:`/`tel:`/non-`http(s)` links have no meaningful host and are
skipped. It goes through the same `emit()` → `trackEvent` → hook → queue pipe as
`ui.click`/`ui.section`/`ui.page`, so it's fire-and-forget by construction and never blocks
the navigation or download the click triggers; the existing `sendBeacon` flush on `pagehide`
(`useSignalQueue.ts`) is what gets a same-tab outbound navigation's row out before the page
unloads, and a `target="_blank"` link or a download that never navigates just rides the
normal debounced flush since the page stays open.
### Form Submission Flow
`useFormCapture()` validates client-side, then POSTs straight to `/api/v1/webhook` →
inline honeypot/timing check → zod payload validation →
`appendSignal({ kind: 'event', name: 'form_submitted' })`. The server is the durable
capture point (it also stamps `visitor.class`); a `$fetch` failure surfaces a toast and a
`form_error` event. There is no outbound forward to a third-party webhook — Polaris pulls
the buffer instead.
Route name is historical: `/api/v1/webhook` is the form-capture endpoint, not a webhook
sender. It's kept stable so existing deployments don't break.
### Logging
There is no logging library. Server handlers `throw createError({ statusCode,
statusMessage })` (h3 native) and the Nitro error hook (`server/plugins/signal-errors.ts`)
turns thrown request errors into `kind: 'log'` rows. **Do not** `appendSignal` an error in
a handler *and* throw it — that double-records. Pure info logging is gone.
### Anti-Spam Layers
Honeypot filled in, or `timeOnForm` under 2s → treated as a bot and silently accepted
(no error surfaced, so bots don't learn they were caught). Checked inline in
`webhook.post.ts` — no dedicated util or rate limiter.
- ### Anonymous Identity
- `useUserIdentity().getUserId()` returns `user_<uuid>`. Persists to localStorage via `useAppStorage()`. SSR-safe (empty string on server). If storage throws outright — private mode, blocked site data — it falls back to a per-page-load id rather than throwing on every visitor.
+ ### Anonymous Identity (cookieless)
+ `visitor.hash` = `sha256(daily_salt + site + ip + user_agent)`, truncated to 16 hex chars,
+ stamped by `appendSignal()` on every row that has a request (`server/utils/visitor-hash.ts`,
+ decision product-validator-ev2.1). Nothing is stored on the device, so no consent is needed
+ under ePrivacy Art. 5(3). The client sends no identity at all; a client-supplied hash is
+ dropped, and the ingest schema strips a stale client's `anonId`.
+ - **IP:** `getRequestIP(event, { xForwardedFor: true })` — Railway's proxy terminates the
+ connection. The raw IP is only a digest input; it is never stored.
+ - **Salt:** random, one key (`visitor-salt`) in the `signals` storage, replaced on the first
+ append of each UTC day. Overwriting the key is the deletion of the old salt, so past
+ hashes can't be recomputed. On the memory driver a restart mints a new salt, splitting a
+ day's visitors at the restart; a persistent `signals` mount avoids that.
+ - **Trade:** one value per visitor per day. Same-day features (uniques, sessions, bounce,
+ funnels, review binding) work; cross-day returning-visitor recognition is gone on purpose.
+ - **Legacy:** older builds stored `foundry_userId` in localStorage; `events.client.ts` removes
+ it on boot. `useUserIdentity()` no longer exists.
+
## How to Modify
### Add new event type
1. Add to `TrackedEvents` union in `runtime/types/events.ts`
2. Add metadata in `runtime/utils/locations.ts`
3. Use in component: `trackEvent({ id: 'x', type: 'new_type', location: 'hero' })`
### Emit a signal from server code
```ts
import { appendSignal } from '../utils/signal-buffer'
await appendSignal({ kind: 'log', severity: 'warning', name: 'quota_low', data: { … } }, event)
```
`site`, `id`, `ts`, and `seq` are filled in for you.
## Module Options (`events:` in nuxt.config)
```ts
events: {
signals: { enabled: true, capacity: 170_000, captureErrors: true },
debug: true, // console.info every tracked event (client)
}
```
That's the whole surface. There is no `providers` and no `webhook` option.
## Environment Variables
- `NUXT_SIGNAL_EXPORT_TOKEN` — Bearer token for `GET /api/_signals/export` (unset → 503)
- `NUXT_PUBLIC_SITE_ID` — Site identifier stamped on every row (defaults to request host)
- `NUXT_PUBLIC_SIGNAL_ENV` — Overrides the `env:'local'` debug marker's dev-mode default;
`local` stamps it even on a non-dev server, `production` suppresses it even under
`import.meta.dev`. See "Local debugging against Polaris" above.
- `NUXT_SIGNAL_STORE_DIR` — Absolute directory to persist the `signals` ring buffer to
(`server/plugins/signal-storage.ts`), e.g. a Railway Volume's mount path. Unset → the
in-memory driver (default; fine for dev/tests, loses unpulled rows on restart). Read from
`process.env` at Nitro startup, not baked into `nitro.storage` at build time, since a host
like Railway injects its runtime env into the running container independently of the build.
Nothing else. If you're adding a fifth env var to this module, you're probably adding a
feature that doesn't capture signal.
## Design Principles
1. No database — the buffer is a transient ring consumers pull from, not storage
- 2. No auth — anonymous UUID sufficient for signal capture
+ 2. No auth — a server-side daily visitor hash is sufficient for signal capture
3. No analytics vendors — one pipe out, the consumer decides what to do with it
4. No second destination — adding a fan-out target means two sources of truth