Observer — complete spec with reference implementation
Portable passive-capture observer for ecomm + adtech-heavy pages

Table of Contents

1. Preamble

This is the spec. Read top-to-bottom. Every code block carries a :tangle header — running org-babel-tangle from Emacs writes each block to the path in its header, creating a working scaffold.

The observer targets any ecomm or adtech-heavy page: Shopify checkouts, retail-media search pages, native ad grids, product listings that lazy-load into infinity, chumbox recommendation widgets, in-article sponsored slots. It is not specialised for wal.sh — the wal.sh tracker (src/wal_sh/site/tracker/ in the oracle repo) is one deployment of the same shape.

Numbered requirements R1-R74 are structural. New requirements require a spec bump; existing ones can be refined but not silently dropped.

2. 1. What this document specifies

A passive-capture observer: a browser-side module that (a) attaches DOM listeners for scroll, click, hover, form change, form input, form submit, page-lifecycle, IntersectionObserver impressions, and copy / selection events; (b) emits each event as a same-shape envelope over a same-origin pixel GET plus a configurable third-party fan-out; (c) degrades safely when the target CSP forbids the transport; (d) persists a per-browser uid across sessions in localStorage; (e) never captures field values, credentials, cookies, or storage contents.

The observer is portable — three injection surfaces (bookmarklet, WebExtension, MITM proxy addon) allow it to be dropped onto any page, including pages the operator does not control. It is configurable per route — a config file maps URL patterns to CSS selectors and handler bindings, so the operator can point the observer at a competitor's checkout flow and get impressions on every input[type=checkbox] without touching the page.

3. 2. Ecomm surface — what to instrument

Ecomm-heavy pages share a small number of critical surfaces. The observer's default config MUST recognise all of them so a generic install has non-trivial coverage before any per-site tuning.

3.1. 2.1 Product listing (PLP)

Grids or lists of product tiles. Trackable via [data-product-id], article[itemtype*=Product] (schema.org), or any element containing img[src*=product] as fallback.

R1. Impressions on a product tile MUST fire only after ≥50% viewport overlap for ≥1000ms. Separates "user saw it" from "it scrolled past".

R2. Click MUST classify as element.click with elKind = "product-tile" and carry productId if data-product-id is present.

3.2. 2.2 Product detail (PDP)

Trackable: main image, gallery thumbnails, variant selectors (size/color), quantity input, add-to-cart, buy-now, review summary, related-product tiles.

R3. Every select that changes an add-to-cart's variant MUST fire element.change with the old + new option label (label captured, value opaque per R29).

R4. The add-to-cart button MUST be identifiable by kind add-to-cart — via data-track-kind or the generic ecomm route (§4.2).

3.3. 2.3 Cart / mini-cart

Trackable: line items, quantity spinners, remove buttons, promo-code field, subtotal, taxes, ship-to selector, checkout CTA.

R5. Quantity changes MUST fire element.change with the delta (+1 / -1 / N), not the absolute quantity. Deltas recover to state; state without deltas doesn't.

R6. Remove button MUST fire element.click with a reason field if the page exposes one; otherwise null.

3.4. 2.4 Checkout flow

Trackable: address, city/state/zip, phone, email, shipping-method radios, payment-method radios, credit-card form (numbers NEVER captured per §19), promo code, order-summary line items, "place order" CTA.

R7. Every input[type=text|email|tel], textarea, and select inside a form[action] ending in /checkout, /order, /purchase, or /confirm MUST auto-instrument as element.input

  • element.change even without explicit config. Checkout

completion is critical; requiring per-field config is a footgun.

R8. Form abandon MUST be detectable: if element.input fires ≥1 time in a form followed by pagehide without form.submit, the element.dwell on the last-touched field carries abandon: true.

R9. input[type=password] MUST be excluded from ALL events. Hard rule in the config-load path (elevated to §19 boundary).

3.5. 2.5 Order confirmation

Trackable: order number, order total, line items, "download receipt" / "print" buttons, related-product tiles (upsell).

R10. pageview on /order/complete, /thank-you, or /confirmation MUST carry conversion: true and orderValue if extractable ([data-order-total] / schema.org [itemprop=totalPaymentDue] / regex over currency-formatted innerText).

4. 3. Adtech surface — what to instrument

4.1. 3.1 Sponsored slots (native, in-line)

Markers: [data-ad-slot], [class*=sponsored], a[href^=/go/], a[href^=/redirect], a[rel*=sponsored].

R11. Every sponsored unit MUST fire sponsored.impression (not element.impression) so the auction's inventory analysis can distinguish organic from paid.

R12. Every sponsored click MUST fire sponsored.click with the destination clickHref — retail-media click paths route through affiliate redirects; the observer sees the initial href.

4.2. 3.2 Recommendation widgets (chumbox)

Markers: [id^=taboola], .OUTBRAIN, .trc_related_container, [data-taboola-source], retailer-native equivalents.

R13. Chumbox tiles fire sponsored.impression AND sponsored.feed.page (page-index-scoped rollup) so pool depth is visible.

R14. Headline text NEVER captured verbatim. Instead: headline length in chars + word count + starts-with-numeric? boolean — classifies shape without leaking copy.

4.3. 3.3 Display ads (mrec, banner, sky)

IAB sizes: 300×250, 728×90, 160×600, 320×50. Usually iframed; §11 covers cross-origin observability.

R15. Display impressions require ≥50% pixel overlap for ≥1000ms plus iframe observability grant OR first-party origin. Otherwise sponsored.impression.opaque — slot exists, view unverifiable.

4.4. 3.4 Consent / privacy overlays

R16. On init! scan for [id*=consent], [class*=consent], [id*=cookie-banner], [aria-modal=true][id*=consent], .cookie-notice, .cc-banner. If present, emit consent.overlay.impression.

R17. Any click inside overlay → consent.decision with outcome enum: accept-all, reject-all, customise, dismiss. Classify by button text substring — Table §4.3.

R18. consent.abandon if pagehide within 1s of overlay impression with no decision.

5. 4. Configuration model

5.1. 4.1 Structure

Load via window.__wal_tracker.init(<edn-string-or-object>). EDN in CLJS source, JSON on the wire.

R19. Routes list, first-match-wins. Each route: :url-pattern, :name, :elements (list of :selector + :kind + optional :events), :events (subset of vocabulary), :sampling.

R20. Route resolution: iterate :routes in declaration order, first :url-pattern match on location.href wins. If none match, load :global only.

R21. Selectors resolve at init! AND on MutationObserver subtree changes. No polling.

R22. Same node matched by multiple selectors takes identity from FIRST match (declaration order beats DOM order).

R23. A selector match synthesises identity: elId = "<kind>:<idx>", elKind = <kind>, selector = <CSS> (clipped to 200 chars).

5.2. 4.2 Built-in generic configs

Two configs ship: ecomm-generic (§21.4) and adtech-generic (§21.5). Enough for first-pass observation of an unfamiliar site before per-site tuning.

R24. ecomm-generic matches form[action*=checkout|order|cart], input[type=email|tel|password] (password excluded from events), button[data-add-to-cart] / button[class*=add-to-cart], a[href*=/product/|/p/], [itemtype*=Product].

R25. adtech-generic matches [data-ad-slot], [data-taboola-source], iframe[id^=google_ads], [class*=sponsored|chumbox|advertorial], a[rel*=sponsored], a[href^=/go/|/redirect].

5.3. 4.3 Consent-decision classifier

Consent buttons vary; substring-match on lowercase button text:

Text contains Outcome
accept all, allow all, agree all, got it accept-all
reject all, decline all, necessary only reject-all
customise, customize, manage, preferences customise
close, ×, dismiss dismiss

R26. Non-matching → unknown; button text logged (clipped to 200) so classifier grows.

6. 5. Event vocabulary

Closed set. Adding requires spec bump + emitter.

6.1. 5.1 Every event

Event When Extra fields
pageview init, once per page-life; re-fired on SPA nav conversion, orderValue
page.dwell pagehide / hidden dwellMs, maxScrollPct
scroll.depth first cross of 25/50/75/100 depthPct
scroll.reverse after ≥50%, upward ≥200px fromPct, toPct
element.impression trackable ≥50% ≥1000ms element identity
element.dwell leaves viewport after impression + dwellMs, + abandon?
element.click click on trackable + modKeys, clickHref
element.hover pointer over ≥500ms element identity
element.change change on checkbox/radio/select + checked, option, delta
element.input input on text field (throttled 500ms) + valueKind, valueLen, valueHash
element.copy copy event + selLen
element.select selectionchange ≥3ch, 750ms throttle + selLen, anchorTag
sponsored.impression impression on sponsored kind element identity
sponsored.impression.opaque third-party iframe, no grant + iframeSrc, size
sponsored.click click on sponsored kind + clickHref
sponsored.feed.page scroll into new chumbox page + pageIdx, poolDepth
form.submit form submit + formId, action, fieldCount
form.focus first focus on input/textarea/select + tag, name, inputType
consent.overlay.impression consent overlay detected + provider if recognised
consent.decision button click in overlay + outcome (R17)
consent.abandon pagehide within 1s, no decision  
result.dwell search-result ≥8s ≥50% search-mount

R27. Unknown event names are silently rejected at send!.

6.2. 5.2 valueKind classifier (R29)

Never capture the value. Emit the shape:

Kind Predicate
empty =""
digits ^\d+$
email-shaped ^[^@\s]+@[^@\s]+\.[^@\s]+$
url-shaped ^https?://
currency-shaped ^\$?\d+(\.\d{2})?$
phone-shaped ^\+?[\d\s()-]{7,}$
text-short ≤20 chars
text-medium ≤200 chars
text-long >200 chars

Plus valueLen (int char count), valueHash (fnv1a 32-bit). Detects re-typing across sessions without storing the value.

7. 6. Envelope

Every event carries this base map before extra merges on top.

Field Source Purpose
event whitelist string dispatch
ts Date.now() ordering
session per page-life join within session
uid localStorage[<uid-key>] join across sessions
page location.pathname cluster by URL
ref document.referrer inbound path
tz Intl.DateTimeFormat tz rough geo
screen ${w}x${h} from screen device class
userAgent navigator.userAgent post-hoc bot
pageID CEDDL if present tie-in
viewport ${innerW}x${innerH} mobile/iframe disambig
depth history.state?.depth or 0 funnel step

R28. Field names literal. Downstream parsers key on them.

8. 7. Transport

R29. Fire N pixels per event: :global.transport.endpoint + each :fan-out + /observer/pixel (same-origin fallback).

R30. Encode as URL query. Skip nil/blank. Clip to 1800 bytes so no CDN truncates mid-parameter.

R31. Never throw. Every handler try/catch → nil on error. A crashing tracker fails harder than a silent one.

9. 8. Bot filter + opt-out

R32. bot? true if navigator.webdriver OR UA contains bot, Bot, crawl, spider, Spider, HeadlessChrome, Playwright, Puppeteer, Cypress.

R33. opt-out? true if ?tracking=off in URL.

R34. Both fail closed on exception (return false = allow).

R35. Injector mode inverts R32. When the operator IS the driver (Playwright / Claude-in-Chrome), config INJECTOR_MODE? disables the bot gate.

10. 9. Listeners — nine, all capture-phase

10.1. 9.1 Scroll — depth + reverse

R36. scroll on window, passive: true, rAF throttled. scroll.depth at 25/50/75/100 one-shot per page-life. scroll.reverse on ≥200px upward after ≥50%.

10.2. 9.2 Click — delegated

R37. Capture-phase click. Walk up via closest("[data-track-id],a,button,img,figure,input,select,textarea,form"). sponsored.click if predicate holds, else element.click. Include modKeys.

10.3. 9.3 Hover — dwell-thresholded

R38. Capture-phase pointerover + pointerout. setTimeout(500ms) that fires element.hover. Cancel on pointerout.

10.4. 9.4 Impression — IntersectionObserver

R39. One observer, threshold: [0, 0.5, 1], rootMargin: "0px". Entry ≥0.5 ratio → setTimeout(1000ms)element.impression. Exit → cancel or emit element.dwell with dwell time.

10.5. 9.5 Form change

R40. Capture-phase change. Payload: checked (checkbox/radio)

  • option (select label). Values never captured — only state.

10.6. 9.6 Form input (throttled)

R41. Capture-phase input. Per-element setTimeout(500ms) quiet-then-fire. Emit element.input with valueKind + valueLen

  • valueHash (never the value).

R42. Sampled BEFORE throttle allocation.

10.7. 9.7 Copy + select

R43. copy handler → element.copy with selLen.

R44. selectionchange, throttled 750ms, ≥3ch → element.select with selLen + anchorTag.

10.8. 9.8 Form submit + first-focus

R45. Capture-phase submitform.submit with formId, action, fieldCount.

R46. Capture-phase focusinform.focus on first focus of input/textarea/select. One shot per field per page-life.

10.9. 9.9 Page-lifecycle dwell

R47. Both pagehide AND visibilitychange (hidden). Emit page.dwell with dwellMs + maxScrollPct.

11. 10. SPA navigation

R48. Hook history.pushState + history.replaceState (monkey-patch proto) AND popstate event. On URL change:

  1. Emit page.dwell for outgoing page
  2. Reset per-page state
  3. Re-resolve config against new URL
  4. Re-observe matched nodes
  5. Emit pageview with depth+1

R49. Do NOT hook location setter — surface not enumerable, Object.defineProperty triggers frameworks. pushState + popstate + replaceState covers ≥99%; MutationObserver on <title> as fallback.

12. 11. Iframes

R50. First-party iframes (same eTLD+1): observer attaches inside via contentDocument. Envelope carries iframeSrc.

R51. Third-party iframes: IntersectionObserver on the <iframe> element itself. sponsored.impression.opaque with iframeSrc, size, isIntersecting.

R52. NEVER attempt postMessage handshake into cross-origin iframe. Observability grant is a separate contract.

13. 12. Consent overlays

R53. On init!, scan for markers (§4.3). If present:

  1. Emit consent.overlay.impression
  2. Wait for consent.decision OR 1s abandon gate
  3. If reject-all: reduce fan-out to same-origin only, drop third-party beacon. Respect user even in injector mode by default; operator can override.

R54. Close-button (dismiss) is NOT consent. Treat as consent.abandon for fan-out purposes.

14. 13. User id

R55. Persistent per-browser id at localStorage[<uid-key>] (default _observer_uid, configurable).

R56. Generation: 128 bits from crypto.getRandomValues (fallback Math.random), base36, prefix u-. Never crypto.subtle, canvas, audio, or WebGL — opacity depends on being generated, not derived.

R57. NEVER auto-rotate. Operator MAY force-rotate by clearing.

R58. One uid → many sessions. Session per page-life. Every event carries both.

15. 14. Debug logger

R59. Sinks: none (production) · console · localStorage (ring buffer, ring size, key _observer_log) · both.

R60. window.__wal_tracker.dumpLog() returns plain array. Never uploaded automatically.

R61. Debug lines carry same ts + session as pixel envelope (reconcile with §21.11).

16. 15. Injection surfaces

16.1. 15.1 Bookmarklet (§21.6)

R62. One-line loader. Subject to target CSP. Fall back to WebExtension or mitmproxy on CSP block.

16.2. 15.2 WebExtension (§21.7)

R63. MV3. Content script in isolated world → bypasses page CSP. host_permissions enumerates origins. Visible browser-action badge when active — silent observer is a compliance failure.

16.3. 15.3 mitmproxy addon (§21.8)

R64. String-substitutes </body> with observer script tag. Strips response CSP. HTTPS mutation requires explicit host in mitm-allowed-hosts.txt. Every mutation → mitm-observed.log.

17. 16. Driver — reproducibility

R65. Two drivers ship: Playwright (§21.9) and Claude-in-Chrome (§21.10). Both accept JSON output of §21.11 parser as input.

R66. Round-trip: replay produces AT LEAST the driven events. May produce more (impressions on render, page.dwell on pagehide) but never fewer.

18. 17. LLM auditor

R67. Prompt template lives at §21.12 as a tangle block — versioned independently of the observer.

R68. Auditor never sees uid. One session at a time. Model name

  • prompt hash MUST accompany any ledger append.

R69. Anomaly tag vocabulary (fixed): form-abandon · rage-click · back-and-forth · sponsored-blind · bot-shaped · broken-affordance · consent-abandon · checkout-abandon (ecomm) · add-to-cart-then-leave (ecomm) · chumbox-scroll-past (adtech) · iframe-impressive (adtech, opaque-only) · unknown. New tags require spec bump.

