diff --git a/FIRST-TOUCH-ATTRIBUTION.md b/FIRST-TOUCH-ATTRIBUTION.md
new file mode 100644
index 0000000000..8fcf859a46
--- /dev/null
+++ b/FIRST-TOUCH-ATTRIBUTION.md
@@ -0,0 +1,108 @@
+# Learn first-touch capture preparation
+
+Status: research-only production design; source characterization is qualified by the tests below.
+This document does not describe an implemented Learn capture or handoff producer.
+
+**Prerequisite: [netdata/cloud-frontend#5743](https://github.com/netdata/cloud-frontend/pull/5743).**
+The reader contract is pinned to `netdata/cloud-frontend @ 3461255246041ad8c309e749973faaa53c716b55`,
+`docs/first-touch-cookie.md`. Merge and deployment of prerequisite readers must be verified before
+Learn capture can be enabled. Draft preparation does not authorize merge or release.
+
+## Purpose and observed behavior
+
+Preserve a visitor's first eligible Learn observation through SPA navigation and App navigation,
+without changing Website field meanings, authentication, or existing analytics identities.
+Source inspection targets `netdata/learn @ c3a16edd5ee4dc819976ef162c9afaff4b9b968c`.
+
+- `src/theme/Root/index.js:21` handles Nedi assets. Initial loads and SPA transitions do not write
+ `nd_first_touch`; the executable Root characterization verifies that absence.
+- `docusaurus.config.js:159,172` supplies App links with `utm_source=learn` and a navigation label.
+ These describe the internal click, not a carried first observation. No `nd_ft` is emitted.
+- Markdown content and `src/data/News.js:426,534` also contain App links. A navbar-only decorator
+ would miss those links; generated documentation must not be edited to add attribution.
+- `docusaurus.config.js:186` uses `posthog-docusaurus@2.0.5`, pinned in `yarn.lock`, with the existing
+ `https://app.posthog.com` endpoint and development disabled. Its actual route module sends
+ `$pageview` on initial load and pathname changes; it does not capture a first-touch cookie.
+- `static/api.html` is a standalone executable content page outside Docusaurus Root and its
+ plugins. `static/oauth2-redirect.html` is an authentication utility, not a content-entry page.
+- `/` redirects before page JavaScript executes (`netlify.toml:22`). Its owner is `static.toml`;
+ generated redirects come from ingestion. Browser capture cannot reconstruct the original route.
+
+## Bounded implementation scope after qualification
+
+1. Add a Learn-owned adapter for the existing cookie and exact App handoff contract. Capture the
+ initial page's pathname, permitted referrer, supported UTMs, and canonical UTC millisecond time
+ together. Preserve earlier Website or other supported evidence as a whole; never put a Learn
+ pathname into Website-only root fields. Unknown versions and malformed stored records must not
+ become fresh captures or be overwritten. No additional cookie or persistent store is permitted.
+2. Use the supported Docusaurus client-module hooks: initialize document evidence before the
+ initial React render; use `onRouteUpdate` for route context and `onRouteDidUpdate` for rendered
+ link refresh. Preserve the original observation across pathname, query, hash, and back navigation.
+ Leave Root's Nedi head ownership intact. Qualify standalone `/api` coverage separately; do not
+ attach capture to the OAuth redirect utility. See [Docusaurus client lifecycles](https://docusaurus.io/docs/advanced/client).
+3. Decorate exact permitted HTTPS App destinations across navbar, content, news, and dynamically
+ inserted links. Preserve their existing query/hash bytes and internal `utm_*` labels. Cover
+ keyboard activation, middle-click, copied links, and SPA rerenders. Storage or SDK failure must
+ not block navigation. Do not change redirects, generated content, backend inputs, or SDK setup.
+4. Attach only identity candidates observed for the captured visit. Retain original `ts` and
+ existing identities; never infer a historical ID from a later page or change SDK persistence.
+ ID association is diagnostic evidence, not authorization to identify or merge people.
+5. Include tests, a rendered-browser check with intercepted network traffic, operator instructions,
+ and single-repository rollback qualification. Production code must have no SEO runtime dependency
+ and no dependency on another capture PR from the same delivery batch.
+
+## Reader constraints and unresolved qualification gates
+
+- Numeric version 2 uses `entry` with `surface`, `path`, `referrer`, five `utm_*` strings, and `ts`.
+ Optional Website roots retain their existing meaning. At most five identity candidates carry
+ `distinct_id`, public `project_token`, `surface`, anonymous/identified `status`, and `observed_at`.
+- Encoded cookie and `nd_ft` JSON values are bounded to 3,800 bytes. Every populated observation,
+ including Website roots, requires canonical time, nonfuture time, original time plus 90 days
+ strictly after now, and pre-signup time when known. Invalid roots reject the entire handoff.
+- Structured referrers exclude credentials, query/fragment, IP/local/private hostnames and
+ unsupported ports. Do not silently replace an unsupported referrer with an empty one. The safe
+ projection must preserve the observation's meaning and pass the App's existing contract.
+- App independently caps the complete auth callback at 3,800 serialized URL bytes and can omit
+ `nd_ft` to preserve authentication. Envelope size alone does not prove callback continuity.
+- **Concurrency is unresolved.** The approved requirement calls for recoverable earlier candidates
+ across competing writers. A shared-cookie read/compare/write is not atomic; document memory does
+ not prove recovery after a losing tab exits. The existing v2 reader exposes one primary entry
+ and optional Website roots. Do not claim complete concurrency or add storage to conceal this gap.
+- **Delayed identity persistence is unresolved.** `document.cookie` does not reveal stored expiry.
+ Exact metadata is required for v1 migration. The approved timestamp-plus-90-day fallback belongs
+ to Website enrichment; it does not authorize Learn identity updates or renewal after SDK load.
+- **SDK readiness is unresolved.** The installed plugin's stub has no synchronous identity reader;
+ the existence of `capture` or its queued opt-out method does not establish readiness. A `loaded`
+ function in plugin options is discarded by JSON serialization, reproduced by the tests below.
+ A supported readiness and anonymous/identified-state adapter must be qualified without changing
+ project, endpoint, SDK identity, or existing tracking controls. [PostHog documents this serialization limit](https://posthog.com/docs/libraries/docusaurus).
+- **Mixed versions and rollback need browser proof.** Learn-only cookies cannot supply legacy
+ Website fields to a stale App reader. A stale Website existence guard can skip a later Website
+ capture. Qualify against the deployed Website compatibility change from
+ [netdata/website#1383](https://github.com/netdata/website/pull/1383) and the prerequisite App reader;
+ explicitly retain cached-client limitations. No same-batch Website producer is required.
+- Root/legacy redirect capture requires its separate policy approval. It remains outside this
+ preparation scope. Missing SDK, disabled tracking, refused storage, oversize data, and absent
+ referrers must remain visible limitations rather than invented successful attribution.
+
+## Runnable characterization and later acceptance
+
+Install the existing lockfile with Yarn 1.22.22 and the repository's Node.js 22.23.2 runtime, then run:
+
+```sh
+yarn vitest run src/theme/Root
+node --test tests/first_touch_producer_baseline.test.mjs
+```
+
+The Root tests execute the actual React component. The native tests execute the installed plugin's
+generated SDK stub and route module, with network insertion intercepted and only module bindings
+adapted for the VM. They characterize missing capture and SDK limitations; they do not simulate an
+unbuilt Learn writer or certify the desired production behavior. App owns its producer-contract
+fixtures and real reader/auth/navigation checks; Learn does not vendor that source or require an App
+checkout in CI. When capture is implemented, replace the absence assertions with behavior tests.
+
+Acceptance must additionally cover Website/Learn ordering, repeated/competing tabs, original expiry,
+missing or late SDK, known IDs and overflow, unsupported versions, malformed/oversize values,
+both observation timestamp boundaries, App URL safety, standalone-page coverage, and rollback.
+Run the owning production build, `scripts/run-post-build-gates.mjs`, and existing redirect,
+indexability, and rendered-site checks; never widen their baselines to make attribution pass.
diff --git a/src/theme/Root/firstTouchBaseline.test.js b/src/theme/Root/firstTouchBaseline.test.js
new file mode 100644
index 0000000000..6fb618b0ff
--- /dev/null
+++ b/src/theme/Root/firstTouchBaseline.test.js
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render } from '@testing-library/react';
+
+import { __setMockLocation, __resetMockLocation } from '@docusaurus/router';
+import Root from './index';
+
+const websiteCookie = `nd_first_touch=${encodeURIComponent(JSON.stringify({
+ landing_page: '/website-first/',
+ referrer: 'https://search.example/',
+ utm_source: 'search',
+ ts: '2026-09-01T00:00:00.000Z',
+}))}`;
+let jar;
+let writeCookie;
+
+function navigate(pathname, search = '') {
+ window.history.replaceState({}, '', `${pathname}${search}`);
+ __setMockLocation({ pathname, search, hash: '' });
+}
+
+beforeEach(() => {
+ jar = '';
+ vi.spyOn(document, 'cookie', 'get').mockImplementation(() => jar);
+ writeCookie = vi.spyOn(document, 'cookie', 'set').mockImplementation((value) => {
+ jar = value.split(';')[0];
+ });
+ vi.spyOn(document, 'referrer', 'get').mockReturnValue('https://search.example/');
+});
+
+afterEach(() => {
+ __resetMockLocation();
+ window.history.replaceState({}, '', '/');
+ vi.restoreAllMocks();
+});
+
+describe('Learn Root capture baseline before a Learn producer exists', () => {
+ it('renders a direct campaign entry without writing a first-touch cookie', () => {
+ navigate('/docs/netdata-agent', '?utm_source=search&utm_medium=organic');
+ const page = render(Documentation
);
+
+ expect(page.getByText('Documentation')).toBeInTheDocument();
+ expect(document.cookie).toBe('');
+ expect(writeCookie).not.toHaveBeenCalled();
+ });
+
+ it('renders a SPA transition without recording either Learn route', () => {
+ navigate('/docs/netdata-agent', '?utm_source=search');
+ const page = render(First documentation page
);
+
+ navigate('/docs/netdata-agent/installation', '?utm_source=learn');
+ page.rerender(Installation
);
+
+ expect(page.getByText('Installation')).toBeInTheDocument();
+ expect(document.cookie).toBe('');
+ expect(writeCookie).not.toHaveBeenCalled();
+ });
+
+ it('leaves an existing Website cookie byte-identical on load and SPA navigation', () => {
+ jar = websiteCookie;
+ navigate('/docs/netdata-agent');
+ const page = render(Documentation
);
+
+ navigate('/docs/ask-nedi', '?utm_source=learn');
+ page.rerender(Ask Nedi
);
+
+ expect(document.cookie).toBe(websiteCookie);
+ expect(writeCookie).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/first_touch_producer_baseline.test.mjs b/tests/first_touch_producer_baseline.test.mjs
new file mode 100644
index 0000000000..85d85e4e62
--- /dev/null
+++ b/tests/first_touch_producer_baseline.test.mjs
@@ -0,0 +1,86 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { test } from 'node:test';
+import vm from 'node:vm';
+
+const require = createRequire(import.meta.url);
+const config = require('../docusaurus.config.js');
+const posthogPlugin = require('posthog-docusaurus');
+
+test('Learn navbar carries internal campaign tags without a first-touch envelope', () => {
+ const appLinks = config.themeConfig.navbar.items.filter((item) =>
+ (item.href || item.to || '').startsWith('https://app.netdata.cloud'),
+ );
+ assert.equal(appLinks.length, 2);
+ for (const item of appLinks) {
+ const url = new URL(item.href || item.to);
+ assert.equal(url.searchParams.get('utm_source'), 'learn');
+ assert.equal(url.searchParams.has('nd_ft'), false);
+ assert.deepEqual([...url.searchParams.keys()].sort(), ['utm_content', 'utm_source']);
+ }
+});
+
+test('the installed PostHog plugin generates an asynchronous stub without a synchronous identity reader', () => {
+ assert.equal(require('posthog-docusaurus/package.json').version, '2.0.5');
+ const options = config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === 'posthog-docusaurus')[1];
+ const plugin = posthogPlugin({}, { ...options, enableInDevelopment: true });
+ const script = plugin.injectHtmlTags().headTags.find((tag) => tag.tagName === 'script').innerHTML;
+ const inserted = [];
+ const context = {
+ document: {
+ createElement: () => ({}),
+ getElementsByTagName: () => [{ parentNode: { insertBefore: (element) => inserted.push(element) } }],
+ },
+ };
+ context.window = context;
+ vm.runInNewContext(script, context);
+
+ assert.equal(inserted.length, 1);
+ assert.equal(inserted[0].src, 'https://app.posthog.com/static/array.js');
+ assert.equal(inserted[0].async, true);
+ assert.equal(context.posthog._i[0][0], options.apiKey);
+ assert.equal(context.posthog.get_distinct_id, undefined);
+ assert.equal(typeof context.posthog.capture, 'function');
+ assert.equal(context.posthog.has_opted_out_capturing(), undefined);
+});
+
+test('the installed PostHog route module captures pageviews without producing first-touch cookies', () => {
+ const plugin = posthogPlugin({}, { apiKey: 'phc_synthetic', enableInDevelopment: true });
+ const filename = require.resolve(plugin.getClientModules()[0]);
+ const module = readFileSync(filename, 'utf8')
+ .replace("import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';", '')
+ .replace('export default', 'globalThis.lifecycle =');
+ const events = [];
+ const writes = [];
+ const document = {};
+ Object.defineProperty(document, 'cookie', {
+ get: () => '',
+ set: (value) => writes.push(value),
+ });
+ const context = {
+ ExecutionEnvironment: { canUseDOM: true },
+ document,
+ window: { document, posthog: { capture: (event) => events.push(event) } },
+ };
+ vm.runInNewContext(module, context, { filename });
+ context.lifecycle.onRouteUpdate({ location: { pathname: '/docs/first' }, previousLocation: null });
+ context.lifecycle.onRouteUpdate({
+ location: { pathname: '/docs/second' }, previousLocation: { pathname: '/docs/first' },
+ });
+ context.lifecycle.onRouteUpdate({
+ location: { pathname: '/docs/second', hash: '#heading' }, previousLocation: { pathname: '/docs/second' },
+ });
+
+ assert.deepEqual(events, ['$pageview', '$pageview']);
+ assert.deepEqual(writes, []);
+});
+
+test('the installed plugin removes a loaded callback from configuration when serializing it', () => {
+ const plugin = posthogPlugin({}, {
+ apiKey: 'phc_synthetic', enableInDevelopment: true, loaded: function captureOriginIdentity() {},
+ });
+ const script = plugin.injectHtmlTags().headTags.find((tag) => tag.tagName === 'script').innerHTML;
+ assert.equal(script.includes('captureOriginIdentity'), false);
+ assert.equal(script.includes('"loaded"'), false);
+});