# Self-referrals, cross-subdomain journeys, and what counts as a visit

**Status:** The self-referral host list is BUILT (1.8.0). **Visit bounding is BUILT
(1.8.0)**. **Rank 1 of "THE OPEN PIECE" is BUILT (1.8.0)** — the original source now
survives a hop through an owned property, by link passthrough plus a conservatively
scoped visitor cookie; see "Built 2026-08-07 — carrying the source across" at the end.
**Rank 2 remains open:** return-trip attribution for owned properties on genuinely
different domains. Two problems that look separate and are the same subsystem — both
are "when does a visit start, and whose visit is it?"

## The problem, from a real site

A site sends traffic to a landing-page platform on a subdomain, and visitors come
back to the main site to enquire. The main site sees the subdomain as a *referring
host* and credits it as an acquisition channel. On the site this was found on, that
is **19% of all leads** attributed to the site's own property, with whatever
actually found those people getting none of the credit.

Two things are wrong at once:

1. **The referral is not an acquisition.** Nobody was acquired from your own
   landing page; they were already yours.
2. **A new visit is started.** Returning from the subdomain counts as a fresh
   visit with a fresh landing page, inflating visit counts and breaking the
   journey into pieces that were one journey.

The second is the same fault as the separate visit-inflation issue: a visit is
currently bounded by *"a navigation arrived carrying tracking parameters or an
external referrer"* rather than by *"enough time passed that this is a new
session"*.

## The proposed setting

A list of hostnames that are **ours, not sources**. Wildcards allowed, because
these are usually a family of subdomains:

```
get.example.com
*.example.com
pages.example-marketing.com
```

Behaviour when a visit arrives referred by a listed host:

- It is **not** a new acquisition. Source, medium and campaign are left exactly as
  they were — the visitor keeps whatever originally found them.
- The arrival becomes **a page view in the existing journey**, not a new visit.
- If no prior attribution exists (the visitor's first ever contact came through
  that subdomain), the visit records as **Direct** with the subdomain page as the
  landing page — an honest "we do not know", rather than a false attribution to
  ourselves.

This also covers the case of a visitor bouncing *out* to another owned property
and back: the excursion is part of one journey rather than three.

## Recovering the original source across the boundary — THE OPEN PIECE

> **Rank 1 below was built on 2026-08-07** — but not the way this section proposed.
> The answer turned out to be link decoration rather than a shared cookie, and the
> cookie was widened far less than "the registrable domain" because doing that on a
> site living under a shared parent leaks visitor keys sideways. Read this section for
> the problem statement; read "Built 2026-08-07 — carrying the source across" at the
> end for what actually shipped and why it differs. **Rank 2 is still open.**

**This is the part that still matters and is not built.** The host list stops the
lie — it prevents a site's own subdomain taking credit — but its fallback is to
record the visitor as **Direct**, and Direct is just a tidier way of saying "we
lost it". Someone reached that landing page *from* somewhere. That somewhere is
knowable, and until it is recovered, every lead that arrives via an owned
property is attribution we threw away rather than attribution that never existed.

The goal: **avoid falling back to Direct whenever the original source can be
carried across instead.**

### The subdomain case has a much better answer than the general one

Worth separating, because they are not equally hard:

**Same registrable domain** (`get.example.com` → `example.com`). A cookie scoped
to `.example.com` is readable and writable from every subdomain, with no tokens,
no link decoration and no round trip. The plugin already sets a first-party
visitor cookie (`bwlai_vk`, HttpOnly, 400-day cap) — it is currently scoped by
WordPress's `COOKIE_DOMAIN`, which normally means the exact host. Widening that to
the registrable domain would make the visitor key span every owned subdomain
automatically.

That alone does not carry *source*, only identity — but identity is enough,
because the journey is stored server-side against that key. A visitor who arrives
on the landing subdomain, then reaches the main site, resolves to the same stored
journey and keeps the source it already has.

The requirement is that the capture script runs on the subdomain property. Most
landing-page platforms (Unbounce, Instapage, HubSpot pages) allow a custom script,
which is exactly what the cross-domain handoff wizard already generates. What is
needed is a variant of that snippet whose job is "you are one of ours — share the
cookie" rather than "you are a destination — read this token".

**Genuinely different domains** (`example-offers.com` → `example.com`). Cookies
cannot be shared, so this needs the existing token mechanism: the handoff wizard
already decorates outbound links to configured destinations and the destination
snippet already claims the payload. The missing half is the **return trip** —
carrying attribution *back*, which today only travels outward.

### Ranked

1. ~~**Widen the visitor cookie to the registrable domain**, and offer a
   "shared property" snippet for owned subdomains.~~ — **done in 1.8.0**, with both
   halves changed by the build. See the end of this document.
2. **Return-trip link decoration** for owned properties on other domains. Reuses
   the handoff token; needs the snippet to write attribution onto links pointing
   home. **Still open** — and now narrower than it was, because the shipped
   passthrough already carries the SOURCE from any domain. What is left is the
   JOURNEY: a visitor who converts on a different-domain property, where the
   handoff token still only travels outward.
3. **Shared analytics ID as a last resort.** If both properties run the same GA4
   property the client ID is common to both. Weakest — it depends on a third-party
   script still being installed, on cookie scope, and on the tool not changing.
   A guess dressed as data; worth having only when 1 and 2 are impossible.

### Until then

The host list records these visitors as Direct, which is honest but lossy. The
Reports data-quality block should say so where it can, so a site owner reading a
Direct figure knows some of it is really "arrived via our own landing page and we
could not see further back".

## What must happen alongside: bounding a visit properly

Measured on a real dataset of 434 journeys, after a separate parser bug was fixed:

- Multi-visit journeys store an average of **2.73 visits** but contain **1.80
  real sessions** at a 30-minute boundary.
- **39% of page views repeat a URL already seen** in the same journey.

So a visit should be bounded by a session timeout (30 minutes of inactivity is the
conventional choice), and a repeated view of a URL already in the current visit
should not increment the page count. Neither of these is a subdomain problem — the
subdomain case is one *cause* of a visit starting when it should not.

**This matters to reporting, not just tidiness.** "Pages read" and "came back" are
two of the three quality columns on the Reports dashboard. They are inflated by a
known amount today.

## Suggested order — updated after the 1.8.0 build

1. ~~The self-referral host list~~ — **done in 1.8.0.**
2. ~~Bound visits by session timeout, and stop counting a repeated view of a URL
   already seen in the current visit.~~ — **done in 1.8.0.**
3. ~~Stop owned properties resolving to Direct.~~ — **done in 1.8.0**, as link
   passthrough plus a conservatively scoped cookie.
4. **Return-trip attribution for owned properties on other domains.** The only
   piece of this document still open.

## One caution

Changing what counts as a visit changes every historical comparison. Journeys
already stored keep their original counts, so a site will see engagement figures
step down on the day this ships. That needs saying in the release notes, and
probably in Data Quality for a period afterwards, or it reads as a drop in
performance rather than a correction in measurement.


---

## Built 2026-08-07 — the host list

Shipped as **Your Own Domains** on the Settings tab, working exactly as designed
above: a listed host returns no classification at all, which is the same path the
capture script already took for the site's own hostname, so the visitor keeps
their earlier attribution and a first-contact visitor records as direct.

Two decisions made during the build:

- **The site's own hostname is included automatically**, and matching is
  suffix-aware. That means the motivating case needed *no configuration*: the site
  where a fifth of leads were credited to `get.<own domain>` is fixed by the
  default. The field is there for a platform on a genuinely different domain.
- **The excursion is recorded as a page view** in the journey, stamped a second
  earlier so it sorts before the page they returned to. It is the only honest
  record available — nothing of ours runs on the other property.

**Still open, and unchanged by this:** history already stored keeps its
self-referral channel labels. Rewriting them to "Direct" would make history
consistent with future capture, but it would merge a clearly-labelled group into
an existing bucket and lose the distinction, so it was left alone deliberately.
The Data Quality block already explains the group. Worth a decision rather than a
default.

---

## Built 2026-08-07 — visit bounding

Shipped in 1.8.0, in three parts: the capture rule, the history correction, and one
thing the measurement turned up on the way.

### The capture rule (`assets/js/capture.js`)

A visit now starts on **30 minutes of inactivity**, tracked in a new localStorage key
`bw_lai_activity` written on every pageview. The old `bw_lai_session` marker in
sessionStorage is legacy and nothing reads it; `clearAll()` still removes it.

The measured problem was over-counting, but the rewrite fixed an **under-count nobody
had noticed**: the old marker never expired, so a visitor who left a tab open and came
back the next day was still inside their first visit. Both directions are wrong for
the same reason — sessionStorage was answering a question about *time* with a fact
about a *window*.

The `isNewCampaign` exception is untouched: a tagged arrival carrying a different
source or campaign still starts a visit mid-session.

The page counter counts **distinct URLs since the current visit started**. The view is
still stored — the timeline is a record of what happened; only the counter dedupes.

Two limits, both accepted deliberately and both failing in the safe direction:
- **Only pageviews extend a visit.** Someone watching a 40-minute video without
  navigating starts a new visit on their next click. Interaction events live in a
  separate script that may not be loaded at all, so the view is the one unit of
  activity `capture.js` can always see.
- **The view list is capped at first 25 + last 25**, so inside a visit longer than
  that an early URL can fall out and be counted twice.

### The history correction (`BW_Lead_AI_Reprocess`)

Consecutive stored visits under 30 minutes apart merge, keeping the FIRST arrival's
attribution. `visits` and `pages` are recomputed in both places a journey keeps them
(the flat payload keys and the blob's `counts`), with the captured values preserved
once as `visits_original` / `pages_original`.

**The one non-obvious decision.** The brief said to merge unless the later visit is
"tagged with a different campaign/source", but the stored blob does not carry the
capture script's `tagged` flag — so it has to be recovered, and reading it as "the
source differs" is wrong in the worst possible direction. Measured first: of 85
consecutive pairs under half an hour, 32 differed in source or campaign, and **27 of
those 32 were the new-tab case with no source at all** — precisely what needed
merging. So a visit counts as tagged when it carries a campaign, term or content
value, or a medium no referrer rule could have produced (`cpc`, an ad platform's
truncated `g`). What a rule CAN produce (`organic`, `social`, `ai`, `email`,
`referral`) was inferred from a referring host, which is not tagging.

Run on the real 435-journey dataset: 117 changed, second and third passes changed 0.

| | before | after |
|---|---:|---:|
| journeys stored as multi-visit | 94 | 47 |
| their mean stored visits | 3.99 | **1.81** |
| mean pages per journey | 3.35 | 2.54 |
| "came back" — Direct | 30% | 16% |
| "came back" — Google Organic | 19% | 12% |
| "came back" — Google Ads | 14% | 6% |

1.81 against the 1.80 this document predicted. The form showing 70.5 median visits
beside forms at 1.3 — finding D3, open since round 6 — now reads 5.0.

### What the measurement turned up: staff are not leads

The most engaged "lead" in the table was the site's own staff account: 111 visits,
with a wp-admin page in its journey. `capture.js` is no longer enqueued for anyone who
can `edit_posts`. `edit_posts` rather than "logged in", so subscribers and customers —
frequently the actual leads — are still tracked. Capture-side only: nothing already
stored is rewritten, which is why it is not a Reprocess `CHANGES` entry.

### Still open

**Item 3 — stopping owned properties resolving to Direct — was untouched by this**,
and was built later the same day. See the next section.

---

## Built 2026-08-07 — carrying the source across

Rank 1 shipped, and **neither half is what this document proposed**. Both changes are
worth reading as corrections to the plan rather than as implementations of it.

### The cookie was NOT widened to the registrable domain

The plan said: scope the visitor cookie to eTLD+1 and every owned subdomain shares
identity automatically. That is right for a site that owns its whole apex and a **data
leak** for one that does not. A site living at `<customer>.<platform>` — a staging host,
an agency preview domain, any multi-tenant platform, which is how most sites start life
— would scope its cookie to `<platform>` and hand its visitor keys to every unrelated
site sitting beside it. Two businesses' visitors merge into one continuity record,
silently, with no signal but journeys that look mysteriously well-travelled.

What shipped: **the site's own host with a leading `www.` stripped, dot-prefixed**. A
site at `www.example.com` covers `get.example.com`; a site at `client.demoing.info`
yields `.client.demoing.info` and **never** `.demoing.info`. A site that genuinely owns
its apex widens it deliberately through the new `bw_lead_ai_cookie_domain` filter.
Verified on the live deployment, not just in theory.

### The source is carried on LINKS, not in the cookie

The plan treated the shared cookie as the mechanism and the snippet as its delivery.
It is the other way round, and the reason is that **the cookie cannot do this job even
when its scope allows it**: it is HttpOnly, so no script on the property can read it,
and the REST layer never permits credentials on a cross-origin request. Both are
deliberate; routing around them would be undoing a security decision to reach a
convenience.

Link decoration has none of those constraints, and it turned out to be the *more
general* answer rather than the fallback the plan filed it as — it works across
genuinely different domains, on any platform that allows a script tag, with no cookies,
no CORS and no dependency on continuity being switched on at all. So the ranking was
upside down: **the mechanism listed as "for other domains" is the one that solves the
subdomain case too**, and the cookie widening is a smaller, separate improvement to
identity rather than the point.

What ships: a **"A property you own"** panel on the Cross-domain tab generates the
snippet. On the property it captures the original source once — the page's own tracking
parameters if it has them, otherwise the external referring host — keeps it in
sessionStorage for the session, and appends it to every link pointing at the main site.
`capture.js` reads it below the referrer in the cascade, classifies it through the
existing `referrerClass()` so one place decides what `www.google.com` means, and strips
it back out of the URL once the visit is stored.

### The hazard the naive version has

`bwlai_ref=www.google.com` is **a claim about a referrer, made by a link, and links are
written by anyone**. Ungated, a stranger could put that parameter in a marketing email
or on their own site and have every click stored as organic search: paid traffic
laundered into organic, a competitor's referral erased, and nothing in the data
afterwards to show it. Nobody would notice, because the numbers would stay plausible.

So **arriving from a property you own is the credential** — only your own snippet writes
the parameter there, and a browser-set referrer cannot be forged by a link. It is
honoured only when the referring host is one of yours, or when there is no referrer at
all. The check is deliberately redundant with the cascade above it, so that editing a
classification rule can never silently open it.

**One limit, accepted knowingly.** An empty referrer is not evidence of anything, so a
link from a native mail client can carry a false source. Refusing that case would throw
away every arrival that lost its referrer to a redirect chain, an ad platform's click
tracker or a referrer policy — the common case this exists to rescue — against a rare
case where somebody chooses to falsify a number in a stranger's private analytics for
no gain. It is written into the code rather than left to be discovered.

### Not a Reprocess entry

Every other attribution improvement in 1.8.0 added a `BW_Lead_AI_Reprocess::CHANGES`
entry, and this one deliberately does not. Reprocess re-derives stored attribution from
what was captured; **this changes what gets captured**. A journey already stored as
Direct contains no carried source to re-read, and none can be invented from a record
that never held it. It reaches new visits only, and saying so in the CHANGELOG is the
honest form of that.

### What is proven, and what needs two minutes of rian's time

Proven without a browser: 18 acceptance checks on the capture side (seven of which fail
against the pre-change cascade, so they measure something), 26 checks running the real
generated snippet in a fake DOM, the cookie derivation across six hostnames plus the
two-tenant leak case, and the tab rendering clean under `E_ALL`.

**Not proven: a real second property serving the snippet.** Nothing on this server is a
second subdomain running it, so the hop itself was simulated. To check it live:

1. Put the generated snippet on the owned property.
2. Open it as `https://<property>/?bwlaidebug=true&utm_source=test&utm_medium=check`.
3. Hover a link back to the main site — the address should now carry those two
   parameters. (With no parameters and a real search-engine referrer instead, it
   carries `bwlai_ref=<that host>`.)
4. Click it, then look at the newest journey: the visit should read as `test / check`
   rather than Direct, and the parameters should be gone from the address bar.

### Still open

**Rank 2: return-trip attribution for owned properties on genuinely different
domains** — and it is narrower now than when it was written. The passthrough above
already carries the SOURCE home from any domain. What is still missing is the JOURNEY:
the handoff token travels outward to a destination and nothing carries a journey back,
so a visitor who converts ON the other property is still matched by whatever that
property stores, not by their history here.