R70. Auditor output is a proposal, not a verdict. Operator reviews before ledger append.

19. 18. Boundaries

R71. NEVER capture:

  • Password field values (R9)
  • Credit-card number fields (input[autocomplete*=cc-number], input[name*=card-number])
  • CVV / security-code fields
  • Field values in general (only shape via R29)
  • document.cookie
  • localStorage keys other than observer's own uid + debug log
  • IndexedDB contents
  • Response bodies
  • Cross-origin iframe contents

R72. Every boundary is a silent refusal. Attempting to configure a password-field selector → one console warning at init, selector dropped. No throw.

R73. Consent-reject reduces fan-out to same-origin (R53). Default respects user.

R74. No cross-origin uid sync. Two host injections → two uids.

20. 19. Refutation condition

The claim: this spec is sufficient to build ONE observer that runs against wal.sh (matching the tracker) AND against any ecomm/adtech-heavy site (via §15) without site-specific code, with the same 22-event vocabulary.

Fails if:

  • Interaction that fires an event on wal.sh does NOT fire on injected page (identity/transport mismatch)
  • ecomm-generic (§4.2) misses ≥50% of trackable elements on any of the ten most-trafficked ecomm platforms (Shopify, WooCommerce, BigCommerce, Magento, Wix, Squarespace, SFCC, Amazon, Walmart, Target)
  • adtech-generic misses ≥50% on any of Google Ad Manager, Amazon Publisher Services, Taboola, Outbrain, Prebid.js sites
  • LLM auditor produces materially different anomaly tags for the same session across two runs of same model + prompt

Test: pick two competitor storefronts, inject via §15.2, drive a 5-minute session via §17, run auditor twice, diff tag bags.

21. 20. Rebuild recipe

  1. Tangle this file (M-x org-babel-tangle from Emacs).
  2. Read §21.1 (contract.cljc). Author closed-type Unit + single constructor + total validator.
  3. Land negative fixtures under src/cljs/observer/fixtures/.
  4. Author gate (§21.3). Exit 1 on failure.
  5. Author browser.cljs (§21.2) — DOM adapter delegating every emit to contract's send!. Includes nine listeners (§9), SPA hook (§10), iframe path (§11), consent path (§12).
  6. Wire public API — window.__wal_tracker with init, send, rescan, session, dumpLog.
  7. Build one injection surface (§15.1 easiest); test locally.
  8. Build driver stub (§21.9); confirm round-trip.
  9. Build intent parser (§21.11); confirm it reverses the pixel stream.
  10. Wire auditor (§21.12); run against one session.
  11. Ledger the crossing: append to .verify/chain.jsonl.
  12. Extend to §15.2 + §15.3 per target.

22. 21. Reference implementation — tangle blocks

22.1. 21.1 contract.cljc — closed-type Unit + total validator

(ns observer.contract
  "Closed-type event unit + total validator. Single constructor
   `build`. Every emit path goes through this — browser.cljs never
   manufactures an event map directly. SPEC §5, §6."
  (:require [clojure.string :as str]))

(def event-names
  "Closed set. Adding a name requires a spec bump AND a browser-side
   emitter. Any name outside this set is silently rejected."
  #{"pageview" "page.dwell"
    "scroll.depth" "scroll.reverse"
    "element.impression" "element.dwell" "element.click" "element.hover"
    "element.change" "element.input" "element.copy" "element.select"
    "sponsored.impression" "sponsored.impression.opaque"
    "sponsored.click" "sponsored.feed.page"
    "form.submit" "form.focus"
    "consent.overlay.impression" "consent.decision" "consent.abandon"
    "result.dwell"})

(def envelope-keys
  "SPEC §6 — the closed base envelope. Additional keys go via :extra."
  #{:event :ts :session :uid :page :ref :tz :screen :userAgent
    :pageID :viewport :depth})

(def value-kind
  "SPEC §5.2 classifier. String → kind. Never captures the value."
  (let [rules [[#"^$"                              "empty"]
               [#"^\d+$"                           "digits"]
               [#"^[^@\s]+@[^@\s]+\.[^@\s]+$"      "email-shaped"]
               [#"^https?://.*"                    "url-shaped"]
               [#"^\$?\d+(\.\d{2})?$"              "currency-shaped"]
               [#"^\+?[\d\s()-]{7,}$"              "phone-shaped"]]]
    (fn [s]
      (let [s (or s "")]
        (or (some (fn [[re k]] (when (re-matches re s) k)) rules)
            (cond
              (<= (count s) 20)  "text-short"
              (<= (count s) 200) "text-medium"
              :else              "text-long"))))))

(defn fnv1a
  "32-bit FNV-1a. Cross-language reproducible; do not substitute djb2."
  [s]
  (reduce (fn [h c]
            (let [x (bit-xor h (int c))]
              (bit-and 0xffffffff (unchecked-multiply-int x 16777619))))
          (unchecked-int 2166136261)
          (map int (or s ""))))

(defn value-summary
  "SPEC R29 + §5.2. Returns {:valueKind :valueLen :valueHash} — never
   the value itself."
  [s]
  (let [s (or s "")]
    {:valueKind (value-kind s)
     :valueLen  (count s)
     :valueHash (fnv1a s)}))

(defn valid?
  "Total validator. Returns [ok? violations]. SPEC R27."
  [{:keys [event ts session] :as e}]
  (let [violations
        (cond-> []
          (not (string? event))               (conj :event-not-string)
          (not (contains? event-names event)) (conj :event-not-in-whitelist)
          (not (number? ts))                  (conj :ts-not-number)
          (not (string? session))             (conj :session-not-string))]
    [(empty? violations) violations]))

(defn build
  "Single constructor. Returns {:ok true :value e} or
   {:ok false :violations [...]}. Callers get an explicit failure,
   not a limp map with nil fields."
  [event-name ts session opts]
  (let [{:keys [uid page ref tz screen userAgent pageID viewport depth extra]} opts
        base {:event     event-name
              :ts        (or ts 0)
              :session   (or session "")
              :uid       (or uid "")
              :page      (or page "")
              :ref       (or ref "")
              :tz        (or tz "")
              :screen    (or screen "")
              :userAgent (or userAgent "")
              :pageID    (or pageID "")
              :viewport  (or viewport "")
              :depth     (or depth 0)}
        e    (merge base (or extra {}))
        [ok? vs] (valid? e)]
    (if ok?
      {:ok true :value e}
      {:ok false :violations vs})))

22.2. 21.2 browser.cljs — DOM adapter

(ns observer.browser
  "Browser adapter — DOM listeners → contract.build → transport.
   Everything DOM-touching lives here. Pure logic in observer.contract.
   SPEC §9-13."
  (:require [observer.contract :as c]
            [clojure.string :as str]
            [cljs.reader]))

(defonce ^:private !installed?     (atom false))
(defonce ^:private !session        (atom nil))
(defonce ^:private !cfg
  (atom {:global {:transport {:endpoint nil :fan-out []}
                  :uid-key "_observer_uid"
                  :injector-mode? false
                  :debug {:sink "none" :buffer-size 500}}}))
(defonce ^:private !route          (atom nil))
(defonce ^:private !max-scroll-pct (atom 0))
(defonce ^:private !scroll-fired   (atom #{}))
(defonce ^:private !reverse-fired? (atom false))
(defonce ^:private !page-t0        (atom nil))
(defonce ^:private !hover-timers   (atom {}))
(defonce ^:private !impr-timers    (atom {}))
(defonce ^:private !impr-t0        (atom {}))
(defonce ^:private !input-timers   (atom {}))
(defonce ^:private !debug-buffer   (atom []))
(defonce ^:private !current-url    (atom nil))
(defonce ^:private !last-y         (atom 0))
(defonce ^:private !io             (atom nil))
(defonce ^:private !last-sel-fire  (atom 0))
(defonce ^:private !consent-t0     (atom nil))

;; --- Host helpers ------------------------------------------------------------

(defn- now-ms [] (.getTime (js/Date.)))

(defn- safe-tz []
  (try (-> (js/Intl.DateTimeFormat.) .resolvedOptions .-timeZone)
       (catch :default _ "")))

(defn- safe-screen []
  (try (str (.-width js/screen) "x" (.-height js/screen))
       (catch :default _ "")))

(defn- safe-viewport []
  (try (str (or (.-innerWidth js/window) 0) "x"
            (or (.-innerHeight js/window) 0))
       (catch :default _ "")))

(defn- gen-uid []
  (let [buf (js/Uint32Array. 4)]
    (try (.getRandomValues js/crypto buf)
         (catch :default _
           (dotimes [i 4]
             (aset buf i (js/Math.floor (* (js/Math.random) 4294967296))))))
    (str "u-" (.toString (aget buf 0) 36)
             (.toString (aget buf 1) 36)
             (.toString (aget buf 2) 36)
             (.toString (aget buf 3) 36))))

(defn- uid-key [] (get-in @!cfg [:global :uid-key] "_observer_uid"))

(defn- ensure-uid! []
  (try
    (or (.getItem js/localStorage (uid-key))
        (let [u (gen-uid)] (.setItem js/localStorage (uid-key) u) u))
    (catch :default _ (or @!session ""))))

(defn- ensure-session! []
  (or @!session
      (reset! !session
        (str "s-" (.toString (js/Math.abs (+ (now-ms) (* (js/Math.random) 1e9)))
                             36)))))

;; --- Bot filter + opt-out (§8) ----------------------------------------------

(defn- injector-mode? [] (get-in @!cfg [:global :injector-mode?] false))

(defn- bot? []
  (when-not (injector-mode?)
    (try
      (or (true? (.-webdriver js/navigator))
          (let [ua (or (.-userAgent js/navigator) "")]
            (boolean (some #(str/includes? ua %)
                           ["bot" "Bot" "crawl" "spider" "Spider"
                            "HeadlessChrome" "Playwright" "Puppeteer"
                            "Cypress"]))))
      (catch :default _ false))))

(defn- opt-out? []
  (try (= "off"
          (.get (js/URLSearchParams. (.-search js/location)) "tracking"))
       (catch :default _ false)))

;; --- Transport (§7) ----------------------------------------------------------

(defn- ->query [payload]
  (let [pairs (for [[k v] payload
                    :when (and v (not (and (string? v) (= "" v))))]
                (str (js/encodeURIComponent (name k)) "="
                     (js/encodeURIComponent
                       (cond (string? v)  v
                             (number? v)  (str v)
                             (boolean? v) (if v "1" "0")
                             :else        (str v)))))
        raw   (str/join "&" pairs)]
    (if (> (count raw) 1800) (subs raw 0 1800) raw)))

(defn- fire-one! [endpoint q]
  (try (let [img (js/Image.)]
         (set! (.-src img) (str endpoint "?" q)) img)
       (catch :default _ nil)))

(defn- endpoints []
  (let [t (get-in @!cfg [:global :transport])]
    (cons (:endpoint t) (concat (:fan-out t) ["/observer/pixel"]))))

(defn- fire-pixel! [payload]
  (let [q (->query payload)]
    (doseq [ep (keep identity (endpoints))]
      (fire-one! ep q))))

;; --- Debug sink (§14) --------------------------------------------------------

(defn- debug! [payload]
  (let [sink (get-in @!cfg [:global :debug :sink] "none")]
    (when (contains? #{"console" "both"} sink)
      (js/console.debug "[obs]" (:event payload)
                        (or (:elId payload) "") payload))
    (when (contains? #{"localStorage" "both"} sink)
      (let [bs (get-in @!cfg [:global :debug :buffer-size] 500)]
        (swap! !debug-buffer
               (fn [b] (into (vec (take-last (dec bs) b)) [payload])))))))

;; --- Central send! -----------------------------------------------------------

(defn- send!
  ([event-name] (send! event-name {}))
  ([event-name extra]
   (when (and (not (bot?)) (not (opt-out?)))
     (let [built (c/build event-name (now-ms) (ensure-session!)
                          {:uid       (ensure-uid!)
                           :page      (.-pathname js/location)
                           :ref       (.-referrer js/document)
                           :tz        (safe-tz)
                           :screen    (safe-screen)
                           :viewport  (safe-viewport)
                           :userAgent (.-userAgent js/navigator)
                           :extra     extra})]
       (when (:ok built)
         (fire-pixel! (:value built))
         (debug! (:value built)))))))

;; --- Identity extraction (§4.1 R23) -----------------------------------------

(defn- clip [s]
  (when (string? s) (if (> (count s) 200) (subs s 0 200) s)))

(defn- img-basename [src]
  (when (string? src)
    (last (str/split (first (str/split src #"\?" 2)) #"/"))))

(defn- password-field? [node]
  (and node
       (= "input" (some-> (.-tagName node) (.toLowerCase)))
       (or (= "password" (.getAttribute node "type"))
           (str/includes? (or (.getAttribute node "autocomplete") "")
                          "cc-number")
           (str/includes? (or (.getAttribute node "autocomplete") "")
                          "cc-csc")
           (some #(str/includes? (or (.getAttribute node "name") "") %)
                 ["card-number" "cvv" "card-cvv" "security-code"]))))

(defn- el-attrs [node]
  (try
    (when (and node (not (password-field? node)))
      (let [tag (some-> (.-tagName node) (.toLowerCase))
            explicit-id (.getAttribute node "data-track-id")
            src (when (= tag "img") (.getAttribute node "src"))
            alt (when (= tag "img") (.getAttribute node "alt"))]
        {:elId    (clip (or explicit-id
                            (when src (str "img:" (img-basename src)))))
         :elKind  (clip (or (.getAttribute node "data-track-kind")
                            (when (= tag "img") "img")))
         :tag     tag
         :href    (clip (or (.getAttribute node "href") src))
         :alt     (clip alt)
         :sponsor (clip (.getAttribute node "data-track-sponsor"))
         :creativeId (clip (.getAttribute node "data-track-creative"))
         :unit    (clip (.getAttribute node "data-track-unit"))
         :productId (clip (.getAttribute node "data-product-id"))}))
    (catch :default _ nil)))

(defn- closest-trackable [node]
  (try (.closest node
         "[data-track-id],a,button,img,figure,input,select,textarea,form")
       (catch :default _ nil)))

(defn- sponsored? [attrs]
  (or (str/includes? (or (:elKind attrs) "") "sponsored")
      (str/includes? (or (:elKind attrs) "") "chumbox")
      (some? (:sponsor attrs))
      (some? (:creativeId attrs))))

;; --- Scroll (§9.1) -----------------------------------------------------------

(defn- doc-height []
  (max (.. js/document -body -scrollHeight)
       (.. js/document -documentElement -scrollHeight)))

(defn- scroll-pct []
  (let [y (or (.-scrollY js/window) 0)
        vh (or (.-innerHeight js/window) 1)
        dh (max 1 (- (doc-height) vh))]
    (max 0 (min 100 (int (* 100 (/ y dh)))))))

(defn- on-scroll []
  (let [pct (scroll-pct) y (or (.-scrollY js/window) 0) dy (- y @!last-y)
        crossed (->> [25 50 75 100]
                     (filter #(and (>= pct %) (not (contains? @!scroll-fired %))))
                     set)]
    (swap! !max-scroll-pct max pct)
    (doseq [m crossed]
      (swap! !scroll-fired conj m)
      (send! "scroll.depth" {:depthPct m}))
    (when (and (not @!reverse-fired?) (>= @!max-scroll-pct 50) (< dy -200))
      (reset! !reverse-fired? true)
      (send! "scroll.reverse" {:fromPct @!max-scroll-pct :toPct pct}))
    (reset! !last-y y)))

(defn- rAF-throttle [f]
  (let [pending? (volatile! false)]
    (fn []
      (when-not @pending?
        (vreset! pending? true)
        (js/requestAnimationFrame
          (fn [] (vreset! pending? false) (try (f) (catch :default _ nil))))))))

;; --- Click / Hover / Impression / Change / Input / Copy / Select / Submit / Focus

(defn- on-click [ev]
  (try
    (let [node (closest-trackable (.-target ev))]
      (when node
        (let [attrs (el-attrs node)]
          (when attrs
            (send! (if (sponsored? attrs) "sponsored.click" "element.click")
                   (assoc attrs
                     :modKeys (cond-> []
                                (.-ctrlKey ev)  (conj "ctrl")
                                (.-metaKey ev)  (conj "meta")
                                (.-shiftKey ev) (conj "shift")
                                (.-altKey ev)   (conj "alt"))
                     :clickHref (:href attrs)))))))
    (catch :default _ nil)))

(defn- on-pointerover [ev]
  (try
    (let [node (closest-trackable (.-target ev))
          attrs (el-attrs node)
          key (or (:elId attrs) (str (hash node)))]
      (when attrs
        (swap! !hover-timers assoc key
               (js/setTimeout #(send! "element.hover" attrs) 500))))
    (catch :default _ nil)))

(defn- on-pointerout [ev]
  (try
    (let [node (closest-trackable (.-target ev))
          attrs (el-attrs node)
          key (or (:elId attrs) (str (hash node)))]
      (when-let [tid (get @!hover-timers key)]
        (js/clearTimeout tid)
        (swap! !hover-timers dissoc key)))
    (catch :default _ nil)))

(defn- on-intersect [entries]
  (doseq [entry (array-seq entries)]
    (try
      (let [node (.-target entry)
            attrs (el-attrs node)
            key (or (:elId attrs) (str (hash node)))]
        (when attrs
          (if (and (.-isIntersecting entry) (>= (.-intersectionRatio entry) 0.5))
            (let [t0 (now-ms)
                  tid (js/setTimeout
                        #(send! (if (sponsored? attrs) "sponsored.impression"
                                                       "element.impression")
                                attrs)
                        1000)]
              (swap! !impr-timers assoc key tid)
              (swap! !impr-t0 assoc key t0))
            (do
              (when-let [tid (get @!impr-timers key)]
                (js/clearTimeout tid)
                (swap! !impr-timers dissoc key))
              (when-let [t0 (get @!impr-t0 key)]
                (let [dwell (- (now-ms) t0)]
                  (when (>= dwell 1000)
                    (send! "element.dwell" (assoc attrs :dwellMs dwell)))
                  (swap! !impr-t0 dissoc key)))))))
      (catch :default _ nil))))

(defn- observe-tracked! []
  (try
    (when-let [io @!io]
      (doseq [n (array-seq
                  (.querySelectorAll js/document
                    "[data-track-id],[data-ad-slot],[class*=sponsored],img,form input,form select"))]
        (.observe io n)))
    (catch :default _ nil)))

(defn- on-change [ev]
  (try
    (let [node (.-target ev)]
      (when (and node (not (password-field? node)))
        (let [tag (some-> (.-tagName node) (.toLowerCase))
              attrs (el-attrs node)]
          (when attrs
            (send! "element.change"
                   (cond-> attrs
                     (= tag "input") (assoc :checked (.-checked node))
                     (= tag "select") (assoc :option (clip (.-value node)))))))))
    (catch :default _ nil)))

(defn- on-input [ev]
  (try
    (let [node (.-target ev)]
      (when (and node (not (password-field? node)))
        (let [attrs (el-attrs node)
              key (or (:elId attrs) (str (hash node)))]
          (when-let [tid (get @!input-timers key)] (js/clearTimeout tid))
          (swap! !input-timers assoc key
            (js/setTimeout
              (fn [] (send! "element.input"
                            (merge attrs (c/value-summary (.-value node)))))
              500)))))
    (catch :default _ nil)))

(defn- on-copy []
  (try
    (let [len (count (.toString (.getSelection js/window)))]
      (when (pos? len) (send! "element.copy" {:selLen len})))
    (catch :default _ nil)))

(defn- on-selectionchange []
  (try
    (let [now (now-ms)
          len (count (.toString (.getSelection js/window)))]
      (when (and (>= len 3) (>= (- now @!last-sel-fire) 750))
        (reset! !last-sel-fire now)
        (send! "element.select" {:selLen len})))
    (catch :default _ nil)))

(defn- on-submit [ev]
  (try
    (let [form (.-target ev)]
      (send! "form.submit"
             {:formId (clip (.getAttribute form "id"))
              :action (clip (.getAttribute form "action"))
              :fieldCount (count (array-seq (.-elements form)))}))
    (catch :default _ nil)))

(defn- on-focusin [ev]
  (try
    (let [node (.-target ev)
          tag  (some-> (.-tagName node) (.toLowerCase))]
      (when (and (contains? #{"input" "textarea" "select"} tag)
                 (not (password-field? node)))
        (send! "form.focus"
               {:tag tag
                :name (clip (.getAttribute node "name"))
                :inputType (clip (.getAttribute node "type"))})))
    (catch :default _ nil)))

;; --- Page-lifecycle (§9.9) + SPA nav (§10) ----------------------------------

(defn- on-pagehide []
  (when-let [t0 @!page-t0]
    (send! "page.dwell" {:dwellMs (- (now-ms) t0)
                         :maxScrollPct @!max-scroll-pct})))

(defn- on-visibility []
  (when (= "hidden" (.-visibilityState js/document)) (on-pagehide)))

(defn- reset-page-state! []
  (reset! !page-t0 (now-ms))
  (reset! !scroll-fired #{})
  (reset! !max-scroll-pct 0)
  (reset! !reverse-fired? false)
  (reset! !last-y 0)
  (reset! !hover-timers {})
  (reset! !impr-timers {})
  (reset! !impr-t0 {})
  (reset! !input-timers {}))

(defn- on-url-change [new-url]
  (when (not= new-url @!current-url)
    (on-pagehide)
    (reset-page-state!)
    (reset! !current-url new-url)
    (send! "pageview" {:depth (or (some-> js/history .-state (.-depth)) 0)})
    (observe-tracked!)))

(defn- hook-history! []
  (let [orig-push (.-pushState js/history)
        orig-repl (.-replaceState js/history)
        wrap (fn [orig]
               (fn [& args]
                 (let [r (.apply orig js/history (to-array args))]
                   (js/setTimeout #(on-url-change (.-href js/location)) 0)
                   r)))]
    (set! (.-pushState js/history) (wrap orig-push))
    (set! (.-replaceState js/history) (wrap orig-repl))
    (.addEventListener js/window "popstate"
                       (fn [_] (on-url-change (.-href js/location))))))

;; --- Consent overlay (§12) --------------------------------------------------

(defn- consent-selectors []
  ["[id*=consent]" "[class*=consent]" "[id*=cookie-banner]"
   "[aria-modal=true][id*=consent]" ".cookie-notice" ".cc-banner"])

(defn- consent-node []
  (try
    (some #(.querySelector js/document %) (consent-selectors))
    (catch :default _ nil)))

(defn- classify-consent [text]
  (let [t (str/lower-case (or text ""))]
    (cond
      (some #(str/includes? t %) ["accept all" "allow all" "agree all" "got it"])
      "accept-all"
      (some #(str/includes? t %) ["reject all" "decline all" "necessary only"])
      "reject-all"
      (some #(str/includes? t %) ["customise" "customize" "manage" "preferences"])
      "customise"
      (some #(str/includes? t %) ["close" "×" "dismiss"])
      "dismiss"
      :else "unknown")))

(defn- attach-consent! []
  (when-let [node (consent-node)]
    (reset! !consent-t0 (now-ms))
    (send! "consent.overlay.impression" {})
    (.addEventListener node "click"
      (fn [ev]
        (let [text (some-> (.-target ev) .-textContent str/trim)]
          (send! "consent.decision"
                 {:outcome (classify-consent text)
                  :buttonText (clip text)}))))))

;; --- Attach + public API ----------------------------------------------------

(defn- attach! []
  (reset! !page-t0 (now-ms))
  (reset! !current-url (.-href js/location))
  (.addEventListener js/window "scroll" (rAF-throttle on-scroll)
                     #js {:passive true})
  (.addEventListener js/document "click" on-click true)
  (.addEventListener js/document "pointerover" on-pointerover true)
  (.addEventListener js/document "pointerout"  on-pointerout  true)
  (.addEventListener js/document "change" on-change true)
  (.addEventListener js/document "input" on-input true)
  (.addEventListener js/document "copy" on-copy)
  (.addEventListener js/document "selectionchange" on-selectionchange)
  (.addEventListener js/document "submit" on-submit true)
  (.addEventListener js/document "focusin" on-focusin true)
  (.addEventListener js/window "pagehide" on-pagehide)
  (.addEventListener js/document "visibilitychange" on-visibility)
  (when (exists? js/IntersectionObserver)
    (reset! !io (js/IntersectionObserver. on-intersect
                  #js {:threshold #js [0 0.5 1] :rootMargin "0px"}))
    (observe-tracked!)
    (.addEventListener js/document "DOMContentLoaded" observe-tracked!))
  (hook-history!)
  (attach-consent!))

(defn ^:export init!
  "Public entry. Config is EDN string or JS object. Idempotent."
  [cfg]
  (when (compare-and-set! !installed? false true)
    (reset! !cfg (if (string? cfg)
                   (cljs.reader/read-string cfg)
                   (js->clj cfg :keywordize-keys true)))
    (attach!)
    (send! "pageview" {:depth 0})
    (let [api #js {:send    (fn [e d] (send! e (js->clj d :keywordize-keys true)))
                   :rescan  observe-tracked!
                   :session ensure-session!
                   :dumpLog (fn [] (clj->js @!debug-buffer))}]
      (set! (.-__wal_tracker js/window) api)
      api)))

22.3. 21.3 validate-config.clj — bb linter for config

;; validate-config.clj — sanity-check a route config before loading it
;; into a live observer. Rejects R71 boundary violations (password field
;; selectors), unknown events, malformed url-patterns.
(require '[clojure.edn :as edn])

(def known-events
  #{:pageview :page.dwell :scroll.depth :scroll.reverse
    :element.impression :element.dwell :element.click :element.hover
    :element.change :element.input :element.copy :element.select
    :sponsored.impression :sponsored.impression.opaque
    :sponsored.click :sponsored.feed.page
    :form.submit :form.focus
    :consent.overlay.impression :consent.decision :consent.abandon
    :result.dwell})

(def password-selector-re
  #"password|cc-number|card-number|cvv|security-code")

(defn violations [cfg]
  (concat
    (for [{:keys [url-pattern events name]} (:routes cfg)
          :when (not (string? url-pattern))]
      [:route-url-pattern-not-string name])
    (for [{:keys [name events]} (:routes cfg)
          e events :when (not (contains? known-events e))]
      [:route-unknown-event name e])
    (for [{:keys [name elements]} (:routes cfg)
          {:keys [selector]} elements
          :when (re-find password-selector-re (str selector))]
      [:route-password-field-selector name selector])
    (when-not (get-in cfg [:global :transport :endpoint])
      [[:missing-global-transport-endpoint]])))

(defn -main [& [path]]
  (let [cfg (edn/read-string (slurp (or path "config/ecomm.edn")))
        vs  (violations cfg)]
    (if (seq vs)
      (do (doseq [v vs] (println "✗" v)) (System/exit 1))
      (do (println "✓ config OK — routes:" (count (:routes cfg)))
          (System/exit 0)))))

(apply -main *command-line-args*)

22.4. 21.4 config/ecomm.edn — generic ecomm route config

{:routes
 [{:url-pattern "^https?://[^/]+/(checkout|cart|order|purchase|confirm)(/.*)?$"
   :name        "checkout"
   :elements    [{:selector "input[type=checkbox]" :kind "checkbox"}
                 {:selector "input[type=radio]"    :kind "radio"}
                 {:selector "select"               :kind "select"}
                 {:selector "input[type=email]"    :kind "field-email"}
                 {:selector "input[type=tel]"      :kind "field-phone"}
                 {:selector "input[name*=address]" :kind "field-address"}
                 {:selector "input[name*=zip]"     :kind "field-zip"}
                 {:selector "input[type=submit]"   :kind "submit"}
                 {:selector "button[type=submit]"  :kind "submit"}
                 {:selector "form"                 :kind "form"
                  :events   [:submit :change]}]
   :events      [:pageview :page.dwell :scroll.depth :scroll.reverse
                 :element.impression :element.dwell
                 :element.click :element.hover
                 :element.change :element.input :element.copy
                 :form.submit :form.focus
                 :consent.overlay.impression :consent.decision :consent.abandon]
   :sampling    {:pct 100}}

  {:url-pattern "^https?://[^/]+/(product|products|p|item)/.+$"
   :name        "pdp"
   :elements    [{:selector "[itemtype*=Product]"        :kind "product-detail"}
                 {:selector "img.product-image"          :kind "product-img"}
                 {:selector "select[name*=variant]"      :kind "variant"}
                 {:selector "select[name*=size]"         :kind "size"}
                 {:selector "select[name*=color]"        :kind "color"}
                 {:selector "input[name=quantity]"       :kind "quantity"}
                 {:selector "button[data-add-to-cart]"   :kind "add-to-cart"}
                 {:selector "button[class*=add-to-cart]" :kind "add-to-cart"}
                 {:selector "button[data-buy-now]"       :kind "buy-now"}]
   :events      [:pageview :page.dwell :scroll.depth
                 :element.impression :element.dwell
                 :element.click :element.hover :element.change]
   :sampling    {:pct 100}}

  {:url-pattern "^https?://[^/]+/(category|collection|c|shop|search)(/.*)?$"
   :name        "plp"
   :elements    [{:selector "[data-product-id]"          :kind "product-tile"}
                 {:selector "article[itemtype*=Product]" :kind "product-tile"}
                 {:selector "a[href*=/product/]"         :kind "product-link"}
                 {:selector "a[href*=/p/]"               :kind "product-link"}
                 {:selector "button[data-add-to-cart]"   :kind "add-to-cart"}]
   :events      [:pageview :page.dwell :scroll.depth
                 :element.impression :element.dwell
                 :element.click :element.hover]
   :sampling    {:pct 100}}

  {:url-pattern "^https?://[^/]+/(order/complete|thank-you|confirmation)(/.*)?$"
   :name        "confirmation"
   :elements    [{:selector "[data-order-total]"         :kind "order-total"}
                 {:selector "[data-order-number]"        :kind "order-number"}
                 {:selector "[itemprop=totalPaymentDue]" :kind "order-total"}]
   :events      [:pageview :page.dwell :element.impression]
   :sampling    {:pct 100}}]

 :global
 {:transport {:endpoint "https://beacon.example.com/pixel"
              :fan-out  ["/observer/pixel"]}
  :uid-key   "_observer_uid"
  :injector-mode? false
  :debug     {:sink "console" :buffer-size 500}}}

22.5. 21.5 config/adtech.edn — generic adtech route config

{:routes
 [{:url-pattern ".*"
   :name        "adtech-generic"
   :elements    [{:selector "[data-ad-slot]"             :kind "sponsored-slot"}
                 {:selector "[class*=sponsored]"         :kind "sponsored-inline"}
                 {:selector "[class*=advertorial]"       :kind "sponsored-inline"}
                 {:selector "a[rel*=sponsored]"          :kind "sponsored-link"}
                 {:selector "a[href^=/go/]"              :kind "sponsored-link"}
                 {:selector "a[href^=/redirect]"         :kind "sponsored-link"}
                 {:selector "[id^=taboola]"              :kind "chumbox-taboola"}
                 {:selector "[data-taboola-source]"      :kind "chumbox-taboola"}
                 {:selector ".OUTBRAIN"                  :kind "chumbox-outbrain"}
                 {:selector ".trc_related_container"     :kind "chumbox-taboola"}
                 {:selector "[class*=chumbox]"           :kind "chumbox-generic"}
                 {:selector "[class*=recommendations]"   :kind "chumbox-generic"}
                 {:selector "iframe[id^=google_ads]"     :kind "display-gam"}
                 {:selector "iframe[src*=doubleclick]"   :kind "display-gam"}
                 {:selector "iframe[src*=amazon-adsystem]" :kind "display-aps"}
                 {:selector "iframe[data-ad-slot]"       :kind "display-generic"}]
   :events      [:pageview :page.dwell :scroll.depth :scroll.reverse
                 :element.impression :element.dwell
                 :sponsored.impression :sponsored.impression.opaque
                 :sponsored.click :sponsored.feed.page
                 :consent.overlay.impression :consent.decision :consent.abandon]
   :sampling    {:pct 100}}]

 :global
 {:transport {:endpoint "https://beacon.example.com/pixel"
              :fan-out  ["/observer/pixel"]}
  :uid-key   "_observer_uid"
  :injector-mode? false
  :debug     {:sink "console" :buffer-size 500}}}

22.6. 21.6 bookmarklet loader

// Bookmarklet loader (§15.1). Paste into browser bookmark URL:
//
//   javascript:(async()=>{const s=document.createElement('script');
//   s.src='https://obs.example.com/observer.js';document.head.append(s);
//   await new Promise(r=>s.onload=r);
//   window.__wal_tracker.init(await(await fetch(
//   'https://obs.example.com/config/'+location.hostname+'.edn')).text());})()
//
// Subject to target CSP. If script-src 'self' blocks the observer origin,
// fall back to WebExtension (§15.2) or mitmproxy (§15.3).

(async () => {
  const s = document.createElement('script');
  s.src = 'https://obs.example.com/observer.js';
  document.head.append(s);
  await new Promise((r) => (s.onload = r));
  const cfg = await (
    await fetch(`https://obs.example.com/config/${location.hostname}.edn`)
  ).text();
  window.__wal_tracker.init(cfg);
})();

22.7. 21.7 WebExtension manifest + content script

{
  "manifest_version": 3,
  "name": "Observer (private)",
  "version": "0.1.0",
  "description": "Route-configured passive-capture observer.",
  "permissions": ["storage", "scripting", "activeTab"],
  "host_permissions": ["https://example.com/*"],
  "background": { "service_worker": "background.js" },
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["observer.js", "content.js"],
      "run_at": "document_idle",
      "world": "ISOLATED"
    }
  ],
  "action": {
    "default_title": "Observer active",
    "default_icon": { "16": "icon.png" }
  }
}
// content.js — MV3 content script. Runs in isolated world (bypasses page
// CSP). Loads config from chrome.storage.local keyed by hostname, falls
// back to a bundled default.

const DEFAULT_CFG = JSON.stringify({
  routes: [],
  global: {
    transport: { endpoint: 'https://beacon.example.com/pixel', fanOut: [] },
    uidKey: '_observer_uid',
    injectorMode: false,
    debug: { sink: 'console', bufferSize: 500 },
  },
});

(async () => {
  const host = location.hostname;
  const stored = await chrome.storage.local.get([`cfg:${host}`]);
  const cfg = stored[`cfg:${host}`] ?? DEFAULT_CFG;
  window.__wal_tracker?.init?.(cfg);
  chrome.runtime.sendMessage({ type: 'observer-active', host });
})();

22.8. 21.8 mitmproxy addon

"""observer_inject.py — mitmproxy addon per SPEC §15.3.

R64 — string-substitutes </body> with observer script tag; strips
response CSP so injected observer can run; HTTPS mutation requires
explicit per-host consent in mitm-allowed-hosts.txt; every mutation
logs to mitm-observed.log.
"""

import logging
import re
from datetime import datetime
from pathlib import Path
from mitmproxy import http


LOG_FILE = Path("mitm-observed.log")
ALLOWED_HOSTS_FILE = Path("mitm-allowed-hosts.txt")
BODY_CLOSE = re.compile(rb"</body>", re.IGNORECASE)


def _load_allowed_hosts() -> set[str]:
    if not ALLOWED_HOSTS_FILE.exists():
        return set()
    return {
        line.strip()
        for line in ALLOWED_HOSTS_FILE.read_text().splitlines()
        if line.strip() and not line.startswith("#")
    }


def _log_mutation(host: str, path: str, scheme: str) -> None:
    LOG_FILE.open("a").write(
        f"{datetime.utcnow().isoformat()}Z\t{scheme}\t{host}\t{path}\n"
    )


class ObserverInjector:
    def __init__(self) -> None:
        self.allowed_hosts = _load_allowed_hosts()
        logging.info(f"[observer_inject] {len(self.allowed_hosts)} allowed hosts")

    def responseheaders(self, flow: http.HTTPFlow) -> None:
        if "text/html" not in flow.response.headers.get("content-type", ""):
            return
        if flow.request.scheme == "https" and flow.request.host not in self.allowed_hosts:
            return
        flow.response.headers.pop("content-security-policy", None)
        flow.response.headers.pop("content-security-policy-report-only", None)

    def response(self, flow: http.HTTPFlow) -> None:
        if "text/html" not in flow.response.headers.get("content-type", ""):
            return
        if flow.request.scheme == "https" and flow.request.host not in self.allowed_hosts:
            return
        body = flow.response.content or b""
        if not BODY_CLOSE.search(body):
            return
        inject = (
            b'<script src="http://mitm.local/observer.js"></script>'
            b'<script>window.__observer_config="/config/'
            + flow.request.host.encode()
            + b'.json";</script>'
        )
        flow.response.content = BODY_CLOSE.sub(inject + b"</body>", body, count=1)
        _log_mutation(flow.request.host, flow.request.path, flow.request.scheme)


addons = [ObserverInjector()]

22.9. 21.9 Playwright driver

// Reads a per-session event JSON (output of scripts/parse-intent.clj
// --format json), replays the driven actions against the same URL,
// verifies the observer re-emits the same event set (R66).

import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';

const EVENT_FILE = process.env.EVENTS ?? 'session.json';
const TARGET_URL = process.env.URL ?? 'https://example.com/';
const CONFIG_URL = process.env.CONFIG_URL ?? '';

const events = JSON.parse(readFileSync(EVENT_FILE, 'utf8'));
const [sessionId, sessionEvents] = Object.entries(events)[0];

test(`replay ${sessionId} → ${TARGET_URL}`, async ({ page }) => {
  await page.addInitScript({ path: './observer.js' });
  await page.addInitScript({
    content: `window.__observer_config="${CONFIG_URL}";`,
  });

  await page.goto(TARGET_URL);

  const sorted = [...sessionEvents].sort((a, b) => a.ts - b.ts);
  const t0 = sorted[0]?.ts ?? 0;

  for (const evt of sorted) {
    const delay = evt.ts - t0;
    if (delay > 0) await page.waitForTimeout(Math.min(delay, 5000));

    switch (evt.event) {
      case 'element.click':
      case 'sponsored.click':
        await page.click(`[data-track-id="${evt.elId}"]`).catch(() => {});
        break;
      case 'element.hover':
        await page.hover(`[data-track-id="${evt.elId}"]`).catch(() => {});
        break;
      case 'element.change':
        if (evt.checked !== undefined) {
          await page
            .locator(`[data-track-id="${evt.elId}"]`)
            .setChecked(evt.checked)
            .catch(() => {});
        }
        break;
      case 'scroll.depth':
        await page.evaluate((pct) => {
          const h = document.documentElement.scrollHeight - window.innerHeight;
          window.scrollTo(0, (h * pct) / 100);
        }, evt.depthPct);
        break;
    }
  }

  const replayed = await page.evaluate(
    () => window.__wal_tracker?.dumpLog?.() ?? []
  );
  expect(replayed.length).toBeGreaterThan(0);
  // R66 — replay produces AT LEAST the driven event names
  const drivenNames = new Set(sorted.map((e) => e.event));
  const replayedNames = new Set(replayed.map((e) => e.event));
  for (const name of drivenNames) {
    expect(replayedNames).toContain(name);
  }
});

22.10. 21.10 Claude-in-Chrome recipe

# Claude-in-Chrome driver recipe (§17)

Claude-in-Chrome exposes CDP tools via `mcp__claude-in-chrome__*`.
This recipe turns Claude into the driver — records a natural-language
scenario, replays against a target, compares against the observer's
recorded event stream.

## Session recipe

1. `tabs_create_mcp` → open target URL.
2. `javascript_tool` → inject observer + config:
   ```javascript
   const s = document.createElement('script');
   s.src = 'https://obs.example.com/observer.js';
   document.head.append(s);
   await new Promise(r => s.onload = r);
   window.__wal_tracker.init(YOUR_CONFIG_STRING);
   ```
3. `read_page` → let Claude enumerate trackable elements. Claude
   proposes which selectors belong in the config `:elements` list.
4. Drive interactions per scenario. Between actions:
   `read_console_messages` with `pattern: "\\[obs\\]"` to verify
   each event fires exactly once (never zero, never twice).
5. `javascript_tool` → dump the buffer:
   `JSON.stringify(window.__wal_tracker.dumpLog())`
6. Diff against reference. Prose differences fine; tag-bag
   differences are spec bugs (§19).

## Anti-patterns

- No `alert()` / `confirm()` / `prompt()` — blocks CIC commands.
- No looping failing actions. Two tries, then stop.
- No enumerating `document.cookie` or `localStorage` beyond
  observer's own key (§18 boundary).

## Auditor loop

Same buffer feeds the §17 LLM auditor. Operator reviews the auditor
output before ledger append (R70).

22.11. 21.11 parse-intent.clj — reverse the pixel stream

;; parse-intent.clj — reads Apache/Nginx access log, filters to
;; /observer/pixel hits (configurable), URL-decodes each query,
;; groups by session (or uid / ip), emits timeline / counts / edn / json.
(require '[clojure.string :as str]
         '[clojure.java.io :as io]
         '[clojure.pprint :as pp]
         '[cheshire.core :as json])

(def log-re
  #"^(\S+) \S+ \S+ \[([^\]]+)\] \"(\S+) (\S+) [^\"]*\" (\d{3}) (\d+|-) \"([^\"]*)\" \"([^\"]*)\".*")

(defn parse-line [line]
  (when-let [m (re-matches log-re line)]
    {:ip (nth m 1) :ts-log (nth m 2) :method (nth m 3)
     :path (nth m 4) :status (parse-long (nth m 5))
     :ref (nth m 7) :ua (nth m 8)}))

(defn decode [s]
  (try (java.net.URLDecoder/decode s "UTF-8") (catch Exception _ s)))

(defn parse-query [qs]
  (into {} (for [pair (str/split (or qs "") #"&")
                 :let [[k v] (str/split pair #"=" 2)]
                 :when (and k v)]
             [(keyword k) (decode v)])))

(defn ->set [x] (if (coll? x) (set x) #{x}))

(defn match? [{:keys [uid session page event ip since]}]
  (let [uids (when (seq uid) (->set uid))
        events (when (seq event) (->set event))
        since-ms (some-> since (str "T00:00:00Z")
                         (java.time.Instant/parse) .toEpochMilli)]
    (fn [e]
      (and (or (nil? uids) (contains? uids (:uid e)))
           (or (nil? session) (= session (:session e)))
           (or (nil? page) (str/includes? (or (:page e) "") page))
           (or (nil? events) (contains? events (:event e)))
           (or (nil? ip) (= ip (:ip e)))
           (or (nil? since-ms) (and (:ts e) (>= (:ts e) since-ms)))))))

(defn iso-ts [ms]
  (when ms (str (java.time.Instant/ofEpochMilli ms))))

(defn fmt-event [e]
  (let [ts (:ts e)
        stamp (or (some-> (iso-ts ts) (subs 11 23)) "??")
        ev (or (:event e) "?")
        rest-ks (dissoc e :event :ts :log-ts :ip :ua :session :uid
                        :page :ref :referrer :userAgent :screen :timezone :tz)]
    (str stamp "  " ev "  "
         (str/join " "
           (for [[k v] (sort-by key rest-ks) :when (some? v)]
             (str (name k) "=" v))))))

(defn print-timeline [groups {:keys [limit]}]
  (doseq [[k evs] (sort-by first groups)]
    (let [sorted (sort-by :ts evs)
          shown (if limit (take limit sorted) sorted)
          fe (first sorted)
          span (when (and (:ts fe) (:ts (last sorted)))
                 (- (:ts (last sorted)) (:ts fe)))]
      (println)
      (println (str "── " k "  (" (count evs) " events"
                    (when span (format ", %.1fs" (/ span 1000.0)))
                    ")"))
      (println (str "   uid=" (:uid fe) " ip=" (:ip fe) " page=" (:page fe)))
      (doseq [e shown] (println "  " (fmt-event e))))))

(defn print-counts [groups _]
  (doseq [[k evs] (sort-by first groups)]
    (println (str "── " k "  (" (count evs) " events)"))
    (doseq [[e n] (->> evs (map :event) frequencies (sort-by val >))]
      (println (format "   %5d  %s" n e)))))

(defn parse-args [argv]
  (loop [args (seq argv)
         opts {:format "timeline" :group "session"
               :uid [] :event [] :pixel "/observer/pixel"}
         paths []]
    (if-not args
      [opts (or (seq paths) nil)]
      (let [a (first args) rst (next args)]
        (case a
          "--help" [(assoc opts :help true) nil]
          "--uid" (recur (next rst) (update opts :uid conj (first rst)) paths)
          "--event" (recur (next rst) (update opts :event conj (first rst)) paths)
          "--session" (recur (next rst) (assoc opts :session (first rst)) paths)
          "--page" (recur (next rst) (assoc opts :page (first rst)) paths)
          "--ip" (recur (next rst) (assoc opts :ip (first rst)) paths)
          "--since" (recur (next rst) (assoc opts :since (first rst)) paths)
          "--limit" (recur (next rst) (assoc opts :limit (parse-long (first rst))) paths)
          "--group" (recur (next rst) (assoc opts :group (first rst)) paths)
          "--format" (recur (next rst) (assoc opts :format (first rst)) paths)
          "--pixel" (recur (next rst) (assoc opts :pixel (first rst)) paths)
          (recur rst opts (conj paths a)))))))

(defn -main [& argv]
  (let [[opts paths] (parse-args argv)
        prefix (str (:pixel opts) "?")
        tracking? #(and (:path %) (str/starts-with? (:path %) prefix))
        entry->event
        (fn [entry]
          (-> (parse-query (subs (:path entry) (count prefix)))
              (assoc :ip (:ip entry) :ua (:ua entry) :log-ts (:ts-log entry))
              (update :ts #(some-> % parse-long))))
        source (mapcat (fn [p]
                         (when (or (= p "-") (.exists (io/file p)))
                           (line-seq (io/reader
                                       (if (= p "-") *in* p)))))
                       (or paths ["logs/access.log"]))
        events (->> source (keep parse-line) (filter tracking?)
                    (map entry->event) (filter (match? opts)))
        group-key (case (:group opts) "uid" :uid "ip" :ip :session)
        groups (group-by group-key events)]
    (when (empty? groups)
      (binding [*out* *err*] (println "no matching events"))
      (System/exit 1))
    (case (:format opts)
      "edn" (pp/pprint groups)
      "json" (println (json/generate-string groups {:pretty true}))
      "counts" (print-counts groups opts)
      (print-timeline groups opts))))

(apply -main *command-line-args*)

22.12. 21.12 LLM auditor prompt

# LLM auditor prompt (§17)

Fill placeholders and send to your model of choice (Claude Sonnet /
Opus recommended). Model name + this file's SHA-256 MUST accompany
any ledger append (R68).

## System

You are auditing a browsing session recorded by a passive-capture
observer. The event stream is deterministic per session; sampling is
100%. You never see the uid — one session at a time.

Your job:

1. Reconstruct what the user tried to do, in ONE paragraph.
2. Flag any event or sequence that looks anomalous.
3. Name the intent signals present (dwell, hover, click, form
   abandon, checkout abandon, chumbox scroll-past, iframe
   impressive, etc.).

Emit anomaly tags from THIS closed vocabulary (§17 R69). One tag
per line, prefix `TAG: `. Unknown → `TAG: unknown`.

- form-abandon
- rage-click
- back-and-forth
- sponsored-blind
- bot-shaped
- broken-affordance
- consent-abandon
- checkout-abandon
- add-to-cart-then-leave
- chumbox-scroll-past
- iframe-impressive
- unknown

Do NOT invent facts. If evidence doesn't support a claim, say so.
Do NOT propose fixes — you're an auditor, not a designer.

## User

Session: {{SESSION_ID}}
URL:     {{FIRST_PAGEVIEW_PAGE}}
Duration: {{SPAN_SECONDS}}s
Event count: {{EVENT_COUNT}}

Events (chronological):

```
{{EVENT_STREAM}}
```

Return: (a) reconstruction paragraph; (b) anomaly tag list; (c)
intent-signal list.

23. 22. Cross-references

  • SPEC-tracker-base.org — tracker-rebuild base (R1-R22 of the wal.sh tracker; requirement numbers here are independent of that base)
  • README.md — repo overview
  • .verify/annotations.org — property-drawer verification index
  • .verify/chain.jsonl — sibling ledger
  • Oracle: jwalsh/www.wal.sh:src/wal_sh/site/tracker/ — the CLJS implementation this spec is portable-ised from
  • Oracle: jwalsh/www.wal.sh:scripts/tracker-intent.clj — the reference for §21.11's parse-intent.clj