{
  "slug": "bw-lead-ai",
  "name": "BW Lead Attribution Intelligence",
  "version": "1.8.0",
  "download_url": "https://plugins.bowden.works/wp-content/uploads/plugin-updates/bw-lead-ai-1.8.0.zip",
  "download_hash": "sha256:13f38b4917d3efa59b4c79b1d354e714e273d60d29625c52eef04499c33c7357",
  "download_size": 495064,
  "requires": "6.0",
  "tested": "",
  "requires_php": "7.4",
  "last_updated": "2026-08-11",
  "homepage": "https://plugins.bowden.works/bw-lead-ai/",
  "author": "Bowden Works",
  "description": "Capture traffic source, attribute it to every lead, and understand where your leads are coming from.",
  "changelog": "## [1.8.0] - 2026-08-10\n\n### Changed\n- **The Reports tab is now just the chart and the table.** The date/credit/\n  dimension switchers, then \"Leads per week\" with its chart, then the breakdown\n  table under a heading that is simply the dimension name (\"Channel groups\",\n  \"Channels\", \"Sources\", \"Mediums\"). Everything else is gone from this tab: the\n  intro line, the four KPI cards, the per-block descriptions, the cross-bucket\n  sentence, the \"which form is pulling its weight\" block, and the data-quality\n  pointer (findings live on the Data Quality tab). 400 lines of the render code\n  those pieces used were removed, not just hidden.\n\n- **Every screen's copy rewritten to read in one pass.** The bar: a distracted\n  reader takes it in without re-reading. Descriptions are deleted where the\n  heading and its control already say it, cut to one short line where a\n  first-timer needs orienting, and moved behind the \"?\" where they are detail for\n  the curious. The Data Quality tab is the clearest case — each finding was a\n  heading plus a description plus a \"what this looks for\" paragraph, three copies\n  of one idea; now it is a heading, a count, and one short line behind the \"?\".\n  The handoff wizard, the mapping-section intros, and the Cross-domain copy lost\n  the most words. Data-honesty caveats and the warnings on reprocess, settings\n  import and factory reset were shortened but kept in full.\n\n### Fixed\n- **The self-referral data-quality check now uses the real owned-host matcher.**\n  It used to approximate the registrable domain and substring-match, which on any\n  shared parent domain could report a neighbouring site as your own traffic. It\n  now asks `is_owned_host()` — the same rule live capture and reprocess use — and\n  its meaning tightened with it: it catches a host your \"internal\" row claims\n  that is still stored as a referral channel, which is exactly the\n  reprocess-needed case, and its copy says so. The unmatched-referrer check's\n  owned-host exclusion moved to the same matcher, so a lead lands in exactly one\n  of the two. Nothing in the plugin reduces a hostname to its registrable domain\n  any more.\n\n### Added\n- **A new data-quality check: arrivals from your own properties carry no\n  original source.** Fires wherever leads are credited to Own Property, because\n  the statement is true in both states that produce it — the shared-property\n  code is not installed on that property yet, or the visitor arrived there with\n  nothing to carry — and the report alone cannot tell which. The detail points\n  at the Cross-domain snippet; once it is in place, recovered visitors land in\n  their true channel and the count falls on its own.\n- **The Clear list on Data Quality is collapsed by default.** Clear checks are\n  reference material — the register of what is being watched — not something\n  competing with findings; the closed row carries the count.\n\n- **Un-dismissing the last data-quality finding left an empty option row behind.**\n  A site that never dismissed anything and a site that dismissed one thing and\n  changed its mind should leave the same trace, which is none — the row is now\n  deleted rather than written as an empty array.\n\n- **A bookmarked URL for a moved screen 403'd instead of forwarding.** The\n  redirect for retired page slugs ran on `admin_init`, but WordPress resolves\n  page access in `wp-admin/includes/menu.php`, which `wp-admin/admin.php`\n  requires *before* it fires `admin_init` — so an unregistered `page=` had\n  already hit \"Sorry, you are not allowed to access this page\" by the time the\n  redirect could run. It now also hooks `admin_page_access_denied`, which fires\n  on the line immediately above that `wp_die` and is the only point early\n  enough. The routing test now asserts the hook is wired, not merely that the\n  handler behaves — it had been calling the method directly, which passed while\n  the real page died.\n\n### Changed\n- **Editorial pass over every admin screen's copy.** Section intros and help\n  expansions across Settings, Reports, Data Quality, AI Analysis, the journeys\n  screens, the setup wizard and the Gravity Forms field descriptions are shorter\n  and read correctly on a brand-new install — no sentence presupposes an earlier\n  incident, an import, or a previous version, and per-section intros no longer\n  repeat the page-level format/order rules. Data-quality \"what this looks for\"\n  lines describe each check in the timeless present. The \"Why?\" help toggle is\n  now a small circled \"?\" icon (\"×\" when open) with an accessible label. Two\n  stale \"Direct\" references on the Test tab now say \"Untracked\", and two stale\n  settings-path references were corrected. No behaviour changes.\n- **Reports is a four-tab screen: Reports · Data Quality · AI Analysis · Journeys.**\n  It is wp-admin's own `nav-tab` strip, the same control the settings page uses,\n  replacing the Reports | Journeys pills — because these are four different\n  screens, not four readings of one table, and that is the distinction the two\n  controls now carry: a tab changes what you are looking at, a pill changes how\n  the thing in front of you is counted. Two blocks that had grown into screens of\n  their own moved off the bottom of the report: the data-quality checks and the AI\n  Analysis package. `view=journeys` is unchanged — bookmarks, CRM-stored journey\n  links and the moved-slug redirect all still resolve — and an unrecognised `view=`\n  falls back to the report rather than rendering nothing. Every tab carries the\n  window, the credit mode and the dimension, so a trip to Data Quality and back\n  leaves the report exactly as it was set up.\n- **The range / credit / dimension pills appear only where they change something.**\n  Reports keeps all three. Data Quality and AI Analysis show only the window: no\n  check on Data Quality buckets anything, and the AI package is always built per\n  channel and by last click whatever the report is showing. An inert control is a\n  worse explanation than a sentence, so both tabs say why instead.\n- **The Reports tab keeps a one-line data-quality summary** — how many checks\n  found something, how many findings are dismissed, and which blocks were withheld\n  for thin coverage — so moving the section away did not leave the tab silently\n  confident, or a hidden block unexplained.\n- **The Lead AI menu is four entries: Reports, UTM Builder, Help, Settings.**\n  Two screens that were settings tabs are not settings — the UTM builder writes\n  campaign links and Help is a reference table — and as tabs both were only\n  reachable by somebody already editing settings. They are their own pages now.\n  Bookmarked `&tab=utm` and `&tab=help` links redirect to them.\n- **Visitor Journeys is the Journeys subtab of Reports**, not its own menu entry.\n  The two screens are one question — how many leads, and who were they — and the\n  list is now reached by the same pill control the Reports page uses for its date\n  window and its dimension. Every older address for a journey still opens it:\n  `page=bw-lead-ai-journeys` redirects and carries the token, the record hash and\n  the list's own search, status and page filters, so a journey link stored in a\n  CRM years ago opens exactly the journey it always did. On a site with no stored\n  journeys — where the Reports page is not registered at all — those links land on\n  Settings rather than on \"you are not allowed to access this page\".\n- **The Settings tab formerly called \"Journeys\" is \"Journey Storage\".** Two\n  entries called Journeys in one menu, one of them settings and one a list of\n  records, is one word doing two jobs. The `tab=journeys` slug is unchanged.\n\n### Added\n- **Data Quality tab — every check, including the ones that found nothing.**\n  The old section only ever showed problems that applied, which meant an empty\n  list could not be told apart from a set of checks that never ran, and there was\n  no way to find out what the plugin watches for. Every check is now declared in\n  one register with a plain-language description of what it looks for and why it\n  would matter, shown whether or not it fired. Findings first, dismissed ones\n  after, clear ones last. The register is the single source: the aggregate, the\n  screen and the AI Analysis package all read it, and the routing test fails a\n  register entry with no detector.\n- **Dismissing a data-quality finding.** A dismissed finding is de-emphasised but\n  never hidden: it stays in the list, marked dismissed, with the numbers it was\n  dismissed at, an audit line saying when and by what share, and an un-dismiss\n  button. The Reports tab still says how many are dismissed. **If a dismissed\n  finding grows by five percentage points or more it lapses back to a finding on\n  its own**, showing both numbers — a dismissal is an acceptance of a situation,\n  not a permanent silence, and a screen that went on reassuring somebody about a\n  problem that had trebled would be the exact failure this tab exists to refuse.\n  Share rather than lead count, because a count means different things in\n  different windows. Dismissals live in their own option, so they survive a\n  factory reset and never travel in a settings export.\n- **Export, import and factory reset, on Settings → Other Settings.** Your mapping\n  rules as a JSON file you can keep or move to another site: the tracking\n  parameter definitions, medium mappings, click-ID mappings, channel mappings,\n  channel groupings, the interaction-tracking rules and the Google Client ID\n  switch. `{this-domain}` is what makes that work — a rule written with it expands\n  against whatever site it lands on, so `internal : *.{this-domain}` still means\n  \"my own subdomains\" after the move.\n  - **Nothing site-specific travels**: cross-domain destinations, form field\n    mapping and custom interaction selectors describe one site's plumbing.\n  - **Nothing that starts collecting or sharing personal data travels** —\n    identity capture, submission capture and every cross-domain switch, including\n    the owner's recorded acknowledgement. A file somebody pasted in must not be\n    able to turn those on for them. `debug` stays behind for the same reason.\n  - **An import is checked before it is applied**: it must carry this plugin's\n    marker and a schema this version understands, every value goes through the\n    same sanitizer the settings screens post through, unknown keys are named and\n    dropped rather than silently ignored, and a two-step preview shows exactly\n    which rules leave and which arrive before anything is written.\n  - **Factory reset** puts every setting back to the shipped defaults and asks for\n    the word RESET to be typed. It does not touch stored journeys, leads, saved\n    UTM links or dismissed findings, and the screen says so.\n- **Form Field Mapping — every merge tag is mappable, including your own custom\n  dimensions.** The tab formerly called \"Form Fields\" offered six targets while\n  the plugin exposed twenty-odd tags, and the six were a hand-kept list that could\n  not have known about a site's custom dimensions in the first place. It now has\n  one row per data point the site actually has: the built-ins, every custom\n  dimension declared in Tracking Parameter Definitions, `{bw:ga_client_id}` once\n  that capture is on, and the interaction tags once an event type is enabled.\n  Each row names the field to fill by id, class, name or CSS selector, exactly as\n  before, and every mapping already configured is preserved untouched.\n- **One register behind every list of merge tags.** The Gravity Forms merge-tag\n  dropdown, the BW field's data-point dropdown, the Help reference table and Form\n  Field Mapping all read `BW_Lead_AI_Merge_Tags::available_tags()`. They cannot\n  disagree any more — which they could, and did, on exactly the conditional tags\n  hardest to notice: a site with interaction tracking on saw `{bw:events}` in one\n  dropdown and not the other.\n- **Help's merge-tag table now lists the conditional tags too.** Whatever the\n  register has that the built-in table does not — custom dimensions, the GA client\n  ID, the interaction counts — is appended from the register rather than\n  hand-maintained, so the one screen that explains what a tag means can no longer\n  omit one the site has.\n\n### Fixed\n- **A mapped form field can now hold any data point, not one of six.** The\n  front-end resolved mapped fields from a hard-coded table of six values, so\n  mapping anything else — a channel, a campaign, a custom dimension — wrote an\n  empty string. Mapped fields now resolve through the same path as the matching\n  merge tag, so a mapped field and a Gravity Forms hidden field always agree about\n  the same lead. `sources` and `terms` keep their own handling: they are plural\n  aggregations across every stored visit and have never had a merge tag.\n- **A mapping that matches no field on the page no longer clears anything.**\n\n### Removed\n- **The \"What last/first-click is hiding\" block.** The row expanders under\n  \"Which channels are working\" now show the same first-to-last pairs inline,\n  per row, at whatever dimension the page is set to — two presentations of one\n  fact, and the block was the redundant one. What it also carried, and the\n  expanders cannot, is the whole-page total: expanding one row says where that\n  row's leads went, and nobody sums twenty of those to learn how much of the\n  page crosses buckets at all. That single sentence survives, under the table it\n  describes. The pairs themselves are still in the AI package, whose prompt asks\n  the assistant to name them, so nothing is lost to analysis.\n\n### Fixed\n- **`{this-domain}` now works in Channel Mappings, not only Medium Mappings.**\n  A rule naming one of your own hosts to tell one owned property from the rest —\n  `Own Property (Landing pages) : get.{this-domain}/internal` — was compared as\n  the literal text `get.{this-domain}` and so matched nothing, silently: the rule\n  looked right, saved fine, and simply never fired. The token is expanded in\n  `parse_channels()`, which feeds both live capture and reprocess, so one fix\n  covers both paths. Expansion happens *after* the empty-means-any default, never\n  before: a site with no resolvable hostname gets a rule matching nothing rather\n  than one matching everything.\n\n### Added\n- **\"Which hostnames are mine\" is now one visible row instead of a hidden rule\n  plus a separate box.** Medium Mappings gains an `internal` row —\n  `internal : *.{this-domain}` by default — and that row is the whole answer.\n  Two new pieces of syntax make it possible to write: `{this-domain}` stands\n  for this site's own hostname, so the rule survives being copied between\n  staging, live and a client's own domain; and a `*.` prefix means **subdomains\n  only**, so `*.example.com` covers `get.example.com` but not the bare\n  `example.com`. Both work together. Everything else about hostname matching is\n  unchanged — a bare `google` still matches `www.google.co.uk`.\n\n  `{this-domain}` resolves to the site's **own hostname and never the domain\n  above it**. That is a safety rule with teeth: sites are routinely siblings\n  under one parent — a hosting platform's demo domain, an agency's staging\n  domain — and a parent-expanded rule would silently declare every unrelated\n  neighbour a property you own, filing their referrals as \"Own Property\"\n  instead of counting them as real acquisitions. Nothing would error and the\n  totals would stay plausible, so it is covered by tests on both sides\n  (`tests/owned-hosts.php`, `tests/acceptance.js`) that fail if anyone ever\n  changes it.\n\n### Changed\n- **\"Other Owned Properties / Domains\" is gone as a separate section.** What it\n  held now lives in the `internal` row above, which is the only place the\n  plugin reads: the capture script, the shared-property snippet on the\n  Cross-domain tab (kept — it is the piece that recovers the ORIGINAL source\n  from a property you own) and the reprocess pass all take their list from\n  there. **Nothing is lost on upgrade.** Hostnames the old setting was holding\n  are folded into the `internal` row at read time, and a site that never had\n  the row gets one saying exactly what the old invisible rule said — so what\n  matched yesterday matches today. The merge happens at *read* time rather than\n  by rewriting your saved settings behind your back: you see the row on the\n  Settings screen, and your next save is what makes it permanent. The retired\n  value stays in the database untouched.\n- **Reprocessing now also applies edits to that row.** Because the owned-host\n  list is a setting the plugin re-reads when deriving attribution, adding a\n  property you own and then running Reprocess corrects journeys already stored,\n  the same way editing a channel rule does. Listed under \"Reprocess\" on the\n  Journeys tab so nobody has to guess whether it is worth pressing.\n- **Copy sweep after the section renames.** Every in-product reference to a\n  renamed settings section was updated with it — the data-quality finding that\n  points at referrer rules, the duplicate-label and reserved-key validation\n  errors, the shared-property explainer, and the merge-tag reference table.\n  Documentation that names a screen which no longer exists is worse than none,\n  because it sends a reader looking for something they will not find. The same\n  sweep caught two survivors of the Direct rename in copy outside the reports\n  (the merge-tag example and the owned-property explainer).\n\n- **Channel Groupings — a new setting that collects channels into buckets for\n  reporting.** One row per group in the same `Group : member, member` shape as\n  every other mapping setting, edited with the same row editor, walked in the\n  same first-match-wins order. Ships grouping the shipped channels into\n  **Paid**, **Organic Search** and **AI**, with a final `{bw:channel} : *`\n  catch-all that leaves every channel you have not grouped standing on its own\n  — which is what keeps a grouped table adding up to the same total as an\n  ungrouped one. `{bw:channel}` is the documented form; a bare `{channel}` is\n  accepted as an alias. Grouping is a **reporting** concern only: it is derived\n  from the channel label when a report is drawn, never written to a journey, so\n  changing it needs no reprocessing and no stored data moves.\n- **The \"Settings\" sub-tab is now \"Tracking Rules\", and \"Other Settings\" is its\n  own tab.** The label changed; the `tab=settings` slug did not, so bookmarks\n  and the tab-save round-trip test are untouched. Tracking Rules now holds only\n  the six mapping sections, which is what makes it scannable — a reader working\n  down six sections of `label : value` rules no longer meets a checkbox and has\n  to work out that it is not a seventh rule. Other Settings sits between UTM\n  Builder and Test and holds Capture Google Client ID and Debug Mode.\n- **A dimension selector that drives the whole Reports page.** \"Break leads\n  down by: **Channel Groups** | Channels | Source / Medium | Source | Medium\",\n  as a third pill row under the page heading with the window and the credit\n  switch — where it belongs, because it changes the same amount they do. The\n  KPI card, the weekly chart, the performance table and the transitions block\n  all re-bucket and rename themselves; a heading, a column header or a help\n  sentence never names a dimension the table below it is not showing. Three\n  blocks deliberately do not follow it: the AI export (a self-contained\n  artifact, always per-channel and last-click — and it now says so on the page),\n  Data quality (about tag quality, not attribution) and the per-form table.\n  **Channel Groups is the new default** — a channel list on a real site is\n  twenty-odd rows, most of them one lead, and the question people arrive with is\n  answered by five. It composes with the window and the credit switch: every\n  link carries the other two axes, so switching any one preserves the other two.\n  Every column is **recomputed over that bucket's own leads** rather than summed\n  from the channel rows (a median cannot be summed), and the correctness\n  property groups established generalises rather than staying a special case: a\n  lead that moved between two channels *inside one bucket* has not changed\n  bucket, so it is not a cross-bucket assist and not a move in the transitions\n  table — both ends are mapped before they are compared. Row expanders work at\n  every dimension and name the other end at the row's own granularity. It began\n  as a Channels/Groups switch inside the performance table earlier in this\n  release; the parameter name `grouping` is kept rather than adding a second one\n  that would mean the same thing.\n- **Journeys with no source or medium recorded are bucketed, not dropped.** On\n  the three raw dimensions an unplaceable journey lands in a single **Unknown\n  source** / **Unknown medium** / **Unknown source / medium** row — the same\n  choice the per-form table makes for a journey saved with no form name, and the\n  reason those tables add up to every lead in the window. A pair with one half\n  missing names the half it has (`google / Unknown medium`) rather than\n  rendering as a bare `\" / \"`. Those labels are invented by the report, so they\n  are never linked to a journeys search that would find nothing. Channels and\n  groups keep the older and equally deliberate convention — a journey that never\n  said which channel brought it is excluded from the ranking and reported in\n  Data quality — so \"unknown\" can never win \"biggest channel\".\n- **Journeys are now saved server-side by a form submission on the site\n  itself.** Until now the entire journey-storage pipeline was gated on\n  cross-domain handoff being configured: a site whose forms live on its own\n  domain — the common case — stamped every entry with a summary and stored no\n  journey at all. Storage is now its own thing: on a site with no cross-domain\n  destination, the capture script mints a journey token on pages holding a\n  Gravity Forms form, stamps it into the form, and the submission promotes the\n  record through the exact promotion the cross-domain confirmation uses — same\n  `save()`, same retention, the form's title as the record's context, so the\n  Reports forms block names it properly. Anonymous unless identity capture is\n  on, exactly as before.\n- **Two new settings on the Journeys tab govern the journey lifecycle.**\n  *When is a journey saved?* — on a form submission on this site, or when the\n  cross-domain destination confirms the lead (the only previous behaviour).\n  *After a journey is saved, when does it update?* — never (**snapshot**),\n  until the visit it was saved in ends (**visit**, the new default), or every\n  time the same person returns (**growing** — the old \"Keep growing\" switch,\n  folded in; its checkbox is gone from the Journeys tab and a ticked one reads\n  as growing). Both settings resolve automatically until a choice is made:\n  save-timing follows whether a destination is configured, and an explicit\n  choice, once saved, is never silently rewritten by configuring one.\n  **Visit mode is what catches the thank-you redirect chain**: the pages a\n  form sends the visitor to arrive moments after the save, so the journey now\n  ends where the visitor actually ended, not on the page before the form. The\n  visit boundary is enforced server-side — thirty minutes without a beacon\n  freezes the record permanently; the browser is never trusted to declare a\n  visit still open.\n- **The update beacon reports every pageview after a submission** (on sites\n  that save at submission) instead of only once per browsing session — the\n  once-per-session send has usually been spent before the thank-you page\n  exists. The server accepts or refuses each report per the update mode.\n- **A saved journey now wins over a newer provisional record when the update\n  beacon looks a visitor up.** Right after an on-site save the browser mints a\n  fresh provisional token, bound to the same visitor key; newest-first lookup\n  handed the beacon that record — which it refuses to grow — and the\n  post-submission pages went nowhere. Growing-mode sites get the same fix for\n  the return-visit case.\n- **First-click / last-click attribution toggle on Reports.** A second pill row\n  under the date ranges — *Credit each lead to: Last click | First click* —\n  re-answers the channel blocks (the biggest-channel KPI, Leads per week, Which\n  channels are working) with the OTHER end of each journey: the channel that\n  first found the lead instead of the one it enquired through. Same journeys,\n  same totals, other end of the credit. The switched-journey table is one table\n  reframed, not a second table: **\"What last-click is hiding\"** becomes\n  **\"What first-click is hiding\"**, the headline names the channel that closed\n  leads it was not credited for, and the *Got the credit* header moves to the\n  column that mode actually pays. The channel table's cross column mirrors with\n  it — **Assisted** (found leads someone else converted) under last-click,\n  **Closed** (converted leads someone else found) under first-click — because\n  each mode's blind spot is exactly the other end's work. Plain links carrying\n  a whitelisted `attribution` parameter, so either view is bookmarkable and the\n  default URL keeps its old shape.\n\n- **Channel rows expand to show the other end of their journeys.** A **+** on a\n  row in \"Which channels are working\" opens subrows splitting that row's leads\n  by the opposite end of their journey — under first-click, the channels they\n  actually enquired through; under last-click, the channels that first found\n  them — plus a **came back through the same channel** line for returners who\n  chose the same channel twice (loyalty, not triviality) and a line for\n  journeys whose other end was never recorded. Single-visit journeys are the\n  implied remainder, not a line of their own. Rows that are nothing but\n  single visits get no button. Subrows are server-rendered and merely un-hidden by a\n  small dependency-free script, so every number is in the page without\n  JavaScript and nothing is fetched on click.\n\n- **The \"Part of Direct is arrivals via your own properties\" data-quality\n  finding is gone**, superseded inside this same version: Own Property is now a\n  channel row of its own, so the share that finding existed to explain is\n  printed in the table rather than footnoted under it.\n\n- **Two honest names replace \"Direct\".** An arrival that carried no source\n  information at all — an untagged email click, an app that strips the referrer,\n  a bookmark, a typed-in address — is now labelled **Untracked**. \"Direct\"\n  implied a person deliberately typing the address; the reality is usually an\n  unattributable click, and the old name flattered it. And an arrival from a\n  property the site owns is split out as its own channel, **Own Property**\n  (`*/internal`), instead of being folded into that bucket — unlike Untracked\n  it is *fixable*: the shared-property snippet on the Cross-domain tab carries\n  the true source across. Reprocess applies both retroactively, resurrecting\n  the own-property evidence preserved in `*_original` for journeys folded by\n  an earlier pass. Both are ordinary channel-mapping rows, so a site can\n  rename either freely.\n\n- **AI assistants are classified by default.** `chatgpt.com`, `gemini.google.com`,\n  `claude.ai`, `perplexity.ai` and Copilot now resolve to an `ai` medium and to\n  per-assistant channel labels, with `AI Assistant : */ai` catching whatever comes\n  next. This is real traffic — one site had 22 leads from ChatGPT sitting\n  unrecognised in the referral bucket.\n\n- **Webmail is classified by default.** A click from Gmail arrives as\n  `mail.google.com`, and from the Gmail Android app as `com.google.android.gm`.\n  Both contain the label `google`, so both were counted as **organic search**.\n  Someone opening your newsletter is not someone finding you on Google.\n\n- **Reprocess journeys.** A new panel under **Settings → Journeys → Stored data**\n  that re-derives channel, source and medium on journeys already stored, using this\n  site's settings and this build's processing as they are now. A stored journey's\n  attribution is frozen at capture time, so fixing a channel mapping, adding a\n  referrer rule or listing one of your own domains fixes the future and nothing\n  else — the history keeps yesterday's answer forever.\n\n  **It never runs on its own.** A report that quietly rewrites itself between two\n  viewings is a report nobody trusts, so this happens only when someone presses the\n  button. To make that an informed decision the panel names the processing\n  improvements shipped since this site last reprocessed. That list lives in\n  `BW_Lead_AI_Reprocess::CHANGES`, and **any future change to how attribution is\n  derived must add an entry to it** — without one, a site owner has no way to know\n  that reprocessing is now worth doing, and the fix reaches new leads only.\n\n  The values captured on the day are preserved as `*_original`, written once so a\n  second pass cannot overwrite them, and the correction is applied everywhere the\n  same fact is stored: the flat payload keys the reports read, plus the journey\n  blob's first touch, last touch and per-visit entries that the single-journey\n  report draws from. Correcting only one of those is not a smaller version of the\n  bug — it is the bug plus a headline that contradicts the six lines underneath it.\n  On a real 435-journey dataset: 89 leads credited to the site's own `get.`\n  subdomain collapsed to Direct and 11 stuck on a truncated `google / d` medium\n  resolved to Google Ads. The second pass changed nothing.\n\n- **`tools/import-gf-history.php`** — rebuilds journey history from Gravity Forms\n  entries that carry a `{bw:summary}`, so a site that has been running the plugin\n  arrives at its reports with real history instead of an empty table. Read-only\n  against Gravity Forms, idempotent via a stored `gf_entry_id`, dry run by default.\n  A `reclassify` mode re-runs the site's current referrer rules over already\n  imported records, because the classification a summary captured is frozen at\n  whatever the rules were that day.\n\n- **Reports.** A new screen under **Lead AI → Reports** answering two questions a\n  marketer actually has: is this working, and what should I do differently. Six\n  blocks, chosen by profiling a real 434-journey dataset rather than by imagining\n  what marketing data looks like — which killed four planned blocks before they\n  were built (campaign and search-term tables had 1% and 3-distinct-value\n  coverage; a landing-page table had 11%; a median-days KPI always read zero).\n\n  It is its own menu page rather than a tab on the settings screen: it is the one\n  screen here that is read rather than configured, and behind a settings tab strip\n  it was only ever found by somebody already editing settings. The menu now reads\n  **Reports, Visitor Journeys, Settings** — the two screens you read, then the one\n  you configure — and Reports appears only once the site has stored a journey to\n  report on. A bookmarked `&tab=reports` redirects to the new page, carrying its\n  date range with it.\n\n  The block that earns its place is **\"What last-click is hiding\"**: journeys\n  where one channel found somebody and another closed them. A form notification\n  names only the last touch, so search that finds a lead in March and sees them\n  return as Direct in May earns none of the credit — and the budget decision that\n  follows is made on a false premise.\n\n  **Data quality is a first-class block, not a footnote.** It names what would\n  quietly bend the numbers above it: a site counting its own subdomain as a\n  traffic source, unresolved `{CampaignName}` placeholders arriving from an ad\n  platform, journeys with no channel at all. Most tools present broken\n  measurement as confident numbers.\n\n- **AI Analysis.** A section at the foot of Reports that builds a self-contained\n  investigation package — a written brief plus the whole window as data — to paste\n  into whatever AI assistant you already use. Copy it, or download it as a markdown\n  file. Nothing is sent anywhere by the site; there is no API key and no outbound\n  request.\n\n  The bar it is written against is that an assistant which has never seen this\n  plugin can produce something **true and specific about your site**, rather than a\n  fluent page of advice that would have read identically for anybody. Two things do\n  that work. The dataset **leads with its own limitations** — field coverage, every\n  data-quality finding verbatim, what Direct actually means here, which journeys\n  came from an import and therefore carry no interaction events, and that engagement\n  figures are medians — because a reader applies caveats they met first and ignores\n  caveats they met last. And the brief bans the specific failures: ground every\n  claim in a figure or a page you opened, go and *look* at the landing pages and\n  enquiry pages before recommending anything about them, size effects honestly\n  rather than inventing percentages, and finish with the questions this data cannot\n  answer.\n\n  **It states that it has no denominator, twice.** The plugin only ever sees people\n  who converted, so a conversion rate computed from it would be a number divided by\n  itself. The tables look exactly like the ones a full-traffic analytics tool\n  prints, which is precisely why an assistant handed them without warning will\n  produce a rate — so the absence is stated in the data and restated as a rule in\n  the brief.\n\n  Beyond the dashboard's six blocks it adds what the aggregate throws away: the\n  landing pages journeys actually begin on, which pages recur across journeys and\n  which of them the form was submitted from, and twelve journeys quoted in full —\n  chosen for spread rather than at random, each saying which bucket it came from,\n  because a selected sample presented as a neutral one is its own kind of lie. It\n  finishes with real addresses to open.\n\n  **The dataset counts its own measurement artifacts** so the assistant never has\n  to deduce them: journeys that begin on a thank-you page (post-submit echoes\n  recorded as journey starts — 93 of 435 on the proving dataset), and journeys\n  containing WordPress admin pages (staff browsing captured as a lead, which\n  otherwise tops every engagement ranking). Echo rows are marked in the landing\n  table rather than removed — rewriting the table would hide the artifact the\n  limitations section tells the reader to weigh — and staff journeys are labelled\n  and kept out of the \"highest engagement\" samples. Both came out of running the\n  v1 package for real and watching the analyst burn its wordcount discovering them.\n\n  **Names and emails are off by default** and replaced with a record number. The\n  toggle that includes them says plainly that the file then contains personal data.\n  The download is a nonced, capability-checked request and the flag rides in the URL,\n  so the file that arrives is the one the button described.\n\n- **Reclassify mode for imported history** — re-runs the site's current referrer\n  rules over already-imported journeys, since the classification a summary\n  captured is frozen at whatever the rules were that day.\n\n- **Your Own Domains.** Other hostnames you own — a landing-page platform on a\n  subdomain, a booking tool, a second site. Arriving from one of these is not an\n  acquisition, and crediting it means whatever actually found that visitor gets\n  nothing: on a real site that was **a fifth of every lead** attributed to the\n  site's own subdomain.\n\n  Listed hostnames are treated exactly as the site's own: the visitor keeps\n  whatever source found them earlier, and a visitor whose first contact came that\n  way records as direct — an honest \"we do not know\" rather than a confident\n  attribution to ourselves. The trip out and back becomes one journey, with the\n  excursion recorded as the single waypoint we can honestly capture, since nothing\n  of ours runs on the other property.\n\n  **The site's own hostname is always included and matching covers subdomains**, so\n  the common case — a landing platform at `get.example.com` — needs no\n  configuration at all. Lookalikes are not caught by accident: matching requires a\n  dot boundary, so `notexample.com` is never a subdomain of `example.com`.\n\n- **Traffic through a property you own keeps its original source.** Listing a\n  landing-page platform under Your Own Domains stops it being credited as a\n  channel, which is right — nobody was acquired from your own landing page — but\n  the visit then fell back to **Direct**, and Direct there is a tidier way of\n  saying the answer was thrown away. Somebody found that page from somewhere.\n\n  A new **\"A property you own\"** panel on the Cross-domain tab generates a script\n  for that property. On the property it remembers what originally brought the\n  visitor there — the page's own tracking parameters if it has them, otherwise the\n  hostname that referred them — and appends it to any link pointing back at this\n  site. The arrival is then classified as the ad or the search that really found\n  them instead of as Direct.\n\n  It works for **any** owned property on **any** platform: no cookies, no shared\n  domain, no CORS, and it does not need handoff or continuity switched on. **No\n  visitor data travels** — only the source, on your own links. Anything sent this\n  way is stripped back out of the address bar once the visit is recorded, so it\n  does not leak into browser history, shared links or another tool's page reports.\n\n  **A carried source is only believed when the visitor genuinely came from one of\n  your own hostnames.** A parameter naming a source is a claim made by a link, and\n  links are written by anyone: without that check, a stranger could put one in an\n  email or on their own site and have every click stored as organic search — paid\n  traffic laundered into organic, a competitor's referral erased, and nothing in\n  the data afterwards to show it happened. Real tracking parameters and click IDs\n  always win over a carried source, and a carried hostname naming one of your own\n  properties is still refused, so it cannot be used to launder a self-referral. One\n  limit is accepted deliberately and recorded in the code: a visitor arriving with\n  no referrer at all — common, because redirect chains and native apps strip it —\n  cannot be checked, and refusing that case would throw away the ordinary arrivals\n  this feature exists to rescue.\n\n  **Not a Reprocess entry, and that is the point.** Reprocess re-derives stored\n  attribution from what was captured. This changes what gets captured in the first\n  place: a journey already recorded as Direct has no carried source in it to\n  re-read, and none can be invented. It reaches new visits only.\n\n### Changed\n- **The Settings tab's sections are renamed, reordered and merged**, so the page\n  reads in the order the data actually flows: parameters, then medium, then\n  source/medium, then channels, then groups. Parameter Aliases → **Tracking\n  Parameter Definitions**; Default Referrer Classification → **Medium\n  Mappings**; Click-ID Inference → **Source / Medium Mappings**; Your Own\n  Domains → **Other Owned Properties / Domains**. Google Analytics and\n  Formatting & Debug merge into one **Other Settings** section holding Capture\n  Google Client ID, Source / Medium Separator and Debug Mode. Presentation only:\n  no option key, no form field name and no stored value changes, and saving the\n  tab after the change writes back exactly what was there before.\n- **The mapping settings are edited as rows, not as a block of text.** Five\n  settings share exactly one shape — `label : value, value` — and all five now\n  render as a table of rows with the label in one input and the comma-separated\n  values in the other: **Parameter Aliases**, **Default Referrer\n  Classification**, **Click-ID Inference** and **Channel Mappings** on the\n  Settings tab, and **Custom actions** on the Interactions tab. The defaults\n  arrive pre-made as rows; **+** adds one, **−** removes one. Nobody has to know\n  that a colon separates the halves, or that a stray one breaks a rule.\n\n  **Row order is editable, because for two of these it is the whole answer.**\n  Referrer classification and channel mappings are walked top to bottom and the\n  first match wins — `ai` above `organic`, catch-alls at the bottom — so each row\n  carries **↑ / ↓** buttons and the order on screen is the order stored. The two\n  settings whose order carries no meaning (parameter aliases, custom actions)\n  get no arrows rather than a control that decides nothing.\n\n  **Source and Medium cannot be deleted.** Without them nothing can be\n  attributed at all, so those two rows are pinned to the top of Parameter\n  Aliases with no remove button and an uneditable label. Clearing one falls back\n  to the default parameter list, and the rule is enforced when the settings are\n  saved rather than only drawn in the UI.\n\n  **Each section keeps an \"Edit as text\" switch** — for pasting a whole ruleset\n  in, and for anything the row parser cannot split. A line with no separator, or\n  a comment, is never discarded: it comes back as a row with the whole line in\n  the label and nothing in the values, so it is in front of you rather than\n  quietly gone. Exactly one of the two editors is ever submitted, so they cannot\n  race.\n\n  **The stored format did not change.** Rows are a projection of the same\n  newline-delimited text the textarea always held — parsed to draw, folded back\n  to save — so attribution, reprocessing, the AI export and the reports are\n  untouched, and a row nobody edits is written back byte-for-byte, spacing and\n  all. Opening the settings screen and pressing Save changes nothing.\n\n- **The visitor-continuity cookie spans your own subdomains.** It was scoped by\n  WordPress's `COOKIE_DOMAIN`, normally an exact host — so a landing subdomain\n  running the capture script started its own journey rather than joining the one\n  already in progress. It is now scoped to this site's own host with a leading\n  `www.` removed, so a site at `www.example.com` also covers `get.example.com`.\n\n  **Deriving the registrable domain would have been the bug, not the fix.** It is\n  what \"share it across our subdomains\" sounds like it means, and on a site that\n  owns its whole apex the two agree — but a site that itself lives on a subdomain\n  of a shared parent, which is how most sites start life, would scope its cookie to\n  that parent and hand its visitor keys to every unrelated site sitting beside it.\n  Two businesses' visitors would silently merge into one record. So the scope never\n  goes higher than the site's own host, and a site that genuinely owns its apex can\n  widen it deliberately through the new `bw_lead_ai_cookie_domain` filter.\n\n  This carries identity across subdomains of one site. Carrying the *source* across\n  a property is the separate link mechanism above — they are independent, and the\n  cookie is deliberately not used for it: it is HttpOnly and the API never permits\n  credentials cross-origin, by design.\n\n- **A visit is now a session, not a browser tab.** A visit used to start whenever a\n  browser session appeared that had not been counted yet, which is wrong in both\n  directions: `sessionStorage` is per-tab, so opening a link in a second tab counted a\n  fresh arrival, and it dies with the window, so closing the browser and coming back\n  ten minutes later counted another. A visit is now bounded by **30 minutes of\n  inactivity** — the conventional session boundary, chosen so these counts mean the\n  same thing as the ones in the analytics tool sitting beside them. The exception is\n  preserved exactly: an arrival carrying tracking parameters for a different source or\n  campaign — a retargeting click, an email link, a second ad click — still starts its\n  own visit mid-session, because that is a genuine new acquisition and whatever paid\n  for it has to be able to claim it.\n\n- **\"Pages\" counts distinct pages within a visit.** A refresh is not another page\n  read, and neither is the back button or returning to the page a menu hangs off. On a\n  real dataset a fifth of every view stored repeated a URL already in the journey. The\n  view is still recorded either way — the timeline is a record of what happened and\n  stays truthful; only the counter dedupes, because the counter answers \"how much of\n  the site did they read?\", which is a question about distinct pages.\n\n- **Reprocess brings stored history to the same two definitions**, so the reports are\n  not half-measured on one rule and half on another. Consecutive stored visits less\n  than half an hour apart merge into one, keeping the FIRST arrival's attribution;\n  page counts are recomputed as distinct URLs per merged visit. The values captured on\n  the day are preserved once as `visits_original` and `pages_original`, on the same\n  write-once rule as the `*_original` attribution keys, and the raw `summary` text is\n  untouched and remains the audit. Verified on a real 435-journey dataset: 117\n  journeys changed, and a second and third pass changed nothing.\n\n  **Expect engagement figures to step DOWN on the day this lands.** That is a\n  correction to how they were measured, not a drop in performance, and the Reprocess\n  panel now says so before anyone presses the button. On that dataset the journeys\n  that stored more than one visit went from a mean of 3.99 visits to **1.81**, and\n  \"came back\" fell from 30% to 16% on Direct, 19% to 12% on Google Organic and 14% to\n  6% on Google Ads.\n\n- **A site's own staff are no longer tracked.** The capture script is not loaded for\n  anyone who can `edit_posts`. A marketer's own browsing is not lead behaviour, and it\n  does not stay quietly in the background: on a real dataset the single most engaged\n  \"lead\" in the whole table was the site's own staff account, with 111 visits and a\n  wp-admin page sitting in the middle of its journey. `edit_posts` is the line rather\n  than \"logged in\", so subscribers and customers — frequently the actual leads on a\n  membership or shop site — are still tracked. This changes who is recorded from now\n  on and rewrites nothing already stored. One consequence worth knowing: a staff\n  member submitting one of the site's own forms will now see the literal `{bw:...}`\n  text land in tracking fields, because nothing is loaded to substitute it.\n\n- **Default referrer rules are ordered `ai · email · organic · social`.** The first\n  match wins and a bare value matches any whole label, so `google` also matches\n  `gemini.google.com` and `mail.google.com`. Ordering resolves that without\n  narrowing the `google` rule — restricting it to `www.` subdomains would have\n  stopped matching `google.com` and every country domain like `google.co.uk`.\n\n### Fixed\n- **A data-quality warning that was itself wrong.** The malformed-medium check\n  flagged every ChatGPT and Copilot lead, because `ai` — a medium this plugin\n  generates itself — was missing from the list of legitimately short mediums. A\n  warning that is wrong teaches the reader to ignore the ones that are right, so\n  any medium the plugin can produce is now on that list.\n\n- **Journey storage is no longer gated behind cross-domain handoff.** The Visitor\n  Journeys page appears whenever journeys exist, and `capture_identity()` no longer\n  requires handoff. `handoff_enabled()` needs a destination domain *and* a mode, so\n  a site whose forms live on its own domain could never create the journey table or\n  reach the page — the same split-brain the Journeys/Cross-domain tabs fixed in the\n  UI, still present in the gates.\n\n- **Engagement figures used a mean and the mean was misleading.** One visitor who\n  read 164 pages pulled a channel's \"pages read\" to 5.9 and made it look like the\n  site's highest-intent source; its median was 2.0, the same as organic search.\n  Long tails are the norm in web analytics, so this was wrong on principle rather\n  than on one dataset. The engagement columns are medians — the typical lead, not\n  the average one.\n\n- **Two new data-quality detectors**, each generalising a fault that had to be\n  found by hand: `unmatched_referrer` (a channel still shaped like a bare hostname\n  means no classification rule claimed it — this would have caught the broken\n  hostname matcher automatically) and `malformed_medium` (a one or two letter\n  medium is an ad platform firing its template before substituting the value).\n\n- **An en dash silently discarded whole journeys on import.** Some stored\n  summaries write \"Converted via – Google Ads\" with an en dash where the format\n  uses a hyphen — most likely `wptexturize` between the form and the database.\n  Every prefix test in the parser matches a literal hyphen, so those entries\n  parsed to *nothing*: no channel, no landing page, no visits, no journey. They\n  did not look broken afterwards, they were simply absent. On one site that was\n  22 entries, 9 of them paid ad clicks that then appeared to have no source at\n  all — which is exactly the shape of an error that makes someone distrust a\n  report they should trust. The separator is normalised before any line is read;\n  hyphens inside URLs and campaign names are untouched because they are never\n  space-surrounded.\n\n- **Imported journeys duplicated their last visit.** The summary parser appended a\n  *reference* to the visit being built, so each new visit wrote through it into\n  the element already stored: a three-visit journey became three copies of the\n  last one, sharing a timestamp. It presented as a tracking fault and was a parser\n  fault. Repaired: unique page views across the dataset went from 179 to 336, and\n  the share of page views that were repeats fell from 66% to 39%.\n\n- **First-touch and last-touch could disagree about the same visit.** The importer\n  gave `first_channel` a fallback to `channel` but gave `first_source` and\n  `first_medium` none, so a later reclassification corrected one end of a journey\n  and not the other — inventing 100+ channel switches that never happened. The\n  measured rate of genuine first-to-last changes fell from a fabricated 44% to a\n  real 10%.\n\n- **Growth percentages are withheld when there is nothing to compare against.**\n  A 90-day window on a site with 90 days of history was reporting \"+106%\" against\n  a period that predates the data. A number on a card gets believed; a caveat\n  underneath it does not.\n\n### Removed\n- **The Source / Medium Separator setting.** `{bw:source_medium}` is always\n  `source / medium` with a slash now. It was configurable, and the only thing\n  that bought was a way for two sites to disagree about what `google / cpc`\n  looks like — including one that saved it as `google/cpc` because an early\n  sanitizer trimmed the spaces off and nobody could see why. The literal lives\n  in `BW_Lead_AI_Settings::SOURCE_MEDIUM_SEPARATOR`, mirrored by a matching\n  constant in `capture.js`. Consumers updated: the merge-tag resolver, the\n  summary builder and the channel-label fallback in `capture.js`, the front-end\n  config it was passed through, the reprocess pass's channel-label fallback, the\n  settings default, its sanitizer and the settings field itself. **A site that\n  had customised the separator will see `{bw:source_medium}` render with a slash\n  from now on.** The stored value is left alone rather than deleted — it is\n  harmless, and deleting a site's saved data for a cosmetic change is not\n  warranted — but nothing reads it."
}
