@dreamdata_io/verify-tracking 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +756 -0
  3. package/dist/ai/checker.js +222 -0
  4. package/dist/ai/checker.js.map +1 -0
  5. package/dist/ai/checklist.js +190 -0
  6. package/dist/ai/checklist.js.map +1 -0
  7. package/dist/ai/cli-presets.js +27 -0
  8. package/dist/ai/cli-presets.js.map +1 -0
  9. package/dist/ai/evidence-view.js +143 -0
  10. package/dist/ai/evidence-view.js.map +1 -0
  11. package/dist/ai/local-cli.js +58 -0
  12. package/dist/ai/local-cli.js.map +1 -0
  13. package/dist/ai/openai-compatible.js +78 -0
  14. package/dist/ai/openai-compatible.js.map +1 -0
  15. package/dist/ai/response-parser.js +105 -0
  16. package/dist/ai/response-parser.js.map +1 -0
  17. package/dist/ai/types.js +2 -0
  18. package/dist/ai/types.js.map +1 -0
  19. package/dist/analysis/consent-mode.js +225 -0
  20. package/dist/analysis/consent-mode.js.map +1 -0
  21. package/dist/analysis/deployment.js +2658 -0
  22. package/dist/analysis/deployment.js.map +1 -0
  23. package/dist/analysis/diagnosis/copy.en.js +303 -0
  24. package/dist/analysis/diagnosis/copy.en.js.map +1 -0
  25. package/dist/analysis/diagnosis/diagnose.js +172 -0
  26. package/dist/analysis/diagnosis/diagnose.js.map +1 -0
  27. package/dist/analysis/diagnosis/docs.js +118 -0
  28. package/dist/analysis/diagnosis/docs.js.map +1 -0
  29. package/dist/analysis/diagnosis/index.js +5 -0
  30. package/dist/analysis/diagnosis/index.js.map +1 -0
  31. package/dist/analysis/diagnosis/rules.js +656 -0
  32. package/dist/analysis/diagnosis/rules.js.map +1 -0
  33. package/dist/analysis/diagnosis/types.js +2 -0
  34. package/dist/analysis/diagnosis/types.js.map +1 -0
  35. package/dist/cli.js +344 -0
  36. package/dist/cli.js.map +1 -0
  37. package/dist/config/env.js +100 -0
  38. package/dist/config/env.js.map +1 -0
  39. package/dist/config/preflight.js +167 -0
  40. package/dist/config/preflight.js.map +1 -0
  41. package/dist/config/user-config.js +79 -0
  42. package/dist/config/user-config.js.map +1 -0
  43. package/dist/evidence/collect.js +527 -0
  44. package/dist/evidence/collect.js.map +1 -0
  45. package/dist/evidence/consent-accept.js +377 -0
  46. package/dist/evidence/consent-accept.js.map +1 -0
  47. package/dist/evidence/consent.js +340 -0
  48. package/dist/evidence/consent.js.map +1 -0
  49. package/dist/evidence/constants.js +615 -0
  50. package/dist/evidence/constants.js.map +1 -0
  51. package/dist/evidence/globals.js +132 -0
  52. package/dist/evidence/globals.js.map +1 -0
  53. package/dist/evidence/gtm-container.js +432 -0
  54. package/dist/evidence/gtm-container.js.map +1 -0
  55. package/dist/evidence/heuristic-banner.js +107 -0
  56. package/dist/evidence/heuristic-banner.js.map +1 -0
  57. package/dist/evidence/http-probe.js +139 -0
  58. package/dist/evidence/http-probe.js.map +1 -0
  59. package/dist/evidence/init-script.js +112 -0
  60. package/dist/evidence/init-script.js.map +1 -0
  61. package/dist/evidence/network.js +365 -0
  62. package/dist/evidence/network.js.map +1 -0
  63. package/dist/evidence/preference-center.js +368 -0
  64. package/dist/evidence/preference-center.js.map +1 -0
  65. package/dist/evidence/script.js +425 -0
  66. package/dist/evidence/script.js.map +1 -0
  67. package/dist/evidence/second-page.js +332 -0
  68. package/dist/evidence/second-page.js.map +1 -0
  69. package/dist/evidence/selector-probe.js +53 -0
  70. package/dist/evidence/selector-probe.js.map +1 -0
  71. package/dist/evidence/stealth.js +21 -0
  72. package/dist/evidence/stealth.js.map +1 -0
  73. package/dist/evidence/types.js +14 -0
  74. package/dist/evidence/types.js.map +1 -0
  75. package/dist/interactive/prompt.js +31 -0
  76. package/dist/interactive/prompt.js.map +1 -0
  77. package/dist/mcp/server.js +308 -0
  78. package/dist/mcp/server.js.map +1 -0
  79. package/dist/mcp/summary.js +41 -0
  80. package/dist/mcp/summary.js.map +1 -0
  81. package/dist/report/context.js +184 -0
  82. package/dist/report/context.js.map +1 -0
  83. package/dist/report/projection.js +125 -0
  84. package/dist/report/projection.js.map +1 -0
  85. package/dist/report/quote-check.js +90 -0
  86. package/dist/report/quote-check.js.map +1 -0
  87. package/dist/report/render.js +178 -0
  88. package/dist/report/render.js.map +1 -0
  89. package/dist/report/serializers.js +124 -0
  90. package/dist/report/serializers.js.map +1 -0
  91. package/dist/report/store.js +139 -0
  92. package/dist/report/store.js.map +1 -0
  93. package/dist/run/verify.js +77 -0
  94. package/dist/run/verify.js.map +1 -0
  95. package/dist/utils/duration.js +10 -0
  96. package/dist/utils/duration.js.map +1 -0
  97. package/dist/utils/errors.js +17 -0
  98. package/dist/utils/errors.js.map +1 -0
  99. package/dist/utils/logger.js +59 -0
  100. package/dist/utils/logger.js.map +1 -0
  101. package/dist/utils/runTimingsLog.js +27 -0
  102. package/dist/utils/runTimingsLog.js.map +1 -0
  103. package/dist/utils/slug.js +27 -0
  104. package/dist/utils/slug.js.map +1 -0
  105. package/dist/version.js +10 -0
  106. package/dist/version.js.map +1 -0
  107. package/package.json +57 -0
  108. package/scripts/install.sh +314 -0
package/README.md ADDED
@@ -0,0 +1,756 @@
1
+ # @dreamdata_io/verify-tracking
2
+
3
+ Scrapes a live website with Playwright and asks an AI model whether the
4
+ Dreamdata Analytics Script is correctly installed — script presence,
5
+ `window.dreamdata`, cookies, Google Consent Mode v2, and dataLayer wiring.
6
+
7
+ ## Install
8
+
9
+ Two channels, same package. Pick by what is already on the machine.
10
+
11
+ ### On a machine with nothing on it (recommended)
12
+
13
+ ```bash
14
+ curl -fsSL https://unpkg.com/@dreamdata_io/verify-tracking/scripts/install.sh | sh
15
+ ```
16
+
17
+ Paste, answer the sign-in, done. The installer fetches everything the run
18
+ needs — a pinned Node 24 if the machine has none, the package, the Claude Code
19
+ CLI, and Chromium if there is no Google Chrome to drive — then walks you
20
+ through the one-time Claude sign-in on the same terminal. **No API key, no
21
+ config file, no Node or npm required beforehand.**
22
+
23
+ Everything lands in two places, and nothing else is touched:
24
+
25
+ | Path | Holds |
26
+ |---|---|
27
+ | `~/.local/share/verify-tracking/` | the Node runtime, both packages, Chromium |
28
+ | `~/.local/bin/verify-tracking` | the command (plus `verify-tracking-mcp`) |
29
+
30
+ No `sudo`, no global npm prefix, and no system Node touched — an already
31
+ installed Node 20+ or `claude` is reused rather than duplicated. Re-running the
32
+ installer upgrades in place; `rm -rf` on those paths is a complete uninstall.
33
+ Anything already provisioned is skipped, so a re-run after a failure is cheap.
34
+
35
+ **The sign-in is the one step that cannot be unattended** — it is an OAuth flow
36
+ through a browser, so a human has to finish it. The installer opens it for you
37
+ rather than leaving you to discover the requirement on a failed first run. In a
38
+ non-interactive shell (CI, a provisioning script) it installs everything and
39
+ prints the one command left to run:
40
+
41
+ ```bash
42
+ claude auth login
43
+ ```
44
+
45
+ macOS and Linux, arm64 and x64. On Windows, install Node 20+ and use `npx`
46
+ below.
47
+
48
+ ### On a developer machine
49
+
50
+ Already have Node 20+ and a signed-in `claude`? Skip the installer:
51
+
52
+ ```bash
53
+ npx -y @dreamdata_io/verify-tracking@latest --url https://example.com --quick --no-save-report
54
+ ```
55
+
56
+ Three things are needed either way, and the installer is simply what supplies
57
+ them. Each is checked at startup, so a missing one is reported in under a
58
+ second rather than after the browser work:
59
+
60
+ 1. **Node 20+.**
61
+ 2. **The `claude` CLI, signed in.** The default backend shells out to a local
62
+ [Claude Code](https://claude.com/claude-code) CLI, so there is **no API key**
63
+ to distribute, rotate, or leak. Installed-but-logged-out is its own check
64
+ with its own fix.
65
+ 3. **A browser.** ~150MB of Chromium does not belong in an npm tarball, and
66
+ does not need to: the collector drives an installed Google Chrome first and
67
+ only falls back to Playwright's own Chromium, so a machine with Chrome on it
68
+ needs nothing extra.
69
+
70
+ With `claude` signed in there is nothing to configure: no config file, no flags.
71
+
72
+ ### Using a hosted endpoint instead
73
+
74
+ Put a key in `~/.config/verify-tracking/config.env` — a key is what selects
75
+ that backend, so setting one is all it takes:
76
+
77
+ ```
78
+ AI_API_KEY=...
79
+ ```
80
+
81
+ That file exists because a globally installed CLI has no project `.env` to
82
+ read. It is written once and is independent of the directory you run from. It
83
+ only fills in what is unset, so flags and real environment variables still win
84
+ over it. `VERIFY_TRACKING_OUTPUT_DIR` belongs here too if the default
85
+ `verification/` tree should not land in whatever directory you happen to be in
86
+ — see [Usage](#usage).
87
+
88
+ A run is measured in minutes, not seconds: a single-phase `--quick` check took
89
+ ~2m45s, almost all of it waiting on the model. All three consent states take
90
+ longer.
91
+
92
+ ## Setup for development
93
+
94
+ ```bash
95
+ bun install
96
+ bunx playwright install chromium
97
+ cp .env.example .env # fill in AI_API_KEY, or point AI_BASE_URL at a local model server
98
+ ```
99
+
100
+ Bun is the development runtime; the published package is plain compiled JS and
101
+ runs under Node (`engines.node >= 20`).
102
+
103
+ ### Cutting a release
104
+
105
+ `.github/workflows/release.yml` publishes on a `v*.*.*` tag: it checks the tag
106
+ against `package.json`, runs the typecheck and tests, and `prepublishOnly`
107
+ rebuilds `dist/` so the tarball is never a stale checkout. Keep
108
+ `src/version.ts` in step with `package.json` — the tag check does not see it.
109
+
110
+ Publishing by hand does the same job:
111
+
112
+ ```bash
113
+ npm publish --access public
114
+ ```
115
+
116
+ Pick one per release. A manual publish followed by a tag push makes the
117
+ workflow fail on a version already on the registry.
118
+
119
+ ## Usage
120
+
121
+ One run, two commands:
122
+
123
+ ```bash
124
+ bun run src/cli.ts --slug paytronix_com --url https://www.paytronix.com/
125
+ bun run verification/review/build.ts
126
+ ```
127
+
128
+ The first walks the site and **writes the report itself** — no output flag
129
+ needed. It writes a pair, and prints both paths when the run ends:
130
+
131
+ | File | What it holds |
132
+ | --- | --- |
133
+ | `verification/reports/<slug>_report.json` | The report. Every judgement, and the evidence behind it with the raw values that only fed those judgements taken back out. ~35KB — a size you can open, diff, or hand to a model. |
134
+ | `verification/evidence/<slug>_evidence.json` | The same run, nothing trimmed: full request bodies, cookie values, the model's raw reply. Written for the archive, read when a judgement needs auditing. |
135
+
136
+ So the command above writes `paytronix_com_report.json` and
137
+ `paytronix_com_evidence.json`. Both come from one in-memory collection in one
138
+ write step, so the pair is always the same run — re-running a live site never
139
+ reproduces its evidence exactly, which is why the untrimmed copy is kept rather
140
+ than re-derived on demand. `--out <path>` writes the report somewhere else and
141
+ puts its companion beside it as `<path>.evidence.json`; `--no-save-report`
142
+ skips both.
143
+
144
+ Both paths are relative to the working directory, which is right inside this
145
+ repo — the review dashboard reads exactly those two directories — and unwelcome
146
+ when the CLI is installed globally and run from somewhere arbitrary. Set
147
+ `VERIFY_TRACKING_OUTPUT_DIR` (a flag-free setting, so `~/.config/verify-tracking/config.env`
148
+ is its natural home) to root them somewhere else; the run prints absolute paths
149
+ when it ends either way.
150
+
151
+ What the report leaves out, and why it is safe to leave out, is documented in
152
+ `src/report/projection.ts`: every check and verdict is already resolved against
153
+ the full evidence before serialization, so nothing the report drops could have
154
+ changed one.
155
+
156
+ The second regenerates the review dashboard's data from whatever is currently
157
+ in `verification/reports/` — that directory decides which accounts appear, with
158
+ `verification/evidence/` read on top of it for the raw values. It has to be
159
+ re-run after every `cli.ts` run
160
+ for `verification/review/index.html` to show the new report — there is no build
161
+ step for the page itself; open the HTML file directly in a browser afterwards.
162
+
163
+ That single `cli.ts` run already walks **all three consent states** — clean,
164
+ grant, refused — each in its own fresh browser context, and reports them side by
165
+ side. There are no consent flags to pass: `--phase clean,grant` narrows the set
166
+ if you want fewer, and `--quick` is shorthand for `--phase clean`. Omit `--url`
167
+ to be prompted interactively. See `bun run src/cli.ts --help` for all flags.
168
+
169
+ `--slug` names the account the run belongs to. It sets the report filename and
170
+ is recorded inside the file, so a run against a host that is not the account's
171
+ own domain still files under the account:
172
+
173
+ ```bash
174
+ bun run src/cli.ts --slug tungstenautomation_com --url https://sign-in.printix.net/
175
+ bun run verification/review/build.ts
176
+ ```
177
+
178
+ Without it the slug is derived from the URL's hostname — which would have filed
179
+ that run as `printix_net` and lost the account. The review index flags any report
180
+ whose filename and recorded slug disagree.
181
+
182
+ See [Verification review index](#verification-review-index) below for what the
183
+ dashboard shows and how it's structured.
184
+
185
+ ### AI backends
186
+
187
+ - `--backend local-cli` — shells out to an installed AI CLI. `--cli-preset claude` is the default and the
188
+ only verified one; for anything else pass `--cli-cmd` with a command template using `{model}`/`{prompt}`
189
+ placeholders.
190
+ - `--backend openai` — any OpenAI-compatible chat completions endpoint. Works unchanged for OpenRouter
191
+ (`--base-url https://openrouter.ai/api/v1`), a local model server (e.g. a local vLLM/Ollama/LM Studio
192
+ endpoint), or any other compatible provider — just change `--base-url`/`--model`.
193
+
194
+ **An API key picks the backend.** With one set — flag, environment, or config
195
+ file — the hosted endpoint is used; with none, the local CLI is, since it is
196
+ the only one that can answer without credentials. `--backend` overrides the
197
+ choice either way. An unconfigured run used to select the endpoint and die on a
198
+ 401 naming nothing the operator could fix, which is the wrong default for a
199
+ tool whose intended setup carries no key at all.
200
+
201
+ The default model follows the backend, because the two name models
202
+ differently: `anthropic/claude-sonnet-4.5` for the endpoint, `sonnet` for the
203
+ claude CLI. Do not cross them — `claude --model anthropic/claude-sonnet-4.5`
204
+ is refused **and still exits 0**, so the refusal used to be read back as the
205
+ model's answer. `local-cli` now rejects a zero-exit reply that carries no JSON
206
+ rather than reporting a verdict it could not parse.
207
+
208
+ ### Running against a local model
209
+
210
+ The `openai` backend is the local path too — a local server is just another
211
+ base URL:
212
+
213
+ ```bash
214
+ bun run src/cli.ts --slug clio_com --url https://www.clio.com/ \
215
+ --base-url http://localhost:11434/v1 --api-key ollama --model dd-verify
216
+ ```
217
+
218
+ **Build the model tag first; do not point `--model` at a stock Ollama tag.**
219
+ Ollama's default context is 4096 tokens; a three-phase run's prompt measures
220
+ around 16k — a ~6k system prompt (the checklist and shape guidance) plus the
221
+ evidence view — so a stock tag silently truncates the payload and then scores
222
+ checklist items against evidence it was never shown, a wrong verdict that
223
+ reports as a clean run. `replay.ts` prints the measured count for the run in
224
+ front of you. The `Modelfile` at the repo root pins a context window that fits:
225
+
226
+ ```bash
227
+ ollama create dd-verify -f Modelfile
228
+ ```
229
+
230
+ LM Studio works the same way at `http://localhost:1234/v1`.
231
+
232
+ **Check the model actually fits.** `ollama ps` reports a `PROCESSOR` column: a
233
+ model larger than free RAM runs partly on the CPU, and the difference is not
234
+ subtle. Measured on a 24GB machine, the same single-phase prompt took 148s on
235
+ `qwen2.5-coder:14b` (fully on GPU) and had not finished after ten minutes on
236
+ `qwen2.5-coder:32b` (28GB required, 38% on CPU). Waiting for the latter is a
237
+ choice you can make — `--ai-timeout` exists for it, and defaults to 30 minutes
238
+ — but a batch of accounts is where that cost lands.
239
+
240
+ ### Replaying a saved run against another backend
241
+
242
+ Comparing two backends by re-running `cli.ts` against a live site compares two
243
+ different observations: the site changed, the banner behaved differently, a bot
244
+ wall appeared. `verification/replay.ts` removes that variable — it rebuilds
245
+ `MultiPhaseEvidence` from a saved report, re-runs the deterministic
246
+ `analyzeDeployment` over it, and sends that same fixed evidence to whichever
247
+ backend you name:
248
+
249
+ ```bash
250
+ bun run verification/replay.ts --slug clio_com \
251
+ --base-url http://localhost:11434/v1 --model dd-verify --compare
252
+ ```
253
+
254
+ `--compare` diffs the result against the verdicts already in that report and
255
+ exits non-zero on a regression. What it checks, in the order that matters:
256
+
257
+ 1. **`overallPass` did not flip** — that is the exit code, and it must not
258
+ depend on which model was asked.
259
+ 2. **Every checklist id is present exactly once**, with no invented ids.
260
+ 3. **Each `passed` matches.** A `true` ↔ `false` flip fails the run;
261
+ `inconclusive` ↔ `false` is tabled for a human but tolerated.
262
+ 4. **`evidenceQuote` names a field that actually exists** in the evidence or the
263
+ analysis. Inventing a field path is the failure mode small models reach for,
264
+ and `checker.ts`'s prompt forbids it explicitly.
265
+ 5. **`remediationAdvice` does not tell an installed customer to install the
266
+ script** when `deployment.shape` already confirms an install.
267
+
268
+ The replayed run is written to `verification/reports/local_ai/` — beside the
269
+ baseline it read, never over it (`--out-dir` moves it, `--no-save-report` skips
270
+ it). To look at those runs in the dashboard:
271
+
272
+ ```bash
273
+ bun run verification/review/build.ts --reports-dir verification/reports/local_ai --out verification/review/local-data.js
274
+ ```
275
+
276
+ then open `verification/review/index.html?data=local-data.js` — the same page,
277
+ which reads its dataset from that query parameter and labels its own header
278
+ with the reports directory it loaded, so a replayed run is never mistaken for a
279
+ ground-truth one.
280
+
281
+ ## The three consent states
282
+
283
+ Every consent verdict is a **comparison**, not a property of one page load: a
284
+ bundle that is correctly gated and one that is broken both look like silence. So
285
+ each run observes three states, each in its own fresh browser context (the
286
+ isolation matters — a grant leaves a cookie that would make the next state replay
287
+ the granted page):
288
+
289
+ | Phase | What it does |
290
+ | --- | --- |
291
+ | `clean` | Loads the page and leaves the banner alone |
292
+ | `grant` | Drives the banner's accept control, waits for it to persist, reloads |
293
+ | `reject` | Drives the banner's reject control, waits for it to persist, reloads |
294
+
295
+ `--phase clean,grant` runs a subset; `--quick` is shorthand for `--phase clean`.
296
+
297
+ ## The second page
298
+
299
+ Each phase then follows one internal link, because a first page view is the one
300
+ page view a broken install still tends to get right. Two failures live entirely
301
+ in the gap between page one and page two:
302
+
303
+ - A consent-gated bundle injected **once**, on the load that followed the grant,
304
+ and never again. The visitor accepted, got identified tracking for exactly one
305
+ page, and was quietly handed back to the cookieless fallback after that — so
306
+ their session arrives split in two, half identified and half anonymous.
307
+ - A **single-page app** whose router swaps the route without a document load. No
308
+ script re-runs, so a bundle that only tracks on load never sends another page
309
+ event no matter how many pages the visitor reads.
310
+
311
+ Both look perfect in every first-page field, which is why the second page's
312
+ evidence is kept separate (`phases[].secondPage`) rather than folded into the
313
+ phase's own counts.
314
+
315
+ The link is picked in the page and clicked the way a visitor would click it —
316
+ never with `page.goto`, which forces a document load and would hide the SPA case
317
+ outright. `secondPage.kind` records what the site did with it: `hard` (a new
318
+ document) or `soft` (the router handled it in the same document, with the
319
+ `pushState` to prove it). Legal, auth and asset URLs are skipped
320
+ (`SECOND_PAGE_PATH_DENYLIST`) since a missing request there says nothing about the
321
+ install, and the click is dispatched on the element so an unanswered consent
322
+ banner cannot swallow it.
323
+
324
+ When the walk is handled by the router rather than by a document load, the
325
+ phase is left with **no unambiguous page view at all**: its entry page was
326
+ loaded twice around the consent answer, so two page events there can be the
327
+ consent upgrade rather than two loaders, and the page it routed to never
328
+ re-ran a script. So a soft walk is followed by one plain document load of the
329
+ page it landed on (`secondPage.hardLoad`) — the same navigation a visitor
330
+ makes when they open that page directly or refresh it.
331
+
332
+ That load is what `page-view-counted-once` can actually judge. meteomatics.com
333
+ is the case it was added for: every internal link routes in-document, and a
334
+ plain load of the routed-to page sends **both** bundles' page events — 6 of 8
335
+ warm-cache loads, 20–70ms apart, on a settled consent decision with no answer
336
+ anywhere near it. Nothing else the run observed could say so.
337
+
338
+ It costs one reload and one flush wait per phase, and only in phases whose
339
+ walk was soft; a walk that already landed a document load records
340
+ `hardLoad: null`.
341
+
342
+ `--no-second-page` skips the walk. Then the three checks below read
343
+ `inconclusive`, and the run sees one page view per phase again.
344
+
345
+ The comparisons are then made in code, not by the model:
346
+
347
+ | Check | Expectation |
348
+ | --- | --- |
349
+ | `clean-before-consent` | No normal request and no identity cookie while the banner is unanswered |
350
+ | `grant-enables-normal` | Accepting must actually start identified tracking |
351
+ | `reject-blocks-normal` | Refusing must actually stop it |
352
+ | `cookieless-covers-unconsented` | Where nothing else tracks, the cookieless bundle does |
353
+ | `grant-sets-identity-cookies` | A running normal bundle stores an anonymous id |
354
+ | `reject-writes-no-identity` | A refusal leaves no cookie or localStorage entry behind |
355
+ | `consent-mode-signal` | `analytics_storage` follows the choice, with source `update` |
356
+ | `second-page-keeps-tracking` | After a document load onto another page, whatever was tracking still is |
357
+ | `soft-nav-keeps-tracking` | After an in-app route change, a page event still fires |
358
+ | `second-page-honours-consent` | A navigation does not turn identified tracking on by itself |
359
+ | `page-view-counted-once` | One page view produces one `page` event, not two (the banner-answering entry page is exempt: it loaded twice) |
360
+
361
+ The two continuity checks split one question by where its answer lives: a
362
+ document load that stops tracking is a missing bundle or tag-manager trigger on
363
+ non-entry pages, while a route change that stops tracking is a bundle that is
364
+ right there and a router that never calls `page()`. Every phase falls into
365
+ exactly one of them, so nothing is counted twice.
366
+
367
+ A failed check sinks the run regardless of what the model says. A check whose
368
+ phase was not run is `inconclusive`, never a failure.
369
+
370
+ ## Deployment shapes
371
+
372
+ Dreamdata installs legitimately differ, and all of these are healthy. The shape is
373
+ computed from all phases at once — in code, not inferred by the model — and handed
374
+ to the checker as a fact, so a valid shape is not reported as a defect:
375
+
376
+ | Shape | Looks like | Cookies |
377
+ | --- | --- | --- |
378
+ | `cookieless-by-design` | Only a cookieless bundle exists anywhere | none, by design |
379
+ | `consent-gated-dual` | Both bundles belong to the install; consent picks | `dd_*` once granted |
380
+ | `normal-only` | Only the standard bundle is installed | `dd_*` |
381
+ | `helper-only` / `nothing-installed` | No tracking bundle at all | — a real failure |
382
+ | `undetermined` | Would read as `nothing-installed`/`cookieless-by-design`, but a detected consent banner could not be driven | can't tell — needs a manual click-through |
383
+ | `third-party-segment-undetermined` | Would read as `nothing-installed`/`helper-only`, but a REAL Segment install (`cdn.segment.com` script and/or a request to `api.segment.io`) was found instead of a Dreamdata bundle | can't tell — Segment→Dreamdata forwarding is server-side and invisible to this harness; check Segment's destination config or Dreamdata's ingestion records directly |
384
+
385
+ `deployment.segmentCompat` is orthogonal: the install exposes `window.analytics`
386
+ and `ajs_*` storage instead of `window.dreamdata` and `dd_*`. Also valid.
387
+
388
+ ### Why "no normal bundle in the DOM" proves nothing
389
+
390
+ `cookieless-by-design` is the shape that most often gets asserted wrongly, because
391
+ a consent-gated or tag-manager-injected bundle is **absent from the DOM by
392
+ design** — exactly when the question matters. So the shape is only claimed when
393
+ none of these signals is found in **any** phase (`script.normalBundleSignals`
394
+ records whichever did):
395
+
396
+ | Signal | What it means |
397
+ | --- | --- |
398
+ | `request` | A normal-mode request was actually sent |
399
+ | `dom-script` | A normal bundle tag is in the DOM |
400
+ | `cmp-blocked-script` | The tag is there but neutralised (`type="text/plain"`, `optanon-category-*`, …) — installed, not absent |
401
+ | `tag-manager-container` | The page's published GTM container ships the tag, with the consent types it is gated on |
402
+ | `cookieless-loader-companion` | The cookieless loader checks for `#dreamdata-analytics`, i.e. it expects a normal bundle |
403
+ | `stub-queue` | `window.dreamdata` is the loader's stub array — calls queued for a bundle that never came |
404
+
405
+ The container scan is one HTTP fetch per container per run, cached across phases;
406
+ `--no-tag-manager-scan` skips it, at the cost of reading a GTM-only install as
407
+ absent.
408
+
409
+ Three consequences worth knowing:
410
+
411
+ - **Cookieless requests carry no `anonymousId`.** That is the point of the mode —
412
+ identity resolution happens server-side. Only `writeKey` is required.
413
+ - **`window.dreamdata` can be a stub array.** A loader snippet creates its global
414
+ as an array that queues calls until the bundle replaces it, so an array means
415
+ the bundle never arrived (`globals.dreamdataIsStubQueue`). Expected when it is
416
+ consent-gated; a real defect when it is not.
417
+ - **The two bundles are mutually exclusive, not ordered.** The cookieless snippet
418
+ stands down when it finds `#dreamdata-analytics` present and still
419
+ `type="text/javascript"` (a CMP that blocks the tag rewrites that type, which
420
+ is what lets cookieless take over), and the normal bundle `delete`s
421
+ `window['dreamdata-cl']` as soon as it initializes. So a missing cookieless
422
+ global in a granted phase is the handoff working, and both modes tracking *one
423
+ page view* is the finding — that page view counted twice, usually because the
424
+ cookieless snippet sits above the normal one. Scoped to a page view, not to a
425
+ phase: `page-view-counted-once` counts `page` requests per observed page view,
426
+ and it skips the entry page of a phase that answered the banner, because the
427
+ run loaded that URL twice — anonymous before the answer, identified after —
428
+ which is the consent upgrade rather than a duplicate loader. Which mode fired
429
+ first is a race with no correct answer, so `network.cookielessRequestSeenFirst`
430
+ is reported for humans but never scored.
431
+
432
+ ## Headless runs advertise themselves, and sites answer differently
433
+
434
+ Headless Chrome writes `HeadlessChrome/<version>` into its User-Agent — in the
435
+ request header and in `navigator.userAgent` alike — and `isbot`-style checks
436
+ match that substring. Sites do not merely *log* such a visitor as a bot; they
437
+ change what they render. meteomatics.com never mounts its consent banner at
438
+ all for a matching UA, so every phase found no CMP, no accept control and
439
+ nothing to drive, and all three consent states measured the same banner-free
440
+ page — a page no visitor is ever served.
441
+
442
+ So a headless run renames it back to `Chrome`, which is what the same binary
443
+ reports when it has a window, and does it at the browser-context level so the
444
+ request header changes too and not just the JS-visible copy a server-side
445
+ check never reads. Headed runs (`--headed`) are left alone.
446
+
447
+ Worth knowing when a run looks implausible: this is one specific tell, not a
448
+ stealth suite (see `stealth.ts` for the rest), and a site behind stronger bot
449
+ management can still serve automation a different page.
450
+
451
+ ## Consent-gated sites
452
+
453
+ A CMP running in automatic blocking mode (e.g. Cookiebot with
454
+ `data-blockingmode="auto"`) rewrites tracking tags to `type="text/plain"` before
455
+ the visitor answers the banner, so nothing loads and nothing is tracked. **That is
456
+ correct GDPR behaviour, not a broken install** — and the `clean` phase reports it
457
+ as such, while `grant` shows what the same page does once consent exists.
458
+
459
+ Consent state is reported with its provenance. `analytics_storage: denied` from
460
+ Google's own implicit default is *not* a user decision; check
461
+ `consent.analyticsStorageSource` before reading a denial as one.
462
+
463
+ Two failure modes worth naming, both of which need more than one state to see:
464
+
465
+ - **The CMP stores a choice Consent Mode never receives.** The banner records the
466
+ answer, `analytics_storage` never moves, and every gated tag stays frozen.
467
+ `consentAcceptance.consentSignalConfirmed` is false and `consent-mode-signal`
468
+ fails — distinct from a broken bundle, and fixed somewhere else entirely.
469
+ - **A tag manager holds the bundle back for its own reasons.** Consent is granted,
470
+ the Consent Mode signal lands, and the tag still never fires because its
471
+ trigger does not cover the page. `grant-enables-normal` fails and says so.
472
+
473
+ ## First-party tracking hosts
474
+
475
+ Dreamdata-owned hosts are detected automatically, matched on the request's
476
+ **hostname** so a third party's `?referer=dreamdata.io` query string cannot pass
477
+ as an install. A first-party install on `api.s.dreamdata.io` therefore needs no
478
+ configuration. Only a fully custom CNAME (`track.yourdomain.com`) has to be
479
+ named with `--tracking-host`.
480
+
481
+ Worth knowing that a first-party install spans **three distinct hosts**, and the
482
+ one that matters for requests is the delivery host — not the CDN in `_cdn`:
483
+
484
+ | Role | Example on dreamdata.io |
485
+ | --- | --- |
486
+ | Loader script | `evs.s.dreamdata.io/…/CxqWbtgYml.min.js` |
487
+ | Bundle CDN (`analytics._cdn`) | `evs.s.dreamdata.io` |
488
+ | **Event delivery** | **`api.s.dreamdata.io/v1/p`** |
489
+
490
+ ```bash
491
+ bun run src/cli.ts --url https://dreamdata.io \
492
+ --tracking-host api.s.dreamdata.io
493
+ ```
494
+
495
+ `--tracking-host` repeats and accepts comma-separated values; `DD_TRACKING_HOSTS`
496
+ sets it via env. The watched set is recorded in `network.trackingHosts`, so a run
497
+ that captured nothing shows what it was looking at.
498
+
499
+ Delivery paths are `/api/v1/<letter>` on the shared CDN and `/v1/<letter>` on
500
+ first-party hosts; both are recognised.
501
+
502
+ ## Manual verification
503
+
504
+ No fake fixture site is used. Sanity-check against a site you can inspect in
505
+ DevTools, and compare field by field:
506
+
507
+ | Evidence field | DevTools check |
508
+ | --- | --- |
509
+ | `script.present` / `writeKey` | `document.querySelectorAll('script[src*="dreamdata"], script[src*="drda"]')` |
510
+ | `network.requests` | Network tab filtered on the host in `network.trackingHosts` |
511
+ | `globals.*` | `window.dreamdata`, `window['dreamdata-cl']`, `window.analytics` |
512
+ | `cookies.cookieNames` | Application → Cookies, filter `dd_` (or `ajs_`) — the report lists which are set, `verification/evidence/` has their values |
513
+ | `consent.*` | `window.google_tag_data.ics.entries.analytics_storage` |
514
+ | `secondPage.*` | Click an internal link yourself and watch the Network tab; `secondPage.kind` should match whether the tab reloaded |
515
+
516
+ To diff raw values field by field, read the run's
517
+ `verification/evidence/<slug>_evidence.json` — that is the untrimmed copy, and
518
+ it is already on disk from the run you just did.
519
+
520
+ ## What the verdicts are, and are not
521
+
522
+ Evidence collection, the deployment shape and the cross-phase consent checks are
523
+ deterministic code. The **checklist verdicts are an LLM reading that evidence**,
524
+ so their wording varies between runs on the same page; the model is told the shape
525
+ and the resolved checks as facts rather than being asked to derive them, because a
526
+ single misread boolean used to turn every consent check into "not applicable" and
527
+ the report into a green PASS.
528
+
529
+ `overallPass` is not taken from the model either. It is derived from the per-item
530
+ verdicts — any `false` sinks the run, `not_applicable` and `inconclusive` do not,
531
+ and an all-inconclusive run is not a pass — and then a failed cross-phase check
532
+ sinks it regardless. The process exit code follows it.
533
+
534
+ Each cross-phase check also carries `phases`: the consent states its status
535
+ actually rests on, and for a **failure** only the states that actually failed. A
536
+ continuity check that spans three phases and broke in one says so in one field
537
+ rather than only in its prose, so anything drawing a per-state view — the review
538
+ index's matrix — can attribute the failure instead of painting all three red.
539
+
540
+ ## Driving the CLI from an MCP client
541
+
542
+ The CLI also runs as an [MCP](https://modelcontextprotocol.io) server, so an
543
+ agent can run a verification and then read the result:
544
+
545
+ ```bash
546
+ bun run src/mcp/server.ts
547
+ ```
548
+
549
+ | Tool | What it does |
550
+ | --- | --- |
551
+ | `verify_tracking` | Runs the full three-state check and writes `verification/reports/<slug>_report.json`. Returns a summary and that path — never the report itself, which runs 40–50KB. |
552
+ | `list_reports` | Every saved report, newest first, with its shape and verdict. |
553
+ | `read_report` | One report by slug, or one field of it (`deployment.checks`, `phases[0].script`). |
554
+ | `explain_verdict` | One checklist item, with the field its `evidenceQuote` names resolved — so a quote pointing at a field that does not exist shows up as `resolved: false` — plus the cross-phase consent checks. |
555
+
556
+ The backend is read from `.env` (or the environment), never from a tool
557
+ argument: a tool call is model-authored text, and a call that could name its
558
+ own `--base-url` could send a run's whole evidence payload to a host of the
559
+ model's choosing.
560
+
561
+ ### Pairing it with chrome-devtools-mcp
562
+
563
+ The reason to run this over MCP is having both servers in one agent —
564
+ [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp)
565
+ for looking, this one for measuring:
566
+
567
+ ```jsonc
568
+ {
569
+ "mcpServers": {
570
+ "verify-tracking": {
571
+ "command": "bun",
572
+ "args": ["run", "src/mcp/server.ts"],
573
+ "cwd": "/path/to/tracking-verification-cli",
574
+ // A full run is minutes, not seconds — see "One run is a long tool call" below.
575
+ "timeout": 900000
576
+ },
577
+ "chrome-devtools": { "command": "npx", "args": ["chrome-devtools-mcp@latest"] }
578
+ }
579
+ }
580
+ ```
581
+
582
+ Goose, Cline and mcphost all take this shape; only the file it goes in
583
+ differs. Ollama and LM Studio are model runtimes, not MCP clients — a local
584
+ model reaches these tools through a client like those, not on its own.
585
+
586
+ For Goose specifically, `verification/goose-config.example.yaml` is that block
587
+ already filled in, and `.goosehints` in the repo root is what the agent is told
588
+ about the two servers. That file is not optional decoration: without it a model
589
+ will re-check a consent verdict by driving one browser tab through accept and
590
+ then reject, which measures a page still carrying the previous answer's cookie.
591
+ Whatever client you use, give it those instructions in whatever form it reads.
592
+
593
+ Two things to know before pointing a local model at an agent loop:
594
+
595
+ - **Tool calling is required.** `ollama show <model>` must list `tools` under
596
+ Capabilities. `qwen2.5-coder:14b` does.
597
+ - **An agent loop spends context that single-shot scoring does not.** The
598
+ Modelfile pins `num_ctx 65536`, but qwen2.5-coder's trained context is 32768
599
+ — fine for one ~16k-token scoring prompt, less so for a conversation
600
+ accumulating tool results. This is why the tools return summaries and a field
601
+ path rather than reports: `read_report` without a `path` will spend 40-50KB of
602
+ that budget in one call.
603
+
604
+ A run then looks like: `verify_tracking` on the URL → read the failing item
605
+ with `explain_verdict` → `navigate_page` and `list_network_requests` in
606
+ DevTools to look at the actual request that item is about.
607
+
608
+ ### One run is a long tool call
609
+
610
+ `verify_tracking` takes minutes: three consent states at roughly 15–20s of
611
+ page loading each, and then a model reading a ~16k-token prompt — which on a
612
+ local 14B model measured 148s for a single-phase run, and grows with the
613
+ phases. Most clients time a tool call out well before that.
614
+
615
+ The server sends a progress notification every 10 seconds while a run is going,
616
+ naming the step it is on. That is enough for a client that resets its timeout
617
+ on progress — in the MCP TypeScript SDK that is `resetTimeoutOnProgress`, which
618
+ is **off** by default, so a client has to opt in.
619
+
620
+ **Claude Code's timeout is not one of them**: its per-server `timeout` (and the
621
+ `MCP_TOOL_TIMEOUT` environment variable it overrides) is a hard wall-clock
622
+ limit per tool call that progress notifications do not extend. Set it to cover
623
+ a whole run — the `"timeout": 900000` above is 15 minutes — rather than relying
624
+ on the heartbeat.
625
+
626
+ **Measure with this server; look with DevTools — not the other way round.** An
627
+ agent that "re-checks" a consent verdict by driving one browser tab through
628
+ accept and then reject is measuring a page that still carries the previous
629
+ answer's cookie. Every consent verdict here is a comparison between three
630
+ states collected in three *fresh* contexts (see
631
+ [The three consent states](#the-three-consent-states)); a shared tab cannot
632
+ reproduce it, and what it produces instead is a confident contradiction of a
633
+ correct report.
634
+
635
+ ## Verification review index
636
+
637
+ `verification/review/index.html` gives a browsable view of every report in
638
+ `verification/reports/`. One `<slug>_report.json` there is one account on the
639
+ page; nothing else is aggregated, so whatever `cli.ts` has written is exactly
640
+ what the dashboard shows.
641
+
642
+ It is built in three levels, so the first screen is scannable and everything
643
+ that needs reading is one click away:
644
+
645
+ | Level | Shows |
646
+ | --- | --- |
647
+ | **The list** | One line per account: slug, overall PASS/FAIL, the check tally (`✕` fail / `?` inconclusive / `✓` pass), and a 3×5 thumbnail of that account's matrix. |
648
+ | **The card** | The **matrix** — three consent states down the side, five signals (`script present`, `event on page load`, `event on page nav`, `identity storage`, `consent mode`) across the top, one verdict per cell — then every cross-phase check as a single line, worst first. |
649
+ | **The reason** | A cell opens the raw values it was computed from plus the check that judged it; a check line opens its own detail and evidence. *Everything measured* holds the complete per-phase evidence JSON, the deployment analysis and the model's raw response — `build.ts` reads those from `verification/evidence/` when the run archived them, so nothing is summarised away here even though the report file itself is trimmed. |
650
+
651
+ Cell colour is never re-derived in the page. Every consent comparison is already
652
+ resolved in `src/analysis/deployment.ts` and shipped as `deployment.checks`; a
653
+ cell takes the worst status of the checks that name its phase, and `script
654
+ present` is left uncoloured because a tag's presence is a fact about the install
655
+ (a consent-gated tag is absent by design exactly when the question matters), not
656
+ a verdict. A **dashed** cell is one whose colour is genuinely shared by more than
657
+ one consent state.
658
+
659
+ Rebuild its data after any sweep run:
660
+
661
+ ```bash
662
+ bun run verification/review/build.ts
663
+ ```
664
+
665
+ This writes `verification/review/data.js`, which `index.html` loads directly
666
+ via `<script src="data.js">` (avoiding `fetch()`/CORS restrictions when opened
667
+ via `file://`). Re-run it any time a report under `verification/reports/`
668
+ changes, then open `index.html` in a browser.
669
+
670
+ ## Known limitations
671
+
672
+ - Each phase covers two page views — the load, then one followed link — plus a
673
+ document load of the second page when the walk was soft. Form submissions are
674
+ still never driven, so the `event-behavior` item stays inconclusive on that
675
+ sub-point. Only one link is followed, and only from the entry page, so a site
676
+ that breaks on the third page or on one particular section will not be caught.
677
+ - A page view whose two loaders race is only sometimes doubled, so one
678
+ observation of a single page event does not clear a site. On meteomatics.com
679
+ the same URL sent one request on 5 of 6 cold-cache loads and two on 6 of 8
680
+ warm-cache ones — a tag manager injecting the normal bundle either side of
681
+ the moment the cookieless snippet looks for it. A run sees one load per
682
+ phase and can therefore miss it; a pass on this check is weaker evidence
683
+ than a failure.
684
+ - The entry page of a phase that answered the banner is never judged on
685
+ double-counting unless its page events name two different builds. Two loads
686
+ of one URL either side of a consent answer are two runs of one install, and
687
+ the ordering of the two bundles cannot separate that from a real duplicate:
688
+ on paytronix.com the normal event the accept click produced is stamped
689
+ mid-reload and so arrives *before* the reloaded page's cookieless one,
690
+ which looks exactly like the race but is not. The extra document load above
691
+ exists because inference on that page view does not work.
692
+ - The second-page probe picks the link itself. A site whose entry page has no
693
+ followable same-origin link, or whose link opens a modal instead of
694
+ navigating, records `urlChanged: false` and leaves the three continuity checks
695
+ inconclusive rather than failed.
696
+ - A known CMP is driven via its vendor API or a vendor-specific selector; add the
697
+ vendor to `CMP_VENDORS`/`DOM_ONLY_CMP_VENDORS` and the selector lists in
698
+ `consent-accept.ts` to cover a new one. An unrecognised CMP falls back to a
699
+ heuristic: a clickable control whose exact text matches a curated accept/reject
700
+ phrase, inside a container that both mentions cookies/consent/privacy and holds
701
+ the opposite-direction control too (`heuristic-banner.ts`) — this is what stops
702
+ an unrelated page button (e.g. a signup form's "Accept") from being mistaken for
703
+ a consent banner.
704
+ - A banner with no reject control anywhere is still *detected*: the heuristic
705
+ proves a container is a consent banner by finding a second consent control
706
+ next to the one it matched, and where there is no opposite-direction control
707
+ a cookie-explicit settings link plays that part
708
+ (`PREFERENCE_CENTER_COUNTERPART_PHRASES` — the vaguer "settings"/
709
+ "preferences" openers are excluded, since they sit in too many page footers
710
+ to prove anything). Requiring the opposite control outright made those
711
+ banners invisible, which in turn meant the preference-centre flow written to
712
+ answer them was never reached.
713
+ - Banners with no one-click answer in the direction we want — the common
714
+ "Accept all" + "Cookie settings" pair, with no reject button anywhere — are
715
+ answered through their preference centre instead (`preference-center.ts`):
716
+ open the panel, set every non-essential category toggle to the target state,
717
+ then click the panel's own save control. The save search deliberately excludes
718
+ accept-all/reject-all shortcuts, which would discard the toggles, and both the
719
+ open and save searches are scoped the same way the accept/reject heuristic is —
720
+ the settings link must sit in a cookie-context container that also holds a real
721
+ accept/reject control, and the save control must sit around the category
722
+ toggles themselves. Always-on essential categories (matched by
723
+ `ESSENTIAL_CATEGORY_PATTERN`, or simply disabled) are left alone. Add a vendor
724
+ to `PREFERENCE_CENTER_OPEN_SELECTORS`/`PREFERENCE_CENTER_SAVE_SELECTORS` to
725
+ drive its panel by element rather than by button text. The category-toggle
726
+ search is scoped more tightly than the banner search is: a container only
727
+ counts as the panel when it mentions cookies or consent — not merely
728
+ "privacy", which every marketing form's terms checkbox does — and is small
729
+ enough to be a panel rather than the page (`PREFERENCE_PANEL_*`). Without
730
+ both, an unrelated form's checkboxes join the toggle set, the panel resolves
731
+ to the whole document, and the save-button search is scoped to it.
732
+ - When none of the above finds or clicks a control, the
733
+ `grant`/`reject` drive is recorded as not-succeeded; if a CMP was detected but
734
+ every attempted drive failed, a would-be `nothing-installed`/`cookieless-by-design`
735
+ shape is downgraded to `undetermined` instead of asserted, and the affected
736
+ checks read `inconclusive` rather than failed — honest, but not a verdict.
737
+ - The GTM consent gate is read out of a minified container by looking backwards
738
+ from the bundle filename for the nearest `"consent":["list",…]`. It is reported
739
+ as a corroborating signal, never as the sole basis for a verdict.
740
+ - Debug mode (`dreamdata.debug(true)`) is never actually enabled.
741
+ - `console.errors`/`warnings` are filtered to messages mentioning "dreamdata", so
742
+ an empty list means no Dreamdata-specific error, not an error-free page.
743
+ - Anonymous-id stability is only partly observable: the id on the first page and
744
+ the one in the same phase's `secondPage` can be compared, but no check asserts
745
+ on it — the model reports it, and a drift there is worth a manual look. The
746
+ report itself lists only which entries were set, so read the values out of
747
+ `verification/evidence/<slug>_evidence.json`.
748
+ - A site that reassigns `window.dataLayer` after the hook installs is covered —
749
+ the accessor re-wraps whatever is assigned — but a snippet holding a direct
750
+ reference to the array from before the hook can push past the wrapper.
751
+ - A bespoke banner's decision is only seen to persist when it lands in a cookie
752
+ or localStorage entry that is either new or *named* like a consent record (a
753
+ vendor prefix, or a name containing "consent"/"cookie settings" and friends).
754
+ A banner that records its answer under an unrecognisable name it also wrote
755
+ on load reads as `not-persisted`, and the phase is reported as not driven
756
+ even though the click worked.