--- phase: 04 slug: harden-clean-up-optional plan: 03 type: execute wave: 2 depends_on: - 01 - 02 files_modified: - tests/uat/extension-page-harness.ts - tests/uat/lib/harness-page-driver.ts autonomous: true requirements: [] tags: - uat-harness - a29-rewrite - cs-injection-world - flake-stabilization - strict-sentinel - rrweb - charter-d-p4-01 user_setup: [] must_haves: truths: - "assertA29 follows the cs-injection-world pattern verbatim ported from assertA30/A31 (chrome.tabs.create + chrome.scripting.executeScript ISOLATED + chrome.tabs.remove cleanup in finally)" - "Injected DOM mutation carries a unique sentinel string ('a29-mutation-sentinel') that rrweb's MutationObserver captures inside the IncrementalSnapshot payload" - "Host-side driveA29 filters rrweb events for `data.source === IncrementalSource.Mutation` AND descends into `data.adds[*].node.textContent` looking for the sentinel string" - "A29 PASS rate flips from ~2/3 to 5/5 across consecutive `npm run test:uat` runs (closes iana.org-leftover-flake gap documented in Plan 03-02 SUMMARY)" - "UAT harness count remains 33/33 GREEN; no new assertions added; A29 rewrite is in-place" artifacts: - path: "tests/uat/extension-page-harness.ts" provides: "assertA29 rewritten to use cs-injection-world pattern with sentinel-bearing mutation injection" contains: "a29-mutation-sentinel" - path: "tests/uat/lib/harness-page-driver.ts" provides: "driveA29 rewritten with strict-sentinel rrweb event filter (IncrementalSource.Mutation + textContent sentinel grep)" contains: "IncrementalSource.Mutation" key_links: - from: "tests/uat/extension-page-harness.ts assertA29" to: "chrome.tabs.create({url:'https://example.com/', active:true}) + chrome.scripting.executeScript ISOLATED" via: "verbatim cs-injection-world skeleton from assertA30 lines 3517-3636" pattern: "world: 'ISOLATED'" - from: "tests/uat/lib/harness-page-driver.ts driveA29" to: "@rrweb/types EventType + IncrementalSource enums" via: "import + filter pipeline" pattern: "IncrementalSource\\.Mutation" - from: "tests/uat/lib/harness-page-driver.ts driveA29 strict-sentinel filter" to: "rrweb/session.json events array adds[*].node.textContent" via: "events.filter(e => e.type===EventType.IncrementalSnapshot && e.data?.source===IncrementalSource.Mutation).filter(e => e.data?.adds?.some(a => a?.node?.textContent?.includes(sentinel)))" pattern: "a29-mutation-sentinel" --- Rewrite the A29 harness assertion (rrweb DOM verification) to use the canonical cs-injection-world pattern from Plan 03-02 (assertA30) instead of the harness-page approach Plan 03-01 originally used. The current A29 "PASSES" by reading leftover iana.org DOM mutations that A27/A28's probe tabs leave open at the moment SAVE_ARCHIVE fires (~2/3 success rate; documented as a pre-existing flake in Plan 03-02 + 03-03 SUMMARYs). The fix per RESEARCH Q3: 1. Open a fresh `https://example.com/` probe tab via `chrome.tabs.create` (canonical RFC 2606 reserved domain; matches A30/A31). 2. Wait the canonical 1.5s for content-script attach (mirrors A27/A30/A31's `A2X_TAB_NAVIGATION_WAIT_MS`). 3. Wait the canonical 11s for the first MediaRecorder segment to rotate (mirrors A30's `SEGMENT_SETTLE_MS`). 4. Use `chrome.scripting.executeScript({world: 'ISOLATED', func: ...})` to inject a synthetic DOM mutation that carries a UNIQUE SENTINEL STRING (`'a29-mutation-sentinel'`) into a fresh `
` appendChild'd to `document.body`. 5. Wait 500ms for rrweb's MutationObserver to enqueue the IncrementalSnapshot. 6. Dispatch SAVE_ARCHIVE while the probe tab is active (its content script is the source of `rrweb/session.json`). 7. Host-side driveA29 JSZip-parses the resulting archive, filters rrweb events for `EventType.IncrementalSnapshot` + `IncrementalSource.Mutation`, descends into the mutation payload's `adds[*].node.textContent` field, and asserts ≥ 1 event contains the sentinel — PROVING the captured mutation came from OUR injection, not from leftover iana.org tabs. Purpose: Flake stabilization. Plan 03-02 + 03-03 SUMMARYs both document A29 as the canonical "tests-pass-for-wrong-reason" smell — the EventType-loose-grep currently in place trivially passes because iana.org's home page has plenty of mutations in its own normal rendering, and the previous A27/A28 probe tab leaks rrweb events into the buffer that A29's findLatestZip picks up. The fix is mechanical (verbatim port of an established pattern) but the diagnostic value is large (the test now genuinely verifies SPEC §10 #4 / REQ-rrweb-dom-buffer at the rrweb wiring at src/content/index.ts:284-309). Output: 1 file rewrite in tests/uat/extension-page-harness.ts (the existing assertA29 body, lines 3363-3419) + 1 file rewrite in tests/uat/lib/harness-page-driver.ts (the existing driveA29 body, lines 1884-2001). No new harness assertion number is added; A29 count stays 33; the rewrite is in-place. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-harden-clean-up-optional/04-CONTEXT.md @.planning/phases/04-harden-clean-up-optional/04-RESEARCH.md @.planning/phases/04-harden-clean-up-optional/04-PATTERNS.md @.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md @.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md # Source files — the canonical assertA30 + driveA30 to copy from + the current A29 to rewrite @tests/uat/extension-page-harness.ts @tests/uat/lib/harness-page-driver.ts # Prior plan SUMMARYs documenting the A29 flake @.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-SUMMARY.md From tests/uat/extension-page-harness.ts:3363-3419 (current assertA29 — TO BE REWRITTEN): - Function body uses a harness-page approach (no chrome.tabs.create; relies on the existing harness page's probe HTML structure). - The harness-page approach is rejected per Plan 03-02 SUMMARY's "Pitfall 2" — `` content-script match-pattern EXCLUDES chrome-extension:// scheme; the harness page never gets a content script attached, so rrweb never wires up, so any mutations captured come from elsewhere. From tests/uat/extension-page-harness.ts:3517-3636 (assertA30 — the CANONICAL cs-injection-world skeleton to copy): - 7-step pattern: setupFreshRecording → chrome.tabs.create probe → wait nav → wait segment → chrome.scripting.executeScript ISOLATED → wait settle → SAVE_ARCHIVE - Uses constants block: `const A30_PROBE_TAB_URL = 'https://example.com/';` + similar. - Uses `try/finally` to ensure probe-tab cleanup via `chrome.tabs.remove` (silent-ignore via T-02-04-04). From tests/uat/lib/harness-page-driver.ts:1884-2001 (current driveA29 — TO BE REWRITTEN): - Currently performs a JSZip parse + EventType-loose-grep that counts `e.type === EventType.Meta` + `EventType.FullSnapshot` + `EventType.IncrementalSnapshot` occurrences ≥ 1. - The loose-grep is RESEARCH Pitfall 1 ("Trusting Plan 03-02's SUMMARY 'A29 events.length=4' diagnostic as proof of correctness"). - After Plan 04-03 rewrite: still does JSZip parse but adds a strict-sentinel filter step PER RESEARCH Code Example Pattern 3. From tests/uat/lib/harness-page-driver.ts:2039-2148 (driveA30 — the CANONICAL host-side filter pattern): - Imports `EventType` (and now `IncrementalSource`) from `@rrweb/types`. - Calls `JSZip.loadAsync(readFileSync(zipPath))` + `zip.file('rrweb/session.json')?.async('text')`. - Filter-pipeline form for events extraction (no for/continue per CLAUDE.md). From RESEARCH Q3 Code Example Pattern 3 (host-side strict-sentinel filter — the NEW code path for driveA29): ```typescript import { EventType, IncrementalSource } from '@rrweb/types'; const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel'; const mutationEvents = events.filter((e) => e.type === EventType.IncrementalSnapshot && e.data?.source === IncrementalSource.Mutation, ); const sentinelEvents = mutationEvents.filter((e) => { const adds = e.data?.adds ?? []; return adds.some((a: any) => typeof a?.node?.textContent === 'string' && a.node.textContent.includes(A29_MUTATION_SENTINEL), ); }); ``` Constants to define at the top of assertA29 (RESEARCH Q3 Code Example Pattern 3): ```typescript const A29_PROBE_TAB_URL = 'https://example.com/'; // RFC 2606 reserved; matches A30/A31 const A29_TAB_NAVIGATION_WAIT_MS = 1_500; // mirrors A27/A30/A31 canonical const A29_SEGMENT_SETTLE_MS = 11_000; // first segment rotation (matches A30) const A29_MUTATION_SETTLE_MS = 500; // rrweb MutationObserver enqueue const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel'; const A29_PROBE_DIV_ID = 'a29-probe-mutation'; ``` Injection function body (inside chrome.scripting.executeScript func: arg): ```typescript func: (sentinel: string, divId: string) => { const div = document.createElement('div'); div.id = divId; div.textContent = sentinel; document.body.appendChild(div); }, args: [A29_MUTATION_SENTINEL, A29_PROBE_DIV_ID], ``` Note on `@rrweb/types` IncrementalSource enum (verified for rrweb 2.0.0-alpha.4 per RESEARCH Assumption A5): - `IncrementalSource.Mutation = 0` — the mutation-source enum value - Already imported at tests/uat/lib/harness-page-driver.ts:2039+ for driveA30 (or grep to confirm; if not imported, ADD the import alongside the existing `EventType` import). Task 1: Rewrite assertA29 to use cs-injection-world pattern (page-side) tests/uat/extension-page-harness.ts tests/uat/extension-page-harness.ts:3363-3419 (current assertA29), tests/uat/extension-page-harness.ts:3517-3636 (assertA30 — canonical cs-injection-world skeleton), .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md, .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md Q3 section 1. Read tests/uat/extension-page-harness.ts in the line range 3300-3700 once (covers existing assertA29 + assertA30 + assertA31 + the `__mokoshHarness` global registration block at line ~3971). DO NOT re-read this range — extract everything needed in this single pass. 2. Locate the existing `async function assertA29(): Promise` at line 3363. Replace its entire body (closing brace at ~line 3419) with the cs-injection-world skeleton, mirroring assertA30 (lines 3517-3636) verbatim with the A29-specific substitutions: - Constants block at the top of the function (use the A29_* names from `` above). - Step 1: `await setupFreshRecording()` (existing helper at extension-page-harness.ts ~line 600+). - Step 2: `const probeTab = await chrome.tabs.create({url: A29_PROBE_TAB_URL, active: true});` - Step 3: `await new Promise(r => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS));` - Step 4: `await new Promise(r => setTimeout(r, A29_SEGMENT_SETTLE_MS));` - Step 5: `await chrome.scripting.executeScript({target: {tabId: probeTab.id!}, world: 'ISOLATED', func: ..., args: [A29_MUTATION_SENTINEL, A29_PROBE_DIV_ID]});` per the injection function body in ``. - Step 6: `await new Promise(r => setTimeout(r, A29_MUTATION_SETTLE_MS));` - Step 7: `const ack = await sendMessageWithTimeout({type: 'SAVE_ARCHIVE'}, A29_SAVE_ARCHIVE_TIMEOUT_MS, 'SAVE_ARCHIVE (A29)');` - Push `result.checks` for `A29.1: SAVE_ARCHIVE ack success === true`. - try/finally: silent-ignore `chrome.tabs.remove(probeTab.id)` cleanup per T-02-04-04 precedent. - `result.passed = result.checks.every(c => c.passed);` final aggregate. - Preserve the existing `diag(result, ...)` helper calls for each step (mirror assertA30's diag pattern). - Function name and return type are UNCHANGED — only the body is replaced. 3. Verify the `__mokoshHarness` global registration at line ~3971 still has the `assertA29` entry (it does — no change needed there). Filter-pipeline form: no `continue`. TypeScript-strict. Inline cleanup comments cite RESEARCH Q3 + Plan 03-02 SUMMARY at top of function. Verify: `npx tsc --noEmit` exits 0. Build the test bundle: `npm run build:test` exits 0 (the bundled `dist-test/` is what the harness loads; assertA29 lives in extension-page-harness.ts which is bundled by the test config). npx tsc --noEmit 2>&1 | grep -c 'error TS'; npm run build:test 2>&1 | tail -5 - `npx tsc --noEmit` exits 0. - `npm run build:test` exits 0. - `grep -c 'A29_MUTATION_SENTINEL' tests/uat/extension-page-harness.ts` returns ≥ 2 (constant definition + injection args). - `grep -c 'a29-mutation-sentinel' tests/uat/extension-page-harness.ts` returns ≥ 1 (constant value literal). - `grep -nE "world: 'ISOLATED'" tests/uat/extension-page-harness.ts` returns ≥ 3 lines (assertA29 new entry + existing assertA30 + assertA31 — all 3 must continue to use ISOLATED world; ANY MAIN-world drift is a regression per RESEARCH Pitfall 5). - `grep -nE 'chrome\\.tabs\\.create.*A29_PROBE_TAB_URL' tests/uat/extension-page-harness.ts` returns ≥ 1. - Existing assertA30 + assertA31 untouched: `git diff tests/uat/extension-page-harness.ts | grep -c '^+.*assertA30\\|^+.*assertA31'` returns 0 (only assertA29 changed). assertA29 rewritten in-place using cs-injection-world pattern + sentinel-bearing DOM mutation injector. Commit: `feat(04-03): A29 page-side rewrite — cs-injection-world + sentinel`. Task 2: Rewrite driveA29 with strict-sentinel rrweb event filter (host-side) tests/uat/lib/harness-page-driver.ts tests/uat/lib/harness-page-driver.ts:1884-2001 (current driveA29), tests/uat/lib/harness-page-driver.ts:2039-2148 (driveA30 — canonical host-side filter pattern), .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md Q3 Code Example Pattern 3 1. Read tests/uat/lib/harness-page-driver.ts in the line range 1850-2200 once (covers existing driveA29 + driveA30 import block + driveA31). DO NOT re-read. 2. Verify the imports at the top of the file include both `EventType` and `IncrementalSource` from `@rrweb/types`: - If only `EventType` is imported, ADD `IncrementalSource` to the import binding list. - Mirror the existing `import { EventType } from '@rrweb/types';` shape. 3. Locate the existing `export async function driveA29(...)` at line ~1884. Replace its body to: - Preserve the existing Phase 1 page-side stub call: `const pageResult = await page.evaluate(() => harness.assertA29());` - Preserve the existing zipPath = findLatestZip(downloadsDir); + null-check fallback. - Replace the existing loose-EventType-grep block with the strict-sentinel filter per RESEARCH Code Example Pattern 3 (use the exact code snippet from `` above). - Push a single check `A29.2: rrweb captured the injected mutation containing 'a29-mutation-sentinel' (closes iana.org-leftover-flake)` with `passed: sentinelEvents.length >= 1`. - PRESERVE any existing A29.0a or A29.3+ checks (Meta/FullSnapshot presence) if they're valuable as defense-in-depth; the strict-sentinel A29.2 is THE primary check that closes the flake. - Filter-pipeline form (no for/continue). - Aggregate: `mergedPassed = mergedChecks.every(c => c.passed);` and return. 4. Verify: `npx tsc --noEmit` exits 0. Run the full UAT harness as the empirical gate: `HEADLESS=1 SKIP_PROD_REBUILD=1 npm run test:uat` (uses the bundle from Plan 04-02 Task 2 dist build; harness has its own build pipeline via build:test). Expect: - A29 GREEN (specifically: A29.1 SAVE ack + A29.2 sentinel found). - All other assertions A0-A28, A30-A32 GREEN (no regression to A30's sentinel-based check; no regression to A31's password-filter check). - Total 33/33 GREEN. Run the 5-consecutive-runs stress test to confirm flake fixed: ```bash for i in {1..5}; do HEADLESS=1 SKIP_PROD_REBUILD=1 npm run test:uat 2>&1 | grep -E '^(A29|TOTAL)' | tail -2; done ``` Expect: A29 PASS in all 5 runs (was ~3/5 historical baseline per Plan 03-03 SUMMARY). npx tsc --noEmit && HEADLESS=1 SKIP_PROD_REBUILD=1 npm run test:uat 2>&1 | tail -10 | tee /tmp/04-03-uat-1.log; grep -c '33/33' /tmp/04-03-uat-1.log - `npx tsc --noEmit` exits 0. - UAT harness 33/33 GREEN in one run; A29 specifically PASS. - `grep -c 'IncrementalSource' tests/uat/lib/harness-page-driver.ts` returns ≥ 2 (import + filter usage). - `grep -c 'a29-mutation-sentinel' tests/uat/lib/harness-page-driver.ts` returns ≥ 1 (constant value or import from a shared symbol; verifiable post-edit). - `grep -cE "A29\\.2:.*sentinel" tests/uat/lib/harness-page-driver.ts` returns ≥ 1 (the new strict-sentinel check entry). - 5 consecutive UAT runs all GREEN for A29 (manual stress; record outcomes in SUMMARY). - No regression to A30 + A31 (they have their own sentinel-based checks; verify their PASS state unchanged). driveA29 strict-sentinel filter landed; UAT harness 33/33 GREEN; flake stress 5/5 PASS. Commit: `feat(04-03): A29 host-side strict-sentinel filter + 5/5 PASS stress test`. ## Trust Boundaries | Boundary | Description | |----------|-------------| | harness host (Node tsx) → Puppeteer page (extension realm) | host-side driveA29 only reads the saved zip; the actual mutation injection happens in the page realm via chrome.scripting.executeScript (sender.id matches chrome.runtime.id automatically — ISOLATED world; no untrusted input crosses boundary) | | chrome.scripting.executeScript ISOLATED world → page DOM | injected function runs in the content-script ISOLATED world (per Chrome match-pattern docs); rrweb's MutationObserver lives in the same ISOLATED world, so the injected mutation IS captured | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-04-03-01 | Repudiation | A29 currently passes for the wrong reason (iana.org-leftover events from A27/A28 probe tabs); a real rrweb regression at src/content/index.ts:284 would be masked by the loose-EventType-grep | mitigate | Strict-sentinel filter (`IncrementalSource.Mutation` + `adds[*].node.textContent` includes injected sentinel string); test fails fast if the captured mutation doesn't carry OUR sentinel | | T-04-03-02 | Spoofing | a future plan could accidentally inject a mutation via MAIN world instead of ISOLATED world, silently masking the test (mutation visible in DOM but rrweb's observer in ISOLATED doesn't see it) | mitigate | Explicit `world: 'ISOLATED'` written in code + acceptance criterion grep gate requires ≥ 3 occurrences across the 3 cs-injection-world callers (A29 + A30 + A31) | | T-04-03-03 | DoS (test) | the 5-min idle path (Plan 04-04 A33) could leave a leftover probe tab from A29 if cleanup misfires, polluting the next run's iana.org-leftover baseline | accept | The harness uses `mkdtemp` downloadsDir per run + each assertA* uses try/finally chrome.tabs.remove; cross-assertion leftover risk is bounded by Puppeteer browser.close() between full runs | - `npx tsc --noEmit` exits 0. - `npm run build:test` exits 0 (test bundle compiles). - UAT harness 33/33 GREEN in one run (`HEADLESS=1 SKIP_PROD_REBUILD=1 npm run test:uat`). - A29 specifically: A29.1 SAVE ack PASS + A29.2 sentinel filter PASS. - 5 consecutive UAT runs: A29 PASS 5/5 (stress-confirmed flake fixed). - A30 + A31 unchanged (no regression to existing sentinel-based checks). - `grep -nE "world: 'ISOLATED'" tests/uat/extension-page-harness.ts | grep -v '^#'` returns ≥ 3 lines (A29 + A30 + A31 all explicit). - assertA29 rewritten in-place using cs-injection-world skeleton + sentinel-bearing mutation injection (Task 1). - driveA29 rewritten with strict-sentinel rrweb event filter (Task 2). - UAT harness 33/33 GREEN preserved. - A29 flake stabilized — 5/5 PASS across consecutive runs (was ~2/3 historical). - Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (production chrome.tabs.create + chrome.scripting.executeScript exclusively — no new `__MOKOSH_UAT__`-gated symbols). - vitest baseline preserved (Plan 04-02 baseline; ≥ 181 GREEN). After completion, create `.planning/phases/04-harden-clean-up-optional/04-03-SUMMARY.md` capturing: - assertA29 diff (full body rewrite cited verbatim) - driveA29 diff (filter pipeline cited verbatim) - 5/5 PASS stress test results (timestamped UAT runs) - A29.0a / A29.2 / A29.3+ check inventory (which preserved, which added, which removed) - UAT total before/after (33/33 → 33/33; assertion count unchanged; quality changed) - Pre-existing flake closure citation (Plan 03-02 SUMMARY + 03-03 SUMMARY) - Commit refs (Task 1 + Task 2)