Files
mokosh/.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-PLAN.md
Mark b3bfbf4a8d feat(03): plans 01-05 — Phase 3 SPEC §10 smoke + DOM/event-log verification
5 plans across 5 waves (Wave 2 sequential per RESEARCH Pitfall 6 file overlap):
- 03-01 Wave 1: rrweb DOM verification harness extension (A29; REQ-rrweb-dom-buffer; §10 #4)
- 03-02 Wave 2: event-log verification harness extension (A30; REQ-user-event-log; §10 #5)
- 03-03 Wave 3: §10 #8 password-filter PARTIAL verification (A31; D-P3-02 charter)
- 03-04 Wave 4: §10 #9 RAM ceiling best-effort + Page.metrics scaffolding (A32; D-P3-04)
- 03-05 Wave 5: §10 sweep VERIFICATION.md + REQUIREMENTS/ROADMAP/STATE marker flips
  (REQ-install-clean + REQ-rrweb-dom-buffer + REQ-user-event-log)

Each plan has:
- frontmatter (wave + depends_on + files_modified + autonomous + requirements + tags + must_haves)
- tasks with mandatory <read_first> + <acceptance_criteria> + concrete <action>
- <threat_model> block per security gate
- Validation map row(s) added to 03-VALIDATION.md (10 tasks total)

Expected UAT growth: 29/29 → 33/33 GREEN (A29-A32 + 03-05 docs).
Expected vitest baseline preserved: 171/171.
Expected Tier-1 FORBIDDEN_HOOK_STRINGS: 12 (A29+ ride production surfaces only).

ROADMAP.md Phase 3 entry replaces "Plans: TBD" with full 5-plan list.
VALIDATION.md status: planner_filled (nyquist_compliant: true; wave_0_complete: true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:01:21 +02:00

38 KiB
Raw Blame History

phase, slug, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, user_setup, must_haves
phase slug plan type wave depends_on files_modified autonomous requirements tags user_setup must_haves
03 spec-10-smoke-verification-dom-event-log-verification 01 execute 1
tests/uat/extension-page-harness.html
tests/uat/extension-page-harness.ts
tests/uat/lib/harness-page-driver.ts
tests/uat/harness.test.ts
true
REQ-rrweb-dom-buffer
uat-harness
a29
rrweb
spec-10-4
approach-b
probe-html
truths artifacts key_links
rrweb session.json contains > 0 events after a probe-page interaction
rrweb emits at least one Meta event (EventType=4) on session start
rrweb emits at least one FullSnapshot (EventType=2) on session start
rrweb emits at least one IncrementalSnapshot (EventType=3) after a DOM mutation on the probe page
UAT harness exits 0 with 29 + 1 = 30/30 assertions GREEN (A0..A28 baseline preserved + new A29)
path provides contains
tests/uat/extension-page-harness.html Probe HTML (form + table + modal trigger + DOM mutation source) appended below existing scaffold; tokens.css link in head untouched id="probe-form"
path provides contains
tests/uat/extension-page-harness.ts assertA29 page-side stub registered on window.__mokoshHarness assertA29
path provides contains
tests/uat/lib/harness-page-driver.ts driveA29 host-side: triggers probe-page DOM mutation, runs setupFreshRecording + SAVE, JSZip-parses rrweb/session.json, EventType enum grep driveA29
path provides contains
tests/uat/harness.test.ts driveA29 import + wrapped driver + drivers-array push entry with banner comment driveA29
from to via pattern
tests/uat/harness.test.ts tests/uat/lib/harness-page-driver.ts driveA29 import + wrapped driver const + drivers-array push driveA29Wrapped
from to via pattern
tests/uat/lib/harness-page-driver.ts driveA29 tests/uat/extension-page-harness.ts assertA29 page.evaluate(() => window.__mokoshHarness.assertA29()) harness.assertA29()
from to via pattern
tests/uat/lib/harness-page-driver.ts driveA29 @rrweb/types EventType enum import { EventType } from '@rrweb/types' import.*EventType.*from.*@rrweb/types
Extend the UAT harness with A29 — empirical verification that SPEC §10 #4 (rrweb DOM event recording on typical pages) is satisfied. Production wiring at `src/content/index.ts:284-309` already ships `rrweb.record()` + `maskInputOptions.password=true`; A29 confirms it actually emits the required EventType events on a synthetic probe page (form + table + modal + DOM-mutation trigger).

Purpose: Closes REQ-rrweb-dom-buffer empirical verification gap. Phase 1 shipped the wiring; Phase 3 confirms it works end-to-end through a real Chrome instance + a real probe page.

Output: 4-check A29 assertion (events.length > 0 + Meta present + FullSnapshot present + IncrementalSnapshot present); UAT count 29 → 30 GREEN; probe HTML appended to extension-page-harness.html below the existing <pre id="status"> scaffold (head unchanged; tokens.css link preserved for A18/A21).

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-CONTEXT.md @.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-RESEARCH.md @.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md @.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-UI-SPEC.md @.planning/phases/02-stabilize-export-pipeline/02-04-SUMMARY.md @src/content/index.ts @tests/uat/extension-page-harness.html

From @rrweb/types/dist/index.d.ts (lines 186-194):

export declare enum EventType {
  DomContentLoaded = 0,
  Load = 1,
  FullSnapshot = 2,
  IncrementalSnapshot = 3,
  Meta = 4,
  Custom = 5,
  Plugin = 6
}

From tests/uat/lib/assertions.ts (lines 25-44):

export interface CheckRecord {
  readonly name: string;
  readonly expected: unknown;
  readonly actual: unknown;
  readonly passed: boolean;
}
export interface AssertionRecord {
  readonly passed: boolean;
  readonly name: string;
  readonly checks: ReadonlyArray<CheckRecord>;
  readonly diagnostics: ReadonlyArray<string>;
  readonly error?: string;
}

From tests/uat/extension-page-harness.ts (line 169):

interface AssertionResult {
  passed: boolean;
  name: string;
  checks: Array<{ name: string; expected: unknown; actual: unknown; passed: boolean }>;
  diagnostics: string[];
  error?: string;
}
function diag(result: AssertionResult, line: string): void;   // line 190
async function setupFreshRecording(): Promise<{ ok: boolean; error?: string }>;  // line 825
async function sendMessageWithTimeout<T>(msg: unknown, timeoutMs: number, label: string): Promise<T>;  // line 234

From tests/uat/lib/harness-page-driver.ts (lines 1395, 41):

function findLatestZip(downloadsDir: string): string | null;  // host-side; mtime-sort wins
import JSZip from 'jszip';  // host-only, not bundled to page

From src/content/index.ts (verifier subject — READ-ONLY):

// Lines 284-311: record({ emit(event){ rrwebEvents.push(event) }, maskInputOptions: { password: true, ... } })
// Line 318: chrome.runtime.onMessage 'GET_RRWEB_EVENTS' returns { events, userEvents }
// Line 82: setupInputLogging filter — if (target.type === 'password') return;

From src/background/index.ts (READ-ONLY):

// GET_RRWEB_EVENTS handler in createArchive: chrome.tabs.sendMessage(activeTabId, { type: 'GET_RRWEB_EVENTS' })
// Result lands in rrweb/session.json inside the assembled zip

Plan Anchors

  • Approach B pattern source: Plan 02-04 driveA26 (canonical host-side JSZip read; chains off prior SAVE) — see 02-04-SUMMARY.md and tests/uat/lib/harness-page-driver.ts:1421-1567 for the exact 3-phase shape (page-side stub → findLatestZip → JSZip-parse).
  • RESEARCH Pitfall 1 (MUST OBSERVE): synthetic probe HTML produces Meta + FullSnapshot on load but NOT IncrementalSnapshot unless a DOM mutation happens between page load and SAVE. Plan must inject a mutation pre-SAVE (e.g. click the modal trigger button or input text).
  • RESEARCH Pitfall 4 (HARD BAN): Probe HTML MUST NOT include <textarea> — rrweb 2.0.0-alpha.4 issue #1596 leaks textarea values even with maskInputOptions.textarea set.
  • UI-SPEC ban (HARD): Probe HTML must NOT modify the <head> block. The existing <link rel="stylesheet" href="../../src/shared/tokens.css"> is load-bearing for A18 + A21. Probe HTML appends below the existing <pre id="status"> (line 21) and BEFORE the existing <script> tag.
  • FORBIDDEN_HOOK_STRINGS lockstep (RESEARCH §"Anti-Patterns"): A29 rides production surfaces (rrweb.record + GET_RRWEB_EVENTS bridge + chrome.runtime.sendMessage). Tier-1 inventory stays at 12 entries. No new __MOKOSH_UAT__-gated symbols expected.
  • Source audit citation: Implements REQ-rrweb-dom-buffer per REQUIREMENTS.md lines 54-60 (10-min window + 5000-event cap + DOM capture via rrweb 2.0.0-alpha.4 pin). The 10-min cleanup mechanics are already covered by src/content/index.ts:27-50 and are NOT exercised by A29 — A29's contract is "rrweb records events without errors on probe pages", which closes §10 #4 literally per CONTEXT D-P3-01.
Task 1: Append probe HTML to extension-page-harness.html (form + table + modal) tests/uat/extension-page-harness.html - tests/uat/extension-page-harness.html (full file; 24 lines — see exact contents in interfaces above) - .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-UI-SPEC.md §"Test Fixture Conventions" (probe HTML rules: NO textarea; NO tokens.css import; NO data-mokosh-* attrs; data-test-* OK) - .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-RESEARCH.md §"Pitfall 1" (DOM mutation required pre-SAVE) and §"Pitfall 4" (textarea ban) - The harness page exposes (querySelector-reachable) a form with at least one text input (id="probe-text"), one email input (id="probe-email"), one password input (id="probe-password"), one submit button, and an enclosing form (id="probe-form"). - The harness page exposes a small static table with thead + tbody + at least 2 data rows (id="probe-table"). - The harness page exposes a modal trigger button (id="probe-modal-trigger") that, when clicked, toggles a hidden div (id="probe-modal") to visible (display:none → display:block). This click is the canonical DOM-mutation source per Pitfall 1. - The existing tokens.css link in `<head>` is NOT modified. - The existing `

`, two `

` lines, and `

` are
      NOT modified. The existing `<script type="module">` is NOT moved.
    - The probe HTML appears between line 21 (`
`) and
      line 22 (`<script type="module">`) in the source — appended to
      the body after the existing scaffold, before the script.
    - No `` element anywhere in the file (rrweb alpha.4
      issue #1596).
    - No `<link>` or `<style>` additions referencing tokens.css.
    - No `data-mokosh-slot`, `data-mokosh-key`, or `data-mokosh-i18n-key`
      attributes (reserved for production welcome page).
    - The probe HTML uses inline minimal styles (`style="display:none"`
      on the modal); no `<style>` block.
    - On the modal trigger click, `document.getElementById('probe-modal')`
      toggles its `style.display` between 'none' and 'block' via a
      small inline `onclick` handler. (rrweb records the attribute
      mutation, satisfying IncrementalSnapshot emission.)
  </behavior>
  <action>
1. Read the current `tests/uat/extension-page-harness.html` (24 lines, contents shown in interfaces).
2. Open the file for editing. Locate the line `    <pre id="status">Ready.</pre>` (line 21) and the next line `    <script type="module" src="./extension-page-harness.ts"></script>` (line 22).
3. Insert the following probe HTML block BETWEEN line 21 and line 22 (preserving the trailing newline structure):
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-html display">    <span class="c"><!--
</span><span class="c">      Plan 03-01 Wave 1 — probe HTML for A29 rrweb DOM verification
</span><span class="c">      (SPEC §10 #4). Provides form + table + modal-trigger so rrweb's
</span><span class="c">      record() emits Meta + FullSnapshot + IncrementalSnapshot events
</span><span class="c">      against this page. Per RESEARCH Pitfall 4: NO <textarea> (rrweb
</span><span class="c">      2.0.0-alpha.4 issue #1596 leaks textarea values even with
</span><span class="c">      maskInputOptions.textarea set). Per UI-SPEC Section "Test Fixture
</span><span class="c">      Conventions": probe HTML uses plain data-test-* attributes; no
</span><span class="c">      data-mokosh-* (production-welcome-page reserved); no tokens.css
</span><span class="c">      import (the harness head already imports the canonical tokens
</span><span class="c">      for A18/A21 — probe sub-tree should appear unstyled to keep
</span><span class="c">      rrweb snapshots focused on structural DOM).
</span><span class="c">    --></span>
    <span class="p"><</span><span class="nt">form</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-form"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-form"</span><span class="p">></span>
      <span class="p"><</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">"text"</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-text"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-text"</span> <span class="p">/></span>
      <span class="p"><</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">"email"</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-email"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-email"</span> <span class="p">/></span>
      <span class="p"><</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">"password"</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-password"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-password"</span> <span class="p">/></span>
      <span class="p"><</span><span class="nt">button</span> <span class="na">type</span><span class="o">=</span><span class="s">"submit"</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-submit"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-submit"</span><span class="p">></span>Submit<span class="p"></</span><span class="nt">button</span><span class="p">></span>
    <span class="p"></</span><span class="nt">form</span><span class="p">></span>
    <span class="p"><</span><span class="nt">table</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-table"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-table"</span><span class="p">></span>
      <span class="p"><</span><span class="nt">thead</span><span class="p">></span>
        <span class="p"><</span><span class="nt">tr</span><span class="p">><</span><span class="nt">th</span><span class="p">></span>col-a<span class="p"></</span><span class="nt">th</span><span class="p">><</span><span class="nt">th</span><span class="p">></span>col-b<span class="p"></</span><span class="nt">th</span><span class="p">></</span><span class="nt">tr</span><span class="p">></span>
      <span class="p"></</span><span class="nt">thead</span><span class="p">></span>
      <span class="p"><</span><span class="nt">tbody</span><span class="p">></span>
        <span class="p"><</span><span class="nt">tr</span><span class="p">><</span><span class="nt">td</span><span class="p">></span>row-1-a<span class="p"></</span><span class="nt">td</span><span class="p">><</span><span class="nt">td</span><span class="p">></span>row-1-b<span class="p"></</span><span class="nt">td</span><span class="p">></</span><span class="nt">tr</span><span class="p">></span>
        <span class="p"><</span><span class="nt">tr</span><span class="p">><</span><span class="nt">td</span><span class="p">></span>row-2-a<span class="p"></</span><span class="nt">td</span><span class="p">><</span><span class="nt">td</span><span class="p">></span>row-2-b<span class="p"></</span><span class="nt">td</span><span class="p">></</span><span class="nt">tr</span><span class="p">></span>
      <span class="p"></</span><span class="nt">tbody</span><span class="p">></span>
    <span class="p"></</span><span class="nt">table</span><span class="p">></span>
    <span class="p"><</span><span class="nt">button</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-modal-trigger"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-modal-trigger"</span>
      <span class="na">onclick</span><span class="o">=</span><span class="s">"(function(){var m=document.getElementById('probe-modal');if(m){m.style.display=(m.style.display==='block'?'none':'block');}})()"</span><span class="p">></span>
      Toggle modal
    <span class="p"></</span><span class="nt">button</span><span class="p">></span>
    <span class="p"><</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">"probe-modal"</span> <span class="na">data-test</span><span class="o">=</span><span class="s">"probe-modal"</span> <span class="na">style</span><span class="o">=</span><span class="s">"display:none;"</span><span class="p">></span>
      <span class="p"><</span><span class="nt">p</span><span class="p">></span>Probe modal content (Plan 03-01 A29 IncrementalSnapshot trigger).<span class="p"></</span><span class="nt">p</span><span class="p">></span>
    <span class="p"></</span><span class="nt">div</span><span class="p">></span>
</code></pre></div><ol start="4" dir="auto">
<li>Verify no <code><textarea></code> was introduced (grep should return 0 hits on the modified file).</li>
<li>Verify the <code><head></code> block is unchanged.</li>
<li>No new dependencies, no other file edits in this task.
</action>
<verify>
<automated>grep -c "id="probe-form"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' && grep -c "id="probe-modal-trigger"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' && grep -c "id="probe-table"" tests/uat/extension-page-harness.html | grep -v '^#' | grep -q '^1$' && ! grep -q "textarea" tests/uat/extension-page-harness.html && grep -q '<link rel="stylesheet" href="../../src/shared/tokens.css">' tests/uat/extension-page-harness.html</automated>
</verify>
<acceptance_criteria>
<ul dir="auto">
<li><code>grep -c 'id="probe-form"' tests/uat/extension-page-harness.html</code> returns exactly <code>1</code>.</li>
<li><code>grep -c 'id="probe-modal-trigger"' tests/uat/extension-page-harness.html</code> returns exactly <code>1</code>.</li>
<li><code>grep -c 'id="probe-table"' tests/uat/extension-page-harness.html</code> returns exactly <code>1</code>.</li>
<li><code>grep -c 'id="probe-password"' tests/uat/extension-page-harness.html</code> returns exactly <code>1</code> (carried into Plan 03-03).</li>
<li><code>grep -c 'textarea' tests/uat/extension-page-harness.html</code> returns <code>0</code>.</li>
<li><code>grep -c '<link rel="stylesheet" href="../../src/shared/tokens.css">' tests/uat/extension-page-harness.html</code> returns exactly <code>1</code> (head unchanged).</li>
<li><code>grep -c 'data-mokosh-slot\|data-mokosh-key\|data-mokosh-i18n-key' tests/uat/extension-page-harness.html</code> returns <code>0</code>.</li>
<li><code>npm run build</code> exits 0.
</acceptance_criteria>
<done>
Probe HTML lives in the harness page below the existing scaffold;
head + script tag + existing body content all unchanged; no
textarea; ready for Task 2 to wire assertA29.
</done>
</task></li>
</ul>
</li>
</ol>
<task type="auto" tdd="false">
  <name>Task 2: Add assertA29 page-side stub + driveA29 host-side + harness.test.ts orchestrator wiring</name>
  <files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
  <read_first>
    - tests/uat/extension-page-harness.ts lines 3151-3413 (assertA26 stub + assertA27 multi-tab + assertA28 stub + declare global block + window.__mokoshHarness object literal — pattern for new assertion)
    - tests/uat/lib/harness-page-driver.ts lines 1395-1567 (findLatestZip + driveA26 3-phase pattern; canonical for host-side JSZip parse + EventType-style structural assertions)
    - tests/uat/lib/harness-page-driver.ts lines 36-46 (existing imports + JSZip + AssertionRecord/CheckRecord)
    - tests/uat/harness.test.ts lines 65-130 (existing driver imports + FORBIDDEN_HOOK_STRINGS at 12 entries)
    - tests/uat/harness.test.ts lines 316-431 (existing wrapped-driver consts + drivers-array push pattern)
    - node_modules/@rrweb/types/dist/index.d.ts lines 186-194 (EventType enum values; confirmed in interfaces above)
    - .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md §"EventType enum grep pattern (Plan 03-01)" and §"Page-side stub pattern" (canonical implementation)
  </read_first>
  <behavior>
    Page-side (`tests/uat/extension-page-harness.ts`):
    - Adds module-local constants `A29_SEGMENT_SETTLE_MS = 11_000`
      (matches A24/A25/A27 settle window).
    - Adds `async function assertA29(): Promise<AssertionResult>` that
      - dispatches a programmatic DOM mutation on the probe HTML:
        - sets `#probe-text.value = 'probe'` and dispatches `Event('input', { bubbles: true })`
        - clicks `#probe-modal-trigger` (toggles `#probe-modal` style.display)
      - waits 500 ms for rrweb to enqueue the IncrementalSnapshot
      - calls `setupFreshRecording()` then waits `A29_SEGMENT_SETTLE_MS`
      - dispatches `SAVE_ARCHIVE` via `sendMessageWithTimeout` (15s timeout, label 'SAVE_ARCHIVE (A29)')
      - pushes `A29.1: SAVE_ARCHIVE ack success===true` to checks
      - returns the `AssertionResult` (page-side computes ONLY the SAVE
        ack; the EventType-enum-shape checks live host-side in driveA29
        because @rrweb/types isn't bundled to the page).
    - Adds `assertA29: () => Promise<AssertionResult>;` to the
      `declare global { interface Window { __mokoshHarness: { ... } } }`
      block (preserve existing entries; append after assertA28).
    - Adds `assertA29,` to the `window.__mokoshHarness = { ... }`
      object literal (preserve existing entries; append after assertA28).
    - Update the closing console.log message line (currently at
      line 3411) to mention `+ Plan 03-01: A29`.
<pre><code>Host-side (`tests/uat/lib/harness-page-driver.ts`):
- Adds `import { EventType } from '@rrweb/types';` at the top of
  the imports section (after the JSZip import on line 41).
- Adds `export async function driveA29(page: Page, downloadsDir: string): Promise<AssertionRecord>` modeled on driveA26 (lines 1421-1567):
  - Phase 1 — page.evaluate `harness.assertA29()` → `pageResult`
  - Phase 2 — `findLatestZip(downloadsDir)`; if null push
    `A29.0` failure and return
  - Phase 3 — JSZip.loadAsync(buf); read `rrweb/session.json`
    entry; if absent push failed `A29.0a: rrweb/session.json
    present` check + return
  - Parse the JSON as `Array<{ type: number; timestamp: number }>`.
  - Push 4 checks (names verbatim):
    - `A29.1: rrweb/session.json contains > 0 events` —
      expected `>0`, actual `events.length`, passed `events.length > 0`
    - `A29.2: rrweb emitted at least one Meta event (EventType.Meta=4)` —
      expected `has Meta`, actual `events.some((e) => e.type === EventType.Meta)`, passed same
    - `A29.3: rrweb emitted at least one FullSnapshot (EventType.FullSnapshot=2)` —
      expected `has FullSnapshot`, actual `events.some((e) => e.type === EventType.FullSnapshot)`, passed same
    - `A29.4: rrweb emitted at least one IncrementalSnapshot (EventType.IncrementalSnapshot=3)` —
      expected `has IncrementalSnapshot`, actual `events.some((e) => e.type === EventType.IncrementalSnapshot)`, passed same
  - Merge with `pageResult.checks` (carries A29.1 SAVE ack already);
    recompute `mergedPassed = mergedChecks.every((c) => c.passed)`.
  - Push diagnostic `A29 zipPath=${zipPath}`,
    `A29 events.length=${events.length}`, and
    `A29 event types: ${[...new Set(events.map((e) => e.type))].sort().join(',')}`.

Orchestrator (`tests/uat/harness.test.ts`):
- Adds `driveA29,` to the import block from `./lib/harness-page-driver` (after `driveA28,`).
- Adds `const driveA29Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> = (page) => driveA29(page, handles.downloadsDir);` after the existing `driveA28Wrapped` const (mirrors A26/A28 host-side-needs-downloadsDir pattern).
- Adds `{ name: 'A29', drive: driveA29Wrapped },` to the `drivers` array after the A28 entry, with a banner comment block citing Plan 03-01 + SPEC §10 #4 + the chaining strategy (A29 owns its SAVE; injects DOM mutation pre-SAVE per RESEARCH Pitfall 1).
- FORBIDDEN_HOOK_STRINGS array stays at 12 entries (no new `__MOKOSH_UAT__`-gated symbols).
</code></pre>
  </behavior>
  <action>
1. Open `tests/uat/extension-page-harness.ts`. Locate the `assertA28` stub (currently around lines 3294-3316) and the `getManifestVersion` declaration block following it.
2. Insert the new `assertA29` block AFTER `assertA28` (and AFTER its end-of-file comments / before `getManifestVersion`). Use this concrete code:
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display"><span class="cm">/* ─── Plan 03-01 Task 2 — A29 (rrweb DOM verification; SPEC §10 #4) ─
</span><span class="cm"> *
</span><span class="cm"> *   A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (already
</span><span class="cm"> *         wired at src/content/index.ts:285) emits Meta + FullSnapshot
</span><span class="cm"> *         + at least one IncrementalSnapshot when the harness page
</span><span class="cm"> *         contains the probe HTML (form + table + modal) AND the driver
</span><span class="cm"> *         injects a DOM mutation before SAVE (RESEARCH Pitfall 1:
</span><span class="cm"> *         static probe HTML emits Meta + FullSnapshot but not
</span><span class="cm"> *         IncrementalSnapshot without mutation).
</span><span class="cm"> *
</span><span class="cm"> * Page side: dispatch the probe-page DOM mutations (input value +
</span><span class="cm"> * modal toggle), settle, setupFreshRecording, settle one segment,
</span><span class="cm"> * dispatch SAVE_ARCHIVE, push A29.1 ack check. The host-side driveA29
</span><span class="cm"> * does the EventType-enum-shape grep against rrweb/session.json from
</span><span class="cm"> * the assembled zip (matches A26's chained-assertion pattern; JSZip
</span><span class="cm"> * + @rrweb/types are host-only deps).
</span><span class="cm"> *
</span><span class="cm"> * FORBIDDEN_HOOK_STRINGS impact: NONE. A29 rides production rrweb
</span><span class="cm"> * wiring (record() at src/content/index.ts:285 + GET_RRWEB_EVENTS
</span><span class="cm"> * round-trip at src/background/index.ts → src/content/index.ts:318)
</span><span class="cm"> * + the existing setupFreshRecording / sendMessageWithTimeout
</span><span class="cm"> * helpers. Tier-1 inventory stays at 12 entries.
</span><span class="cm"> */</span>

<span class="cm">/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27. */</span>
<span class="kr">const</span> <span class="nx">A29_SAVE_ARCHIVE_TIMEOUT_MS</span> <span class="o">=</span> <span class="mi">15</span><span class="nx">_000</span><span class="p">;</span>
<span class="cm">/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */</span>
<span class="kr">const</span> <span class="nx">A29_SEGMENT_SETTLE_MS</span> <span class="o">=</span> <span class="mi">11</span><span class="nx">_000</span><span class="p">;</span>
<span class="cm">/** Settle window between DOM mutation and SAVE so rrweb's
</span><span class="cm"> *  IncrementalSnapshot lands in the in-memory buffer before
</span><span class="cm"> *  GET_RRWEB_EVENTS fires. */</span>
<span class="kr">const</span> <span class="nx">A29_MUTATION_SETTLE_MS</span> <span class="o">=</span> <span class="mi">500</span><span class="p">;</span>

<span class="cm">/**
</span><span class="cm"> * A29 — rrweb DOM event recording empirical (SPEC §10 #4).
</span><span class="cm"> *
</span><span class="cm"> * Page-side dispatches the probe-page mutation (input.value + modal
</span><span class="cm"> * toggle), settles, runs setupFreshRecording + segment-settle, then
</span><span class="cm"> * dispatches SAVE_ARCHIVE. Only the SAVE ack lives in the
</span><span class="cm"> * page-side checks; the EventType-enum-shape grep is host-side
</span><span class="cm"> * because @rrweb/types is host-only.
</span><span class="cm"> *
</span><span class="cm"> * @returns AssertionResult with 1 page-side check (SAVE ack); host-side
</span><span class="cm"> *          driveA29 appends A29.0/A29.1..A29.4 EventType checks.
</span><span class="cm"> */</span>
<span class="kr">async</span> <span class="kd">function</span> <span class="nx">assertA29</span><span class="p">()</span><span class="o">:</span> <span class="nx">Promise</span><span class="p"><</span><span class="nt">AssertionResult</span><span class="p">></span> <span class="p">{</span>
  <span class="kr">const</span> <span class="nx">result</span>: <span class="kt">AssertionResult</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">passed</span>: <span class="kt">false</span><span class="p">,</span>
    <span class="nx">name</span><span class="o">:</span> <span class="s1">'A29 — rrweb DOM events recorded without errors (SPEC §10 #4 / REQ-rrweb-dom-buffer)'</span><span class="p">,</span>
    <span class="nx">checks</span><span class="o">:</span> <span class="p">[],</span>
    <span class="nx">diagnostics</span><span class="o">:</span> <span class="p">[],</span>
  <span class="p">};</span>

  <span class="k">try</span> <span class="p">{</span>
    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="s1">'Step 1: dispatch probe-page DOM mutation (input value + modal toggle)'</span><span class="p">);</span>
    <span class="kr">const</span> <span class="nx">textInput</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p"><</span><span class="nt">HTMLInputElement</span><span class="p">>(</span><span class="s1">'#probe-text'</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">textInput</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">textInput</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">'probe'</span><span class="p">;</span>
      <span class="nx">textInput</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="s1">'input'</span><span class="p">,</span> <span class="p">{</span> <span class="nx">bubbles</span>: <span class="kt">true</span> <span class="p">}));</span>
    <span class="p">}</span>
    <span class="kr">const</span> <span class="nx">modalTrigger</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p"><</span><span class="nt">HTMLButtonElement</span><span class="p">>(</span><span class="s1">'#probe-modal-trigger'</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">modalTrigger</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">modalTrigger</span><span class="p">.</span><span class="nx">click</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="sb">`Step 1 OK — mutations dispatched (#probe-text=</span><span class="si">${</span><span class="nx">textInput</span> <span class="o">!==</span> <span class="kc">null</span><span class="si">}</span><span class="sb">, #probe-modal-trigger=</span><span class="si">${</span><span class="nx">modalTrigger</span> <span class="o">!==</span> <span class="kc">null</span><span class="si">}</span><span class="sb">)`</span><span class="p">);</span>

    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="sb">`Step 2: settle </span><span class="si">${</span><span class="nx">A29_MUTATION_SETTLE_MS</span><span class="si">}</span><span class="sb">ms for rrweb IncrementalSnapshot to enqueue`</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">new</span> <span class="nx">Promise</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=></span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="nx">A29_MUTATION_SETTLE_MS</span><span class="p">));</span>

    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="s1">'Step 3: setupFreshRecording'</span><span class="p">);</span>
    <span class="kr">const</span> <span class="nx">setupResp</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">setupFreshRecording</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">setupResp</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="sb">`setupFreshRecording failed: </span><span class="si">${</span><span class="nx">setupResp</span><span class="p">.</span><span class="nx">error</span> <span class="o">??</span> <span class="s1">'(no error)'</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="s1">'Step 3 OK — REC state established'</span><span class="p">);</span>

    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="sb">`Step 4: settle </span><span class="si">${</span><span class="nx">A29_SEGMENT_SETTLE_MS</span><span class="si">}</span><span class="sb">ms for first segment rotation`</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">new</span> <span class="nx">Promise</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=></span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="nx">A29_SEGMENT_SETTLE_MS</span><span class="p">));</span>

    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="s1">'Step 5: dispatch SAVE_ARCHIVE'</span><span class="p">);</span>
    <span class="kr">const</span> <span class="nx">ack</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">sendMessageWithTimeout</span><span class="o"><</span><span class="p">{</span> <span class="nx">success</span>: <span class="kt">boolean</span><span class="p">;</span> <span class="nx">error?</span>: <span class="kt">string</span> <span class="p">}</span><span class="o">></span><span class="p">(</span>
      <span class="p">{</span> <span class="kr">type</span><span class="o">:</span> <span class="s1">'SAVE_ARCHIVE'</span> <span class="p">},</span>
      <span class="nx">A29_SAVE_ARCHIVE_TIMEOUT_MS</span><span class="p">,</span>
      <span class="s1">'SAVE_ARCHIVE (A29)'</span><span class="p">,</span>
    <span class="p">);</span>
    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="sb">`Step 5 result: </span><span class="si">${</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">ack</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>

    <span class="nx">result</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
      <span class="nx">name</span><span class="o">:</span> <span class="s1">'A29.1: SAVE_ARCHIVE ack received with success=true'</span><span class="p">,</span>
      <span class="nx">expected</span>: <span class="kt">true</span><span class="p">,</span>
      <span class="nx">actual</span>: <span class="kt">ack.success</span><span class="p">,</span>
      <span class="nx">passed</span>: <span class="kt">ack.success</span> <span class="o">===</span> <span class="kc">true</span><span class="p">,</span>
    <span class="p">});</span>

    <span class="nx">result</span><span class="p">.</span><span class="nx">passed</span> <span class="o">=</span> <span class="nx">result</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">every</span><span class="p">((</span><span class="nx">c</span><span class="p">)</span> <span class="o">=></span> <span class="nx">c</span><span class="p">.</span><span class="nx">passed</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">result</span><span class="p">.</span><span class="nx">error</span> <span class="o">=</span> <span class="nx">err</span> <span class="k">instanceof</span> <span class="nb">Error</span> <span class="o">?</span> <span class="nx">err.message</span> : <span class="kt">String</span><span class="p">(</span><span class="nx">err</span><span class="p">);</span>
    <span class="nx">diag</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="sb">`THREW: </span><span class="si">${</span><span class="nx">result</span><span class="p">.</span><span class="nx">error</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">return</span> <span class="nx">result</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div><ol start="3" dir="auto">
<li>In the SAME file, locate the <code>declare global { interface Window { __mokoshHarness: { ... } } }</code> block (around lines 3332-3372). Inside the type literal, AFTER the existing <code>assertA28: () => Promise<AssertionResult>;</code> entry and BEFORE <code>getManifestVersion</code>, insert:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display">      <span class="c1">// Plan 03-01 — rrweb DOM verification (SPEC §10 #4)
</span><span class="c1"></span>      <span class="nx">assertA29</span><span class="o">:</span> <span class="p">()</span> <span class="o">=></span> <span class="nx">Promise</span><span class="p"><</span><span class="nt">AssertionResult</span><span class="p">>;</span>
</code></pre></div><ol start="4" dir="auto">
<li>Locate the <code>window.__mokoshHarness = { ... }</code> object literal (around lines 3374-3404). After <code>assertA28,</code> and before <code>getManifestVersion,</code> insert:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display">  <span class="nx">assertA29</span><span class="p">,</span>
</code></pre></div><ol start="5" dir="auto">
<li>Locate the trailing <code>console.log('[harness-page] ready — ...')</code> line (around line 3411) and replace the message to append <code>+ Plan 03-01: A29</code>. Concrete new line:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">'[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + getManifestVersion)'</span><span class="p">);</span>
</code></pre></div><ol start="6" dir="auto">
<li>Open <code>tests/uat/lib/harness-page-driver.ts</code>. In the import block (after the <code>import JSZip from 'jszip';</code> line — around line 41), add:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display"><span class="kr">import</span> <span class="p">{</span> <span class="nx">EventType</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">'@rrweb/types'</span><span class="p">;</span>
</code></pre></div><ol start="7" dir="auto">
<li>At the end of the file (after the existing <code>driveA28</code> export), append the new <code>driveA29</code> export:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display">
<span class="cm">/* ─── Plan 03-01 — driveA29 (rrweb DOM verification host-side) ─────── */</span>

<span class="cm">/**
</span><span class="cm"> * Drive A29 (Plan 03-01 — SPEC §10 #4 / REQ-rrweb-dom-buffer).
</span><span class="cm"> *
</span><span class="cm"> * Three-phase orchestration:
</span><span class="cm"> *   1. Page-side assertA29 dispatches the probe DOM mutations, settles,
</span><span class="cm"> *      runs setupFreshRecording, settles a segment, dispatches SAVE.
</span><span class="cm"> *      Returns AssertionRecord with A29.1 (SAVE ack) only.
</span><span class="cm"> *   2. Host-side findLatestZip picks the just-produced zip (mtime-sort
</span><span class="cm"> *      wins; race-free per Plan 02-04 precedent — assertA29 awaits the
</span><span class="cm"> *      SAVE ack before returning so the zip has landed by here).
</span><span class="cm"> *   3. Host-side JSZip-parse rrweb/session.json + EventType-enum grep.
</span><span class="cm"> *
</span><span class="cm"> * Checks appended host-side:
</span><span class="cm"> *   - A29.0a: rrweb/session.json entry exists in zip
</span><span class="cm"> *   - A29.1 (already from page side): SAVE_ARCHIVE ack success
</span><span class="cm"> *   - A29.2: events.length > 0
</span><span class="cm"> *   - A29.3: events.some(e => e.type === EventType.Meta)
</span><span class="cm"> *   - A29.4: events.some(e => e.type === EventType.FullSnapshot)
</span><span class="cm"> *   - A29.5: events.some(e => e.type === EventType.IncrementalSnapshot)
</span><span class="cm"> *
</span><span class="cm"> * RESEARCH Pitfall 1 mitigation: the page-side dispatches a real DOM
</span><span class="cm"> * mutation (input value + modal toggle) BEFORE SAVE so the
</span><span class="cm"> * IncrementalSnapshot check (A29.5) has actual content to find.
</span><span class="cm"> *
</span><span class="cm"> * @param page         - The harness page from `launchHarnessBrowser`.
</span><span class="cm"> * @param downloadsDir - Absolute path to the per-run downloads directory.
</span><span class="cm"> * @returns AssertionRecord with 6 merged checks (A29.0a + A29.1..A29.5).
</span><span class="cm"> */</span>
<span class="kr">export</span> <span class="kr">async</span> <span class="kd">function</span> <span class="nx">driveA29</span><span class="p">(</span>
  <span class="nx">page</span>: <span class="kt">Page</span><span class="p">,</span>
  <span class="nx">downloadsDir</span>: <span class="kt">string</span><span class="p">,</span>
<span class="p">)</span><span class="o">:</span> <span class="nx">Promise</span><span class="p"><</span><span class="nt">AssertionRecord</span><span class="p">></span> <span class="p">{</span>
  <span class="c1">// Phase 1 — page-side orchestration + SAVE.
</span><span class="c1"></span>  <span class="kr">const</span> <span class="nx">pageResult</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(</span><span class="kr">async</span> <span class="p">()</span> <span class="o">=></span> <span class="p">{</span>
    <span class="c1">// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
</span><span class="c1"></span>    <span class="kr">const</span> <span class="nx">harness</span> <span class="o">=</span> <span class="p">(</span><span class="nb">window</span> <span class="kr">as</span> <span class="kt">any</span><span class="p">).</span><span class="nx">__mokoshHarness</span><span class="p">;</span>
    <span class="kr">const</span> <span class="nx">r</span>: <span class="kt">AssertionRecord</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">harness</span><span class="p">.</span><span class="nx">assertA29</span><span class="p">();</span>
    <span class="k">return</span> <span class="nx">r</span><span class="p">;</span>
  <span class="p">})</span> <span class="kr">as</span> <span class="nx">AssertionRecord</span><span class="p">;</span>

  <span class="kr">const</span> <span class="nx">mergedChecks</span>: <span class="kt">CheckRecord</span><span class="p">[]</span> <span class="o">=</span> <span class="nx">pageResult</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">slice</span><span class="p">();</span>
  <span class="kr">const</span> <span class="nx">mergedDiagnostics</span>: <span class="kt">string</span><span class="p">[]</span> <span class="o">=</span> <span class="nx">pageResult</span><span class="p">.</span><span class="nx">diagnostics</span><span class="p">.</span><span class="nx">slice</span><span class="p">();</span>

  <span class="c1">// Phase 2 — locate the produced zip.
</span><span class="c1"></span>  <span class="kr">const</span> <span class="nx">zipPath</span> <span class="o">=</span> <span class="nx">findLatestZip</span><span class="p">(</span><span class="nx">downloadsDir</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">zipPath</span> <span class="o">===</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
      <span class="nx">name</span><span class="o">:</span> <span class="s1">'A29.0: at least one zip present in downloadsDir'</span><span class="p">,</span>
      <span class="nx">expected</span><span class="o">:</span> <span class="s1">'>=1 zip'</span><span class="p">,</span>
      <span class="nx">actual</span><span class="o">:</span> <span class="s1">'no zip in downloadsDir'</span><span class="p">,</span>
      <span class="nx">passed</span>: <span class="kt">false</span><span class="p">,</span>
    <span class="p">});</span>
    <span class="k">return</span> <span class="p">{</span>
      <span class="nx">passed</span>: <span class="kt">false</span><span class="p">,</span>
      <span class="nx">name</span>: <span class="kt">pageResult.name</span><span class="p">,</span>
      <span class="nx">checks</span>: <span class="kt">mergedChecks</span><span class="p">,</span>
      <span class="nx">diagnostics</span>: <span class="kt">mergedDiagnostics</span><span class="p">,</span>
      <span class="nx">error</span>: <span class="kt">pageResult.error</span><span class="p">,</span>
    <span class="p">};</span>
  <span class="p">}</span>
  <span class="nx">mergedDiagnostics</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`A29 zipPath=</span><span class="si">${</span><span class="nx">zipPath</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>

  <span class="c1">// Phase 3 — load + inspect rrweb/session.json.
</span><span class="c1"></span>  <span class="kr">const</span> <span class="nx">zipBytes</span> <span class="o">=</span> <span class="nx">readFileSync</span><span class="p">(</span><span class="nx">zipPath</span><span class="p">);</span>
  <span class="kr">const</span> <span class="nx">zip</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">JSZip</span><span class="p">.</span><span class="nx">loadAsync</span><span class="p">(</span><span class="nx">zipBytes</span><span class="p">);</span>
  <span class="kr">const</span> <span class="nx">rrwebFile</span> <span class="o">=</span> <span class="nx">zip</span><span class="p">.</span><span class="nx">file</span><span class="p">(</span><span class="s1">'rrweb/session.json'</span><span class="p">);</span>
  <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
    <span class="nx">name</span><span class="o">:</span> <span class="s1">'A29.0a: rrweb/session.json entry exists in zip'</span><span class="p">,</span>
    <span class="nx">expected</span>: <span class="kt">true</span><span class="p">,</span>
    <span class="nx">actual</span>: <span class="kt">rrwebFile</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">,</span>
    <span class="nx">passed</span>: <span class="kt">rrwebFile</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">,</span>
  <span class="p">});</span>

  <span class="k">if</span> <span class="p">(</span><span class="nx">rrwebFile</span> <span class="o">===</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span>
      <span class="nx">passed</span>: <span class="kt">false</span><span class="p">,</span>
      <span class="nx">name</span>: <span class="kt">pageResult.name</span><span class="p">,</span>
      <span class="nx">checks</span>: <span class="kt">mergedChecks</span><span class="p">,</span>
      <span class="nx">diagnostics</span>: <span class="kt">mergedDiagnostics</span><span class="p">,</span>
      <span class="nx">error</span>: <span class="kt">pageResult.error</span><span class="p">,</span>
    <span class="p">};</span>
  <span class="p">}</span>

  <span class="kr">const</span> <span class="nx">rrwebText</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">rrwebFile</span><span class="p">.</span><span class="kr">async</span><span class="p">(</span><span class="s1">'string'</span><span class="p">);</span>
  <span class="kd">let</span> <span class="nx">events</span>: <span class="kt">Array</span><span class="o"><</span><span class="p">{</span> <span class="kr">type</span><span class="o">:</span> <span class="kt">number</span><span class="p">;</span> <span class="nx">timestamp</span>: <span class="kt">number</span> <span class="p">}</span><span class="o">></span> <span class="o">=</span> <span class="p">[];</span>
  <span class="kd">let</span> <span class="nx">parseErr</span>: <span class="kt">string</span> <span class="o">|</span> <span class="kc">null</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="nx">events</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">rrwebText</span><span class="p">)</span> <span class="kr">as</span> <span class="nb">Array</span><span class="o"><</span><span class="p">{</span> <span class="kr">type</span><span class="o">:</span> <span class="kt">number</span><span class="p">;</span> <span class="nx">timestamp</span>: <span class="kt">number</span> <span class="p">}</span><span class="o">></span><span class="p">;</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">parseErr</span> <span class="o">=</span> <span class="nx">err</span> <span class="k">instanceof</span> <span class="nb">Error</span> <span class="o">?</span> <span class="nx">err.message</span> : <span class="kt">String</span><span class="p">(</span><span class="nx">err</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">if</span> <span class="p">(</span><span class="nx">parseErr</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
      <span class="nx">name</span><span class="o">:</span> <span class="s1">'A29.0b: rrweb/session.json parses as JSON'</span><span class="p">,</span>
      <span class="nx">expected</span><span class="o">:</span> <span class="s1">'JSON.parse success'</span><span class="p">,</span>
      <span class="nx">actual</span><span class="o">:</span> <span class="sb">`<error: </span><span class="si">${</span><span class="nx">parseErr</span><span class="si">}</span><span class="sb">>`</span><span class="p">,</span>
      <span class="nx">passed</span>: <span class="kt">false</span><span class="p">,</span>
    <span class="p">});</span>
    <span class="k">return</span> <span class="p">{</span>
      <span class="nx">passed</span>: <span class="kt">false</span><span class="p">,</span>
      <span class="nx">name</span>: <span class="kt">pageResult.name</span><span class="p">,</span>
      <span class="nx">checks</span>: <span class="kt">mergedChecks</span><span class="p">,</span>
      <span class="nx">diagnostics</span>: <span class="kt">mergedDiagnostics</span><span class="p">,</span>
      <span class="nx">error</span>: <span class="kt">pageResult.error</span><span class="p">,</span>
    <span class="p">};</span>
  <span class="p">}</span>

  <span class="kr">const</span> <span class="nx">distinctTypes</span> <span class="o">=</span> <span class="p">[...</span><span class="k">new</span> <span class="nx">Set</span><span class="p">(</span><span class="nx">events</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="nx">e</span><span class="p">.</span><span class="kr">type</span><span class="p">))].</span><span class="nx">sort</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=></span> <span class="nx">a</span> <span class="o">-</span> <span class="nx">b</span><span class="p">);</span>
  <span class="nx">mergedDiagnostics</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`A29 events.length=</span><span class="si">${</span><span class="nx">events</span><span class="p">.</span><span class="nx">length</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
  <span class="nx">mergedDiagnostics</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`A29 event types: </span><span class="si">${</span><span class="nx">distinctTypes</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s1">','</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>

  <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
    <span class="nx">name</span><span class="o">:</span> <span class="s1">'A29.2: rrweb/session.json contains > 0 events'</span><span class="p">,</span>
    <span class="nx">expected</span><span class="o">:</span> <span class="s1">'>0'</span><span class="p">,</span>
    <span class="nx">actual</span>: <span class="kt">events.length</span><span class="p">,</span>
    <span class="nx">passed</span>: <span class="kt">events.length</span> <span class="o">></span> <span class="mi">0</span><span class="p">,</span>
  <span class="p">});</span>
  <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
    <span class="nx">name</span><span class="o">:</span> <span class="sb">`A29.3: rrweb emitted at least one Meta event (EventType.Meta=</span><span class="si">${</span><span class="nx">EventType</span><span class="p">.</span><span class="nx">Meta</span><span class="si">}</span><span class="sb">)`</span><span class="p">,</span>
    <span class="nx">expected</span><span class="o">:</span> <span class="s1">'has Meta'</span><span class="p">,</span>
    <span class="nx">actual</span>: <span class="kt">events.some</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="nx">e</span><span class="p">.</span><span class="kr">type</span> <span class="o">===</span> <span class="nx">EventType</span><span class="p">.</span><span class="nx">Meta</span><span class="p">),</span>
    <span class="nx">passed</span>: <span class="kt">events.some</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="nx">e</span><span class="p">.</span><span class="kr">type</span> <span class="o">===</span> <span class="nx">EventType</span><span class="p">.</span><span class="nx">Meta</span><span class="p">),</span>
  <span class="p">});</span>
  <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
    <span class="nx">name</span><span class="o">:</span> <span class="sb">`A29.4: rrweb emitted at least one FullSnapshot (EventType.FullSnapshot=</span><span class="si">${</span><span class="nx">EventType</span><span class="p">.</span><span class="nx">FullSnapshot</span><span class="si">}</span><span class="sb">)`</span><span class="p">,</span>
    <span class="nx">expected</span><span class="o">:</span> <span class="s1">'has FullSnapshot'</span><span class="p">,</span>
    <span class="nx">actual</span>: <span class="kt">events.some</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="nx">e</span><span class="p">.</span><span class="kr">type</span> <span class="o">===</span> <span class="nx">EventType</span><span class="p">.</span><span class="nx">FullSnapshot</span><span class="p">),</span>
    <span class="nx">passed</span>: <span class="kt">events.some</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="nx">e</span><span class="p">.</span><span class="kr">type</span> <span class="o">===</span> <span class="nx">EventType</span><span class="p">.</span><span class="nx">FullSnapshot</span><span class="p">),</span>
  <span class="p">});</span>
  <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
    <span class="nx">name</span><span class="o">:</span> <span class="sb">`A29.5: rrweb emitted at least one IncrementalSnapshot (EventType.IncrementalSnapshot=</span><span class="si">${</span><span class="nx">EventType</span><span class="p">.</span><span class="nx">IncrementalSnapshot</span><span class="si">}</span><span class="sb">)`</span><span class="p">,</span>
    <span class="nx">expected</span><span class="o">:</span> <span class="s1">'has IncrementalSnapshot'</span><span class="p">,</span>
    <span class="nx">actual</span>: <span class="kt">events.some</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="nx">e</span><span class="p">.</span><span class="kr">type</span> <span class="o">===</span> <span class="nx">EventType</span><span class="p">.</span><span class="nx">IncrementalSnapshot</span><span class="p">),</span>
    <span class="nx">passed</span>: <span class="kt">events.some</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="nx">e</span><span class="p">.</span><span class="kr">type</span> <span class="o">===</span> <span class="nx">EventType</span><span class="p">.</span><span class="nx">IncrementalSnapshot</span><span class="p">),</span>
  <span class="p">});</span>

  <span class="kr">const</span> <span class="nx">mergedPassed</span> <span class="o">=</span> <span class="nx">mergedChecks</span><span class="p">.</span><span class="nx">every</span><span class="p">((</span><span class="nx">c</span><span class="p">)</span> <span class="o">=></span> <span class="nx">c</span><span class="p">.</span><span class="nx">passed</span><span class="p">);</span>
  <span class="k">return</span> <span class="p">{</span>
    <span class="nx">passed</span>: <span class="kt">mergedPassed</span><span class="p">,</span>
    <span class="nx">name</span>: <span class="kt">pageResult.name</span><span class="p">,</span>
    <span class="nx">checks</span>: <span class="kt">mergedChecks</span><span class="p">,</span>
    <span class="nx">diagnostics</span>: <span class="kt">mergedDiagnostics</span><span class="p">,</span>
    <span class="nx">error</span>: <span class="kt">pageResult.error</span><span class="p">,</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div><ol start="8" dir="auto">
<li>Open <code>tests/uat/harness.test.ts</code>. In the import block from <code>./lib/harness-page-driver</code> (around lines 65-101), AFTER <code>driveA28,</code> and BEFORE <code>getManifestVersion,</code>, add:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display">  <span class="c1">// Plan 03-01 — rrweb DOM verification (SPEC §10 #4 / REQ-rrweb-dom-buffer)
</span><span class="c1"></span>  <span class="nx">driveA29</span><span class="p">,</span>
</code></pre></div><ol start="9" dir="auto">
<li>In the same file, locate the existing <code>driveA28Wrapped</code> const (around lines 330-335). AFTER it, add:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display">  <span class="c1">// Plan 03-01 — driveA29 needs downloadsDir for host-side JSZip parse
</span><span class="c1"></span>  <span class="c1">// of rrweb/session.json from the just-produced zip.
</span><span class="c1"></span>  <span class="kr">const</span> <span class="nx">driveA29Wrapped</span><span class="o">:</span> <span class="p">(</span><span class="nx">page</span>: <span class="kt">import</span><span class="p">(</span><span class="s1">'puppeteer'</span><span class="p">).</span><span class="nx">Page</span><span class="p">)</span> <span class="o">=></span> <span class="nx">Promise</span><span class="p"><</span><span class="nt">AssertionRecord</span><span class="p">></span> <span class="o">=</span>
    <span class="p">(</span><span class="nx">page</span><span class="p">)</span> <span class="o">=></span> <span class="nx">driveA29</span><span class="p">(</span><span class="nx">page</span><span class="p">,</span> <span class="nx">handles</span><span class="p">.</span><span class="nx">downloadsDir</span><span class="p">);</span>
</code></pre></div><ol start="10" dir="auto">
<li>In the <code>drivers</code> array (around lines 337-431), AFTER the existing <code>{ name: 'A28', drive: driveA28Wrapped },</code> entry, add:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display">    <span class="c1">// Plan 03-01 A29: rrweb DOM verification (SPEC §10 #4).
</span><span class="c1"></span>    <span class="c1">// A29 owns its SAVE because the probe-page DOM mutation must
</span><span class="c1"></span>    <span class="c1">// happen between page load and SAVE so rrweb's IncrementalSnapshot
</span><span class="c1"></span>    <span class="c1">// fires (RESEARCH Pitfall 1). Host-side driveA29 JSZip-parses
</span><span class="c1"></span>    <span class="c1">// rrweb/session.json and asserts the EventType enum surfaces
</span><span class="c1"></span>    <span class="c1">// (Meta=4, FullSnapshot=2, IncrementalSnapshot=3) are present.
</span><span class="c1"></span>    <span class="p">{</span> <span class="nx">name</span><span class="o">:</span> <span class="s1">'A29'</span><span class="p">,</span> <span class="nx">drive</span>: <span class="kt">driveA29Wrapped</span> <span class="p">},</span>
</code></pre></div><ol start="11" dir="auto">
<li>Update the orchestrator banner string (currently at line 268: <code>'Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28)'</code>) to append <code>, A29</code>. Concrete replacement string for line 268:</li>
</ol>
<div data-attr-class="lTYvUrHT6Bbeoi-1:code-block-container code-overflow-scroll"><pre data-attr-class="lTYvUrHT6Bbeoi-1:code-block"><code data-attr-class="lTYvUrHT6Bbeoi-1:chroma language-typescript display">  <span class="nx">process</span><span class="p">.</span><span class="nx">stdout</span><span class="p">.</span><span class="nx">write</span><span class="p">(</span><span class="s1">'Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29)\n'</span><span class="p">);</span>
</code></pre></div><ol start="12" dir="auto">
<li>Run <code>npx tsc --noEmit</code> to confirm no type errors. Expected: clean.</li>
<li>Run <code>SKIP_PROD_REBUILD=0 HEADLESS=1 npm run test:uat</code> (full rebuild + harness). Expected: <code>30/30 GREEN</code> (29 prior + A29).
</action>
<verify>
<automated>npx tsc --noEmit && grep -c "assertA29" tests/uat/extension-page-harness.ts | grep -v '^#' | awk '$1 >= 3' && grep -c "driveA29" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | awk '$1 >= 2' && grep -c "driveA29" tests/uat/harness.test.ts | grep -v '^#' | awk '$1 >= 3' && grep -c "from '@rrweb/types'" tests/uat/lib/harness-page-driver.ts | grep -v '^#' | grep -q '^1$' && HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat</automated>
</verify>
<acceptance_criteria>
<ul dir="auto">
<li><code>npx tsc --noEmit</code> exits 0.</li>
<li><code>grep -c 'assertA29' tests/uat/extension-page-harness.ts</code> returns >=3 (function definition + declare global entry + object literal entry).</li>
<li><code>grep -c 'driveA29' tests/uat/lib/harness-page-driver.ts</code> returns >=2 (function definition + docstring or signature mention).</li>
<li><code>grep -c 'driveA29' tests/uat/harness.test.ts</code> returns >=3 (import line + wrapped const + drivers array push).</li>
<li><code>grep -c "from '@rrweb/types'" tests/uat/lib/harness-page-driver.ts</code> returns exactly 1.</li>
<li><code>HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat</code> exits 0 with stdout containing <code>UAT harness: 30/30 assertions passed</code>.</li>
<li><code>npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts</code> exits 0 (Tier-1 inventory stays at 12 entries; verifies no new hook leaks).
</acceptance_criteria>
<done>
UAT harness runs 30/30 GREEN with A29 covering SPEC §10 #4 end-to-end:
rrweb session.json from the assembled zip contains > 0 events with
Meta + FullSnapshot + IncrementalSnapshot EventType-enum members
all present. FORBIDDEN_HOOK_STRINGS unchanged at 12.
</done>
</task></li>
</ul>
</li>
</ol>
</tasks>
<p dir="auto"><threat_model></p>
<h2 id="user-content-trust-boundaries" dir="auto">Trust Boundaries</h2>
<table>
<thead>
<tr>
<th>Boundary</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>Puppeteer host ↔ page realm</td>
<td>Test harness drives the page via page.evaluate; no production data flows out</td>
</tr>
<tr>
<td>Page realm ↔ content script</td>
<td>Existing GET_RRWEB_EVENTS round-trip via chrome.runtime.sendMessage</td>
</tr>
<tr>
<td>Content script ↔ SW</td>
<td>Existing chrome.tabs.sendMessage path (read-only for Plan 03-01)</td>
</tr>
<tr>
<td>dist-test/ ↔ dist/</td>
<td>Two-bundle separation: Plan 03-01 adds NO test-only symbols; production bundle invariant unchanged</td>
</tr>
</tbody>
</table>
<h2 id="user-content-stride-threat-register" dir="auto">STRIDE Threat Register</h2>
<table>
<thead>
<tr>
<th>Threat ID</th>
<th>Category</th>
<th>Component</th>
<th>Disposition</th>
<th>Mitigation Plan</th>
</tr>
</thead>
<tbody>
<tr>
<td>T-03-01-01</td>
<td>Information Disclosure</td>
<td>Probe HTML password input visible in DOM</td>
<td>accept</td>
<td>Sentinel value is not introduced by Plan 03-01 (Plan 03-03 types the negative-assertion sentinel). Plan 03-01 leaves the password input empty; rrweb's existing maskInputOptions.password=true at src/content/index.ts:306 masks any value that lands.</td>
</tr>
<tr>
<td>T-03-01-02</td>
<td>Tampering</td>
<td>New probe HTML elements interfere with A18/A21 token-resolution checks</td>
<td>mitigate</td>
<td>UI-SPEC ban enforced: probe HTML appends BELOW existing scaffold; head + tokens.css link untouched; probe sub-tree uses no var(--mks-*) tokens; verification grep at task acceptance proves the existing link line is preserved.</td>
</tr>
<tr>
<td>T-03-01-03</td>
<td>Information Disclosure</td>
<td>Test-only hook surface leaking to production bundle</td>
<td>mitigate</td>
<td>A29 rides production rrweb wiring + existing GET_RRWEB_EVENTS bridge + sendMessageWithTimeout helper. FORBIDDEN_HOOK_STRINGS inventory unchanged at 12 entries; Tier-1 unit-test gate runs as part of Task 2 acceptance.</td>
</tr>
<tr>
<td>T-03-01-04</td>
<td>Denial of Service</td>
<td>rrweb cleanupOldEvents fires mid-A29 + drops the IncrementalSnapshot before SAVE</td>
<td>accept</td>
<td>A29 settles 500ms after mutation + 11s segment-settle + SAVE. CLEANUP_INTERVAL_MS=60s + retention=10min at src/content/index.ts:16-18 — A29's wall-clock <12s budget is far below the cleanup window; mutation event safely retained.</td>
</tr>
</tbody>
</table>
<p dir="auto">No new production surface; threat surface unchanged from Phase 2
(CON-manifest-permissions intact including DEC-011 Amendment 1 tabs
permission). UAT harness extension is test-only and tree-shakes from
production bundle via the existing <code>__MOKOSH_UAT__</code> Vite-define-token —
A29 introduces no new <code>__MOKOSH_UAT__</code>-gated symbols.
</threat_model></p>
<verification>
- `npx tsc --noEmit` exits 0.
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with 30/30 GREEN.
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (12 FORBIDDEN_HOOK_STRINGS × 0 hits each).
- `grep -c '<textarea' tests/uat/extension-page-harness.html` returns 0.
- `grep -c '<link rel="stylesheet" href="../../src/shared/tokens.css">' tests/uat/extension-page-harness.html` returns 1.
</verification>
<p dir="auto"><success_criteria></p>
<ul dir="auto">
<li>A29 GREEN in UAT harness across 6 host-side checks (A29.0a + A29.1..A29.5).</li>
<li>Probe HTML committed to harness page in correct location; head + script tag preserved.</li>
<li>FORBIDDEN_HOOK_STRINGS unchanged at 12 entries.</li>
<li>vitest baseline preserved (171/171 GREEN; SKIP_BUILD=1 path).</li>
<li>REQ-rrweb-dom-buffer empirically verified end-to-end through rrweb's
<code>record()</code> wiring + GET_RRWEB_EVENTS bridge + the assembled zip's
rrweb/session.json content.
</success_criteria></li>
</ul>
<output>
After completion, create
`.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-SUMMARY.md`
documenting:
- 4-check A29 contract verified (Meta + FullSnapshot + IncrementalSnapshot + count > 0)
- Probe HTML composition rationale (form + table + modal; NO textarea)
- DOM-mutation injection pattern (input value + modal click)
- RESEARCH Pitfall 1 mitigation confirmed empirically
- UAT 29 → 30 GREEN; Tier-1 inventory unchanged at 12
- Plan 03-02 wave dependency: SAME 3 harness files; SEQUENTIAL within Wave 2 per RESEARCH Pitfall 6
</output>
</body></html>