# Visitor graph (Task 357)

Behavioural analytics that connect anonymous page visits to known `:Person` contacts. Replaces the anonymous click-through metric from Task 336 with a fully attributed graph.

## What this gives the operator

- A morning briefing surface: "who has been on the site overnight, and what did they look at?"
- An engagement-ranked nurture queue, ordered by recency × depth × dwell.
- A graph-backed click-through-rate report for property recommendations.
- A full event timeline for any one session, for prep and diagnosis.
- A signed-cookie that recognises a named visitor on later visits without re-clicking the marketing link.

## Data model

| Node | Meaning |
|------|---------|
| `:Session` | One browser tab session. Composite key `(accountId, sessionId)`. |
| `:AnonVisitor` | Pre-identification browser identity. Merges into `:Person` on first signed-token click. |
| `:PageView` | One page load. Carries `referrer`, `path`, optional `dwellMs`. |
| `:Click` | One DOM click on a tagged element (`data-track="<label>"`). |
| `:ScrollMilestone` | Roll-up of scroll depth — one node per `:PageView`, `maxDepth` ∈ {25,50,75,100}. |
| `:Page` | URL metadata. Content-only, survives erasure. |
| `:Recommendation` | Materialised `[property-recommended]` log line for CTR computation. |

| Edge | Direction |
|------|-----------|
| `VISITED` | `Person → Session` or `AnonVisitor → Session` |
| `OWNS_VISITOR` | `Person → AnonVisitor` (cross-session merge) |
| `HAS_EVENT` | `Session → PageView / Click / ScrollMilestone` |
| `OF_PAGE` | `PageView → Page` |
| `OF_LISTING` | `PageView → Listing` |
| `FOR_SESSION` | `Recommendation → Session` |

## How identity gets resolved

The signed-token cookie `mxy_v` carries a `:Person` elementId, signed HMAC-SHA256 with a brand-local 32-byte secret (file at `~/.<brand>/credentials/visitor-token-secret`, minted on first read). When the recommender's `/listings/<slug>/click?session=<sk>&v=<token>` URL is visited:

1. The click handler verifies the HMAC on `<token>`.
2. If valid, the same token value is written into `mxy_v` (Max-Age 30 days, SameSite=Lax, HttpOnly, Secure).
3. On subsequent visits, `POST /v/event` reads the cookie, verifies it, and attributes every event to the bound `:Person`.

When a previously-anonymous browser binds for the first time, any `:Session` already attributed to the `:AnonVisitor` is re-attached to the `:Person`, and the merge fires `[anonvisitor-merge]` with the count of reattributed sessions.

## Tools

All under the `real-agent-buyers` plugin, admin-side only:

| Tool | Purpose |
|------|---------|
| `visitor-recent-by-person` | Recent sessions for a known `:Person` (morning round, 1:1 prep). |
| `visitor-recent-by-page` | Recent visitors of a given listing slug or URL. |
| `visitor-engagement-score` | Engagement-ranked `:Person` list (nurture queue). |
| `visitor-recommendation-ctr` | Graph-backed CTR over a window, joined from `:Recommendation` and `:Click` nodes. |
| `visitor-session-detail` | Full event timeline for one `:Session`. |
| `visitor-event-ingest` | Admin companion to `POST /v/event` for test harness work. |
| `visitor-backfill-from-logs` | One-shot importer: parses `[property-recommended]` and `[property-card-click]` log lines into the graph; used to recover late-arriving sessions and for ad-hoc forensics. |
| `mint-visitor-token` | Mints a signed token bound to a `:Person` for outbound URLs in `morning-round`, `lead-nurturing`, `vendor-updates`. Returns `{ token, expiryMs }`; the agent appends `&v=<token>` to `/listings/<slug>/click?session=<sk>`. Same secret file as the UI server; both processes share it via the wx-create pattern. (Task 362) |

## Privacy

The full description is at `/privacy` on every brand domain. Highlights:

- First-party cookie only, no third-party scripts.
- Retention is erasure-on-request, not time-based; visit data persists until a `:Person` is erased.
- Right-to-erasure cascades through `contact-erase`: `:Session`, every `:HAS_EVENT` child, and any owned `:AnonVisitor` are removed when the `:Person` is erased. `:Page` and `:Listing` are content metadata and intentionally preserved.

## Verification

Quick checks the operator can run after deployment:

1. Load a published listing page; grep `[visitor-event] type=pageview` in `server.log` within 1s.
2. Scroll past 50%; grep `[visitor-event] type=scroll depth=50`.
3. Click an element marked `data-track="floorplan"`; grep `[visitor-event] type=click label=floorplan`.
4. Run `visitor-backfill-from-logs` over a log window where live writes were lost (process restart, etc.); the response reports `recWritten` and `clickWritten` counts. Subsequent runs over the same window are idempotent for `:Recommendation` and append-only for `:Click`.

## Failure signals

| Symptom | What it means | Where to look |
|---------|---------------|---------------|
| `[visitor-event]` count drops to zero with no `[v-event-error]` | Pixel silently failing on the brand domain (probably CSP, CORS, or origin mismatch). | Check brand.json `publishedSiteOrigins`; check browser console on a published listing page. |
| `[token-bind] reject reason=bad-sig` spikes | HMAC verify failing — either the secret rotated and old cookies are being rejected (expected during rotation) or the recommender is minting against a stale secret. | Compare `~/.<brand>/credentials/visitor-token-secret` across processes. |
| `[anonvisitor-merge]` never fires after first signed-token click | The pixel isn't reading the cookie. | Inspect the `mxy_v` cookie in DevTools; check CORS `Access-Control-Allow-Credentials: true`. |
| `[v-event-error] reason=rate-limit` for legitimate operator traffic | Operator IP shares a NAT with high-volume crawlers. | Adjust `RATE_LIMIT` in `visitor-event.ts` or whitelist the IP at the proxy. |
