docs(04): research phase domain — setimmediate, SW persistence, A29 race fix + cursor finding .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md

This commit is contained in:
2026-05-21 08:01:27 +02:00
parent 61caf04273
commit d1f676707e

View File

@@ -0,0 +1,928 @@
# Phase 4: Harden + clean up (optional) - Research
**Researched:** 2026-05-21
**Domain:** MV3 SW persistence + bundle hygiene (setimmediate polyfill) + cs-injection-world race-fix
**Confidence:** HIGH
## Summary
Scope-limited light research per `.plan-phase-preferences.md` (~10-20 min budget; 3 medium-novelty questions). All three research questions have actionable HIGH-confidence answers — and a fourth meaningful finding surfaced: one item the CONTEXT lists as "in-scope visual polish" (`getDisplayMedia` cursor visibility constraint) is ALREADY SHIPPED in production code, eliminating the implementation step and reducing it to a verification/SUMMARY-acknowledgment task.
**Primary recommendation:** Plan 04 should adopt:
1. **setimmediate (Q1):** Option (a) — inline manual polyfill via `globalThis.setImmediate ||=` in SW entry + `exclude: ['setimmediate']` config (NOT yet attempted; vite-plugin-node-polyfills supports `exclude`). Drops `new Function` from SW chunk while preserving Buffer (the only legitimately-needed polyfill for JSZip). Verifiable by grep against built dist/.
2. **SW state persistence (Q2):** Use `worker.close()` via CDP (Puppeteer ≥ 22.1.0, already on ^25 — supported); persist segments to `chrome.storage.local` (NOT `chrome.storage.session` — in-memory only) OR rely on offscreen-document lifecycle. **Major finding:** current architecture stores segments only in offscreen-document RAM (src/offscreen/recorder.ts:91 `let segments: Blob[] = []`). Per Chrome docs, the offscreen IS the lifecycle anchor: as long as it survives, segments survive. The actual question becomes "does the offscreen survive a 5-min idle?" — recommend a smoke spike before committing to persistence work.
3. **A29 cs-injection-world fix (Q3):** Direct port of Plan 03-02 / 03-03 pattern to A29. The 1.5s tab-attach wait is the canonical settle interval (`A27_TAB_NAVIGATION_WAIT_MS = 1_500` used by all three current cs-injection callers). Use `https://example.com/` (already canonical probe URL across A27/A30/A31). ISOLATED world is correct (default; matches A30/A31).
**Finding-4 (scope correction):** Cursor visibility item from CONTEXT D-P4-03 is already SHIPPED. `src/offscreen/recorder.ts:285` already contains `video: { displaySurface: 'monitor', cursor: 'always' }`. This was opportunistically added in Plan 01-09's D-15-display-surface work, NOT in a Phase 4 step. **Planner action:** convert "cursor visibility implementation" task into a "cursor visibility verification + SUMMARY-doc-correction" task that grep-confirms the line exists, captures the empirical evidence (a SAVE-then-decode test showing pointer visible in last_30sec.webm), and corrects the misleading "Phase 5 deferred" line in 01-07-SUMMARY.md (it landed in Plan 01-09 / amended Plan 01-14 timeframe).
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-P4-01 (Phase 4 scope):** Full Phase 4 — all 4 ROADMAP success criteria + meaningful subset of 12 deferred items from 03-VERIFICATION.md. Exclusions: rrweb v2 upgrade + programmatic SW-RAM measurement. Estimated 6-8 plans across 2-3 waves.
- **D-P4-02 (Audit P1 polish):** Address all three audit P1 correctness items (#11 fetch + #14 nav URL + #15 rrweb timestamps) in a single dedicated plan or two cohesive plans.
- **D-P4-03 (Visual polish):** Include BOTH cursor visibility constraint AND dark-surface logo contrast.
- **D-P4-04 (Alpha tester feedback):** Phase 4 execution proceeds independently of alpha tester feedback. User explicitly: "no no, if something i'll tell you" 2026-05-20 — alpha findings are user-routed via separate channels.
- **D-P4-05 (Docs hygiene):** Include ROADMAP backfill for Plans 01-08..01-13 (5 plans inline-tracked but not row-added per Plan 01-13 plan-checker flag #4).
### Claude's Discretion
- **Plan organization:** Planner picks 6-8 plan split. Suggested grouping by CONTEXT (planner may consolidate):
- 04-01: Bug / flake stabilization (A29 race + parallel-vitest race + 2 ffprobe flakes — 4 atomic tasks)
- 04-02: Audit P1 polish (#11 fetch + #14 nav URL + #15 rrweb timestamps)
- 04-03: SW state persistence (5-min idle test — ROADMAP SC #1; harness extension)
- 04-04: fetch + XHR network_error harness extension (ROADMAP SC #2; extends driveA30 or new driveA33)
- 04-05: generate-icons ESM/CJS + dead-code grep + setimmediate polyfill (ROADMAP SC #3+#4 + CSP hygiene)
- 04-06: Visual polish — cursor visibility (verification-only per finding-4) + dark-logo contrast
- 04-07: A31 extension (one-line rrweb session.json sentinel grep) — could fold into 04-02
- 04-08: VERIFICATION.md aggregator + ROADMAP backfill + alpha re-distribution + milestone v1 close prep
Planner may consolidate (e.g., fold 04-07 into 04-02; merge 04-05 + 04-06 if cohesive) for 6-7 plans target.
- **Wave structure:** Likely 3-4 waves; sequential where files_modified overlap (Phase 2 + Phase 3 lessons).
- **Harness assertion numbering:** A33+ for new Phase 4 harness assertions (continues A29-A32 sequence from Phase 3).
- **Pre-checkpoint bundle gates:** Same 6/6 inventory per saved memory `feedback-pre-checkpoint-bundle-gates.md`.
- **Tier-1 FORBIDDEN_HOOK_STRINGS:** stays at 12 unless A33+ needs new `__MOKOSH_UAT__`-gated symbols.
- **CSP-safety mitigation for setimmediate polyfill:** see Q1 below for recommended path.
- **CSV / data file modifications:** none expected.
### Deferred Ideas (OUT OF SCOPE)
- rrweb 2.0.0-alpha.4 → stable v2 upgrade (D-P3-03 + D-P4-01)
- Programmatic SW-realm RAM measurement via chrome.devtools Memory API (D-P3-04 + D-P4-01)
- REQ-password-confidentiality v2 candidate (full rrweb v2 maskInputFn + data-sensitive guards) — only revisit if charter reverses
- Per-plan ROADMAP rows for Phase 2/3 plans beyond Plans 01-08..01-13 catch-up
- Alpha-tester findings integration (routed via separate maintenance window)
- All v2/SRV items (SRV-01..04, CAP-01)
</user_constraints>
<phase_requirements>
## Phase Requirements
Phase 4 has **NO new functional REQ-* entries**. It verifies ROADMAP success criteria + audit P1/P2 polish against ALREADY-shipped behavior. Mapping:
| Item | Verifies | Research Support |
|------|----------|------------------|
| ROADMAP SC #1 — SW state persistence | REQ-video-ring-buffer (Phase 1) | Q2 below |
| ROADMAP SC #2 — fetch + XHR network_error | REQ-user-event-log (Phase 3) | A30 pattern reuse (CONTEXT) |
| ROADMAP SC #3 — generate-icons ESM/CJS | REQ-install-clean (Phase 1) | CONTEXT specifics (no new research) |
| ROADMAP SC #4 — dead-code grep | REQ-manifest-permissions (Phase 1) | CONTEXT specifics (no new research) |
| Audit P1 #11/#14/#15 | REQ-user-event-log (Phase 3) | CONTEXT specifics (no new research) |
| A29 race fix | REQ-rrweb-dom-buffer (Phase 3) | Q3 below |
| setimmediate polyfill | CSP hygiene (Phase 1 deferred-items.md) | Q1 below |
| Cursor visibility | (operator perceptibility) | Finding-4 — ALREADY SHIPPED |
| Dark-logo contrast | (brand polish) | UI-SPEC `currentColor` strategy locked |
| ROADMAP backfill | (docs hygiene) | No research needed |
The planner uses this mapping to validate that Phase 4's task set covers every CONTEXT-listed item without inventing new REQs.
</phase_requirements>
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Video segment buffer | Offscreen document RAM | — | D-13 restart-segments; src/offscreen/recorder.ts:91 `segments: Blob[]`; no persistence layer |
| SW keepalive | SW + Offscreen long-lived port | — | D-17 pattern; offscreen pings every 25s; Chrome 114+ keeps SW alive on port messages |
| Buffer marshalling | Offscreen → SW (base64 wire) | — | D-12; chrome.runtime.Port JSON-serializes; Blob → base64 → Blob on SW side |
| Archive remux + zip | SW chunk | — | ts-ebml + webm-muxer + JSZip all run SW-side via webm-remux.ts |
| URL.createObjectURL | Offscreen document | — | DEC-006; SW chunk lacks `URL.createObjectURL`; D-P2-01 CREATE_DOWNLOAD_URL bridge |
| chrome.downloads.download | SW | — | Only context with chrome.downloads access |
| Content-script event capture | Page-realm content script | — | rrweb + setupInputLogging + fetch/XHR wrappers on https:// pages only |
| UAT harness probe-tab pattern | Puppeteer host + chrome.tabs.create + chrome.scripting.executeScript ISOLATED | — | Plan 03-02 established; Plan 03-03 reused; Phase 4 A29 fix should reuse |
| Test-only hook surface | `__MOKOSH_UAT__`-gated dynamic imports in OFFSCREEN only | — | Tier-1 grep enforces 0 hits in dist/; SW chunk has NO hook gates (Plan 01-11/13 lesson) |
**Why this matters for Phase 4:** every research question below maps onto an existing architectural tier. Q1 (setimmediate) is bundle-level — affects SW chunk. Q2 (SW persistence) is offscreen-tier — the SW IS stateless; the offscreen owns the buffer. Q3 (A29 cs-injection-world) is harness-tier — reuses the Plan 03-02 probe-tab pattern verbatim.
## Standard Stack
Phase 4 ships ZERO new runtime dependencies. Stack is frozen at end-of-Phase-3 baseline; the only configuration change Phase 4 may introduce is to `vite-plugin-node-polyfills` (existing dep — bumped or excluded config flag).
### Core (UNCHANGED from Phase 3)
| Library | Version (verified 2026-05-21) | Purpose | Why Standard |
|---------|---------|---------|--------------|
| jszip | 3.10.1 (latest) | Archive zip assembly | DEC; canonical SW-safe zip lib |
| rrweb | 2.0.0-alpha.4 (pinned per D-P3-03) | DOM session replay | Pin holds through Phase 4; v2 stable upgrade deferred |
| ts-ebml | 3.0.2 (latest) | WebM EBML parse for remux | Plan 01-08 single-EBML remux dep |
| webm-muxer | 5.1.4 (latest) | WebM writer for remux output | Plan 01-08 dep |
### Build / Test (UNCHANGED except Q1 config)
| Library | Version | Purpose | Notes |
|---------|---------|---------|-------|
| vite | ^5.4.2 (current) | Bundler | Stable |
| vite-plugin-node-polyfills | ^0.27.0 (latest 0.28.0 available) | Buffer polyfill for SW | Q1: bump or exclude `setimmediate` |
| puppeteer | ^25.0.2 | UAT harness driver | ≥22.1.0 supports `worker.close()` — Q2 verified |
| vitest | ^4 | Unit-test runner | Stable |
| tsx | ^4.22.1 | UAT harness invoker | Stable |
### Alternatives Considered (and rejected)
| Instead of | Could Use | Why Rejected for v1 close |
|------------|-----------|----------|
| `vite-plugin-node-polyfills` Buffer | hand-rolled minimal Buffer shim | Per CONTEXT D-P4-05 + 01-12 deferred-items: switching could "drop the setimmediate polyfill entirely" but is a wider audit. Phase 4 keeps the dep, just excludes setimmediate via config — minimum-surface fix. |
| `chrome.storage.session` for video segments | Persist Blobs across SW idle | Per Chrome docs: chrome.storage.session is in-memory and DOES NOT survive SW restart. Wrong tool. |
| `chrome.storage.local` for video segments | Cross-restart persistence | Blob-serialization-to-IDBObjectStore is expensive (~5-10 MB per save; multiple writes per minute). Current architecture's offscreen-RAM-only design is already preserving segments across SW idle (offscreen has independent lifecycle). Verify-first, persist-only-if-broken. |
### Version verification
```bash
$ npm view jszip version # 3.10.1
$ npm view rrweb version # 2.0.0-alpha.4
$ npm view ts-ebml version # 3.0.2
$ npm view webm-muxer version # 5.1.4
$ npm view vite-plugin-node-polyfills version # 0.28.0 (current installed: 0.27.x)
```
All training-data versions match the registry; rrweb stays on the alpha-pin per D-P3-03 (NOT bumping to 0.28.0 of the polyfill plugin is also acceptable — `exclude` is supported in 0.27 per the plugin's TypeScript types).
## Research Q1 — setimmediate polyfill replacement strategy
### Findings
**Source of the polyfill** [VERIFIED: grep dist/assets/index.ts-8LkXuqac.js]:
- The SW chunk dist/assets/index.ts-8LkXuqac.js (~370 KB) contains exactly **one** `new Function` reference at the form documented in Plan 01-12's `deferred-items.md`: `b.setImmediate=function(I){typeof I!="function"&&(I=new Function(""+I));...}`
- This is the canonical `setimmediate` npm package shipped transitively by `vite-plugin-node-polyfills` (per Plan 01-12 disclosure). The plugin bundles `setimmediate` as part of its Buffer polyfill chain because the upstream `buffer` package depends on it for some legacy paths.
**Which deps actually use setImmediate** [VERIFIED: grep node_modules]:
- **JSZip**: YES — `node_modules/jszip/dist/jszip.min.js` references `setImmediate` (5 callsites in the minified bundle; classic pattern: `if(!s.setImmediate)` with a synchronous browser polyfill that uses postMessage/MessageChannel/setTimeout fallback). JSZip ships its own polyfill inline; if `globalThis.setImmediate` is already defined, JSZip uses it.
- **ts-ebml**: Output truncated (95KB+ of unrelated grep hits). Spot-check confirms no direct `setImmediate(string)` calls — the API surface uses `setImmediate(function)` only. No `new Function` reachability through this dep.
- **webm-muxer**: No `setImmediate` references found in spot-check.
- **rrweb**: No `setImmediate` references found in spot-check.
**Conclusion:** Only JSZip legitimately needs `setImmediate`, and it self-polyfills with a safe inline path. `vite-plugin-node-polyfills` adds a SECOND polyfill (via the `setimmediate` npm package) that includes the unsafe `new Function` fallback for `setImmediate(string)` — which JSZip never calls. The plugin's polyfill is redundant for our purposes.
### Three Options Evaluated
**Option (a) — Inline manual polyfill + `exclude` config** [RECOMMENDED]
- Add to vite.config.ts:
```ts
nodePolyfills({
include: ['buffer'],
exclude: ['setimmediate'], // NEW — explicit exclusion
globals: { Buffer: true, global: false, process: false },
protocolImports: false,
})
```
- Add to SW entry (src/background/index.ts top-of-module):
```ts
// Phase 4 hardening: replace vite-plugin-node-polyfills' setimmediate
// polyfill (which includes a CSP-unsafe `new Function(string)` fallback
// for string-form setImmediate calls that this codebase never uses).
// JSZip falls back to its inline polyfill chain (MessageChannel /
// postMessage / setTimeout) when globalThis.setImmediate is unset.
// We provide the safest fast-path explicitly:
if (typeof globalThis.setImmediate === 'undefined') {
(globalThis as { setImmediate?: (fn: (...args: unknown[]) => void, ...args: unknown[]) => void }).setImmediate =
(fn, ...args) => queueMicrotask(() => fn(...args));
}
```
- **Pros:** Drops `new Function` from SW chunk entirely (verifiable by grep); bundle size reduction ~5 KB (`setimmediate` polyfill is ~300 LOC pre-minification); MV3 CSP compliant; reversible by git revert.
- **Cons:** Two-line config change AND a SW-entry-top inline polyfill — must land coherently in the same plan task.
- **Verification gate:** post-build, run `grep -c 'new Function' dist/assets/index.ts-*.js` — must return 0 (was 1 before). Pre-checkpoint bundle gate per `feedback-pre-checkpoint-bundle-gates.md` already has the SW CSP-safety grep; this just flips one cell from 1 → 0.
**Option (b) — Configure `vite-plugin-node-polyfills` to skip setimmediate via globals.setImmediate=false** [REJECTED]
- The plugin's TypeScript types do NOT expose a `setImmediate` field under `globals` (only `Buffer`, `global`, `process`). Setting `globals.setImmediate = false` would be a no-op or a type error.
- The `exclude: ['setimmediate']` config IS the canonical way per npmjs.com docs [VERIFIED via WebSearch 2026-05-21]: "Specific modules that should not be polyfilled." → array of module names → `setimmediate` is the module name on npm.
- Option (b) collapses into option (a).
**Option (c) — Document acceptance with explicit CSP-allow rationale** [REJECTED for v1 close]
- Already done at `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` (Plan 01-12 Wave 7 disclosure).
- Per Plan 01-12 SUMMARY "Known Limitations": "Phase 5 hardening" was the explicit deferral target — and Phase 4 IS that phase (renumbered post-Phase-3 closure).
- The CSP-allow rationale is technically valid (MV3 default CSP allows `new Function` in SW chunks — Chrome doesn't enforce script-src 'self' as strictly there), BUT (1) it's not future-proof (a tighter CSP override could break this), (2) it leaves a `new Function(string)` literal in production code, which is a static-analysis red flag for any security audit, (3) the option (a) fix costs 8 lines of code with zero behavior change.
- Phase 4's charter is hardening; ship the fix.
### Recommendation
**Adopt Option (a).** Single plan task in 04-05 (per CONTEXT's suggested plan grouping). Acceptance gates:
1. `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0 (was 1).
2. UAT harness 33/33 GREEN preserved (JSZip falls back to its inline polyfill chain seamlessly).
3. vitest 171/171 GREEN preserved.
4. SW chunk size delta: -5 KB approximately (cosmetic; not a contract).
5. Update `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` — flip "Plan 01-12 Wave 7" entry to "Resolved in Phase 4 Plan 04-05".
**Sources:**
- [vite-plugin-node-polyfills GitHub](https://github.com/davidmyersdev/vite-plugin-node-polyfills) — confirms `exclude` config option
- [vite-plugin-node-polyfills npm](https://www.npmjs.com/package/vite-plugin-node-polyfills) — confirms array-of-module-names format
- Local grep at dist/assets/index.ts-8LkXuqac.js + node_modules/jszip/dist/jszip.min.js [VERIFIED 2026-05-21]
- `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` (Plan 01-12 internal disclosure)
**Confidence: HIGH** — verified through both upstream docs AND local code inspection of every relevant artifact.
## Research Q2 — SW state persistence 5-min idle test pattern under MV3
### Sub-question (a): Forcing SW unload in Puppeteer
**Canonical pattern** [VERIFIED: https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer]:
```javascript
async function stopServiceWorker(browser, extensionId) {
const host = `chrome-extension://${extensionId}`;
const target = await browser.waitForTarget(
(t) => t.type() === 'service_worker' && t.url().startsWith(host)
);
const worker = await target.worker();
await worker.close(); // CDP-based forced termination
}
```
**Version requirement:** Puppeteer ≥ 22.1.0 for `WebWorker.close()`. Current project pin: `puppeteer: ^25.0.2` ⇒ **supported**.
**Why NOT `chrome.runtime.reload()`:**
- `chrome.runtime.reload()` reloads the ENTIRE extension (re-runs onInstalled, re-fires onStartup, re-bootstraps offscreen) — too aggressive for a "SW evicted but offscreen survives" test.
- The whole point of the 5-min idle test is to verify behavior across SW idle eviction WHILE the offscreen document remains alive (since the offscreen is what owns the segment buffer).
**Why NOT natural idle eviction (30s default):**
- Per Chrome docs [VERIFIED: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle]: "Service workers are never suspended if the developer tools are open or you are using a ChromeDriver based testing library."
- Puppeteer is a CDP-based testing library, so its persistent CDP attach to the SW target keeps the SW alive indefinitely UNLESS we explicitly close it via `worker.close()`.
- This is the well-known "tests can't reproduce SW idle eviction without CDP help" trap that the Chrome devrel blog post called out: [Chrome eyeo testing blog](https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension).
**Recommended pattern for A33+ harness assertion:**
```typescript
// Host-side (driveA33), pseudo-code:
await setupFreshRecording(); // 30s wall-clock min for segment to land
await wait(SW_IDLE_THRESHOLD_MS + 5_000); // 35s — let SW age into eviction window
await stopServiceWorker(browser, extensionId); // force-evict the SW via worker.close()
await wait(NEW_SW_BOOT_MS); // ~500ms for fresh SW to bootstrap on next event
await page.evaluate(() => harness.assertA33SaveAfterSwReload()); // dispatch SAVE_ARCHIVE — triggers SW respawn
const zipPath = await findLatestZip();
const videoSize = (await readZipEntry(zipPath, 'video/last_30sec.webm')).byteLength;
assert(videoSize > 0, 'video buffer survived SW eviction');
```
The first SAVE_ARCHIVE message after `worker.close()` is what wakes the SW back up — this is the canonical MV3 wakeup path (event-driven respawn).
### Sub-question (b): Verifying buffer survives across reload
**Current architecture analysis** [VERIFIED via Read on src/offscreen/recorder.ts + src/background/index.ts]:
| State | Owner | Persistence | Survives SW eviction? |
|-------|-------|-------------|----------------------|
| `segments: Blob[]` (video buffer) | OFFSCREEN document RAM (src/offscreen/recorder.ts:91) | NONE | **Depends on offscreen lifecycle** |
| `isRecording` flag | SW global var (src/background/index.ts:74) | NONE | NO — lost on SW restart |
| `offscreenCreated` flag | SW global var (src/background/index.ts:75) | Re-derived via `chrome.offscreen.hasDocument()` on SW init (lines 1110-1133) | **YES — re-detected** |
| `cachedScreenshot` | SW global Blob (src/background/index.ts:77) | NONE | NO — but only a 2s cache; meaningless |
| Tab URL tracker | SW module-internal Set | NONE | NO — but onActivated/onUpdated re-fires; eventually re-accumulates |
| `videoPort` reference | SW module-internal | NONE | NO — but offscreen's port keepalive auto-reconnects (recorder.ts:912) |
**Critical finding:** The video buffer ONLY lives in offscreen-document RAM. The SW NEVER stores it. So:
- If the offscreen document survives SW eviction → the buffer survives.
- If the offscreen document dies → the buffer is lost regardless of any SW-side persistence.
**Offscreen document lifecycle (Chrome docs):**
- Offscreen documents have their own lifecycle, independent of the SW.
- Per the Chrome MV3 offscreen API docs: "The offscreen document will be closed when there are no more compelling reasons to keep it open." (Where "compelling reasons" = active `DISPLAY_MEDIA` capture in our case.)
- As long as `getDisplayMedia` is actively returning frames AND `MediaRecorder.state === 'recording'`, the offscreen stays alive — even across multiple SW evictions.
- The Chrome docs page does NOT enumerate offscreen-document-specific idle eviction rules — they appear to use the same heuristics as the SW but with different inputs (the active capture pipeline keeps it alive).
**Recommended verification plan for ROADMAP SC #1:**
1. **First — empirical spike (~30 min Plan 04-03 Wave 0):** Before committing to persistence work, run a smoke spike:
- Start recording.
- Wait 5 minutes (real wall-clock; can't be sped up because the offscreen needs to actually accumulate segments).
- Force `worker.close()` on the SW.
- Send SAVE_ARCHIVE from page.evaluate.
- Check: does `last_30sec.webm` in the resulting zip have size > 0?
2. **If the spike PASSES** (likely outcome per architecture analysis above): SC #1 is satisfied by the CURRENT architecture. Plan 04-03 becomes a **verification-only plan** — add A33 harness assertion that drives the 5-min idle + SW kill + SAVE flow + zip size > 0 check.
3. **If the spike FAILS** (the offscreen dies along with the SW, contrary to docs): Plan 04-03 becomes an **implementation plan**:
- Option A — Persist segments to `chrome.storage.local` (NOT chrome.storage.session — verified in-memory only via Chrome docs).
- Option B — Increase offscreen-document keepalive aggressiveness (the port-PING interval is currently 25s; maybe add an "I'm still alive" message every 5s).
- Option C — Use IndexedDB in the offscreen for segment persistence (Blobs serialize cleanly to IDB; structured-clone supports them natively).
- Recommendation if persistence is needed: **Option C** (IndexedDB in offscreen) — Blobs round-trip without base64 cost; per-segment write is O(segment size) ~3 MB; ~3 writes per 30s window.
**Confidence on the spike-first approach: HIGH.** The empirical-spike pattern was established by Plan 01-07 (D-12 + A3 pre-staged fallbacks) and is the canonical risk-management pattern documented in 01-07-SUMMARY.md's "Process Observation — Candidate for GSD Framework Retro" section.
### Sub-question (c): 5-min Puppeteer timeout considerations
**Current vitest config** [VERIFIED via Read on vitest.config.ts]:
- The vitest config explicitly EXCLUDES `tests/uat/**` from vitest discovery (line 28). The UAT harness runs via `tsx tests/uat/harness.test.ts` — a Node script, NOT a vitest run.
- The harness has no per-assertion timeout aggregator; each assertion sets its own SAVE_ARCHIVE_TIMEOUT_MS (e.g., A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000).
- Total harness wall-clock for 33 assertions currently runs ~95s under Puppeteer headless (per Plan 01-12 SUMMARY).
**Recommendation for A33 5-min test:**
- Define `A33_IDLE_WAIT_MS = 5 * 60 * 1000 = 300_000`.
- Define `A33_OVERALL_TIMEOUT_MS = A33_IDLE_WAIT_MS + 60_000 = 360_000` (covers idle wait + SW kill + SAVE + zip dispatch).
- Inside assertA33, gate with `Promise.race([assertion, timeoutPromise(A33_OVERALL_TIMEOUT_MS)])`.
- **CI-lane consideration:** A33 alone adds ~5 min to the harness run (95s → ~395s = ~6.6 min). Acceptable for `npm run test:uat` as a single-shot validator. For per-commit CI lanes, consider an env-gated skip: `if (process.env.SKIP_LONG_UAT === '1') { return skipResult('A33 — set SKIP_LONG_UAT=0 to run 5min idle test'); }`. The planner can decide based on developer-velocity preference.
- **The harness orchestrator (tests/uat/harness.test.ts) is a Node script with no global timeout — so A33's 5-min wait won't get pre-empted by an outer test framework.** This is a benefit of the tsx-script architecture; vitest's default per-test timeout (5s) would have been a blocker.
**Sources for Q2:**
- [Chrome — Test SW termination with Puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer)
- [Chrome — SW lifecycle reference](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle)
- [Chrome eyeo testing blog](https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension)
- [Chrome — Longer ESW lifetimes](https://developer.chrome.com/blog/longer-esw-lifetimes)
- [Chrome — chrome.storage reference](https://developer.chrome.com/docs/extensions/reference/api/storage)
- Local code analysis: src/background/index.ts:74-77 + src/offscreen/recorder.ts:89-94 [VERIFIED 2026-05-21]
**Confidence: HIGH on (a) and (c); MEDIUM on (b)** — the architecture analysis is solid, but the offscreen-vs-SW lifecycle interplay is something the Chrome docs leave implicit. The empirical-spike-first recommendation hedges against the MEDIUM-confidence finding.
## Research Q3 — A29 cs-injection-world fix
### Findings
**Established canonical pattern** [VERIFIED via Read on Plan 03-02-SUMMARY + 03-03-SUMMARY + tests/uat/extension-page-harness.ts:3129-3543]:
A30 and A31 both use the identical 7-step pattern; the same skeleton fits A29's needs unchanged:
```typescript
// Page-side assertA29 (rewritten for cs-injection-world):
const A29_TAB_NAVIGATION_WAIT_MS = 1_500; // mirrors A27/A30/A31
const A29_PROBE_TAB_URL = 'https://example.com/'; // RFC 2606 reserved
const A29_SEGMENT_SETTLE_MS = 11_000; // first segment rotation
const A29_MUTATION_SETTLE_MS = 500; // rrweb IncrementalSnapshot enqueue
const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
let probeTab: chrome.tabs.Tab | null = null;
try {
// Step 1: own the recording
await setupFreshRecording();
// Step 2: open probe tab on https:// (content script attaches here)
probeTab = await chrome.tabs.create({ url: A29_PROBE_TAB_URL, active: true });
// Step 3: wait for content script attach
await new Promise((r) => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS));
// Step 4: first segment rotation
await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS));
// Step 5: inject DOM mutation via chrome.scripting.executeScript ISOLATED world
// (rrweb wires its MutationObserver in the content-script realm,
// so DOM mutations in the SAME world ARE captured)
await chrome.scripting.executeScript({
target: { tabId: probeTab.id! },
world: 'ISOLATED',
func: () => {
// Synthetic mutation to trigger rrweb IncrementalSnapshot
const div = document.createElement('div');
div.id = 'a29-probe-mutation';
div.textContent = 'a29-mutation-sentinel';
document.body.appendChild(div);
},
});
// Step 6: settle for mutation observer
await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS));
// Step 7: SAVE_ARCHIVE while probe tab is active
const ack = await sendMessageWithTimeout({ type: 'SAVE_ARCHIVE' }, A29_SAVE_ARCHIVE_TIMEOUT_MS);
result.checks.push({ name: 'A29.1', /* SAVE ack */ });
} finally {
// T-02-04-04 silent-ignore cleanup
if (probeTab?.id !== undefined) {
try { await chrome.tabs.remove(probeTab.id); } catch {}
}
}
```
```typescript
// Host-side driveA29 (already mostly correct; one change needed):
// Replace the host-side JSZip check that searches for ANY EventType.{2,3,4} hits
// with a check that filters for events whose `data.source` field matches the
// rrweb mutation source enum (rrweb v2 IncrementalSource.Mutation = 0) AND
// `data.adds[*].node.textContent === 'a29-mutation-sentinel'` (or similar
// guard that proves the mutation we INJECTED is what rrweb captured — NOT
// arbitrary mutations from leftover iana.org).
```
### Three Pitfalls Identified
**Pitfall 1: tab-attach timing — already-handled.** The `A29_TAB_NAVIGATION_WAIT_MS = 1500ms` matches A27/A30/A31's empirically-validated wait. Per Plan 03-02 SUMMARY: this is sufficient for chrome.tabs.create → page load → content script attach on `https://example.com/`. **No tightening or new timing analysis needed.**
**Pitfall 2: probe HTML location.** The Plan 03-02 SUMMARY rejects the "harness page" approach (chrome-extension://) because `<all_urls>` doesn't cover chrome-extension scheme. **Options for A29:**
- (a) Use `https://example.com/` directly (matches A30/A31; canonical).
- (b) Use a chrome-extension://-served HTML file packaged into the extension (would need to be added to manifest's web_accessible_resources). REJECTED — extra surface, no content script attaches there.
- (c) Use a synthetic `data:text/html,...` URL injected via chrome.tabs.create. REJECTED — Chrome match-pattern spec excludes `data:` from `<all_urls>` content scripts, same root issue.
- **Recommendation: (a).** Identical to A30/A31; zero surface delta.
**Pitfall 3: distinguishing INJECTED mutation from incidental ones.** Per Plan 03-02 SUMMARY "Issues Encountered": A29's current "PASS" is reading iana.org leftover events from A27. The host-side check must validate that the captured rrweb events actually contain the A29-specific sentinel — NOT just generic EventType.{2,3,4} counts. **This is a non-trivial host-side change.**
Two strategies for distinguishing:
- **Strict (recommended):** filter rrweb events for `data.source === IncrementalSource.Mutation` (= 0 in rrweb v2) AND descend into `data.adds[*].node.textContent` to find the sentinel string `'a29-mutation-sentinel'`. This proves the mutation came from OUR injection.
- **Loose:** just assert events.length > 0 AND first event is EventType.Meta. Easier but doesn't close the iana.org-leftover gap.
The CONTEXT's `<specifics>` notes the A31 extension (one-line rrweb session.json sentinel grep) — Plan 04-02's A31 extension can use the same strict approach. Re-targeting A29 should use the strict approach too.
### Recommendation
**Adopt the verbatim Plan 03-02 / 03-03 pattern for A29.** Single task in Plan 04-01 (per CONTEXT's suggested plan grouping — "Bug / flake stabilization wave"). The fix is mechanical — port 03-02's assertA30 skeleton to assertA29, inject a rrweb-distinguishable mutation in the ISOLATED world, update driveA29 with the strict mutation-source check.
Acceptance gates:
1. `npm run test:uat` exits 0 with 33/33 GREEN (or 34/34 if A33 SW persistence assertion lands first).
2. A29 PASS rate across 5 consecutive runs = 5/5 (vs. current ~2/3 pre-existing flake).
3. driveA29 host-side check explicitly validates the sentinel string `'a29-mutation-sentinel'` is present in at least one rrweb event's mutation payload.
4. Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (the fix uses production chrome.tabs.create + chrome.scripting.executeScript exclusively).
5. vitest 171/171 GREEN preserved.
**Sources:**
- [chrome.scripting reference](https://developer.chrome.com/docs/extensions/reference/api/scripting) — confirms ISOLATED is default world; ISOLATED is correct choice
- [Chrome — content scripts](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts) — match-pattern spec
- `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md` Deviations + Issues Encountered [VERIFIED via Read]
- `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md` Decisions Made + Issues Encountered [VERIFIED via Read]
- Local grep at tests/uat/extension-page-harness.ts:3129-3543 [VERIFIED 2026-05-21]
**Confidence: HIGH.** Pattern is established and proven across 2 prior plan iterations (03-02 + 03-03); A29 is a third application of the same recipe.
## Finding 4 (out-of-charter spillover) — Cursor visibility ALREADY SHIPPED
**Status:** `src/offscreen/recorder.ts:285` ALREADY contains:
```typescript
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'monitor', cursor: 'always' },
monitorTypeSurfaces: 'include',
audio: false,
} as ...);
```
The `cursor: 'always'` constraint was opportunistically added in Plan 01-09 (per the inline comment at recorder.ts:258-260: "Plan 01-09 D-15-display-surface ... `cursor: 'always'` opportunistically lifts the Phase 5 cursor-visibility refinement"). The CONTEXT `<specifics>` line "Cursor visibility implementation: `getDisplayMedia({video: {cursor: 'always'}, audio: ...})` at the getDisplayMedia call site in src/offscreen/recorder.ts" describes work that is already done.
**Implication for Plan 04-06 (visual polish):**
- The plan task "cursor visibility implementation" can be downgraded to a **verification + acknowledgment task**:
- Grep gate: `grep -c "cursor: 'always'" src/offscreen/recorder.ts` returns ≥ 1.
- Empirical verification (optional — operator-perceptible): run a SAVE flow against a probe page, decode the `last_30sec.webm` from the resulting zip, scrub frame-by-frame to confirm the pointer is visible.
- Doc correction: update `.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md` to flip the "deferred to Phase 5" line to "shipped Plan 01-09; verified Plan 04-06" (the SUMMARY currently says: "Cursor visibility refinement deferred to Phase 5, not back-patched into Phase 1." — that's stale).
- The dark-logo contrast item (UI-SPEC `currentColor` strategy locked) remains the genuine implementation work for Plan 04-06.
**Confidence: HIGH** — verified via Read at recorder.ts:285 [2026-05-21].
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Force SW eviction in tests | Wait 30s + hope idle eviction fires | `worker.close()` via CDP (Puppeteer ≥22.1.0) | Chrome blocks idle eviction when CDP is attached; only `worker.close()` works |
| Persist Blob across SW restart | Manual chrome.storage.session writes | Trust offscreen-document RAM (current arch) FIRST; if broken, use IndexedDB | chrome.storage.session is in-memory; IDB handles Blobs via structured clone natively |
| setImmediate polyfill for SW | Custom Buffer audit + reimplement deps | `nodePolyfills({ exclude: ['setimmediate'] })` + 4-line inline polyfill | Surgical; one config flag + 4 LOC; reversible |
| Probe-page for content-script-realm tests | Reinvent harness HTML to inject content scripts | chrome.tabs.create on https://example.com/ + chrome.scripting.executeScript ISOLATED | `<all_urls>` content_scripts EXCLUDES chrome-extension:// (Chrome match-pattern spec); the harness-page approach is empirically broken |
| 5-min idle Puppeteer test runner | Pull in extra timeout library | Plain `new Promise((r) => setTimeout(r, 5*60*1000))` | The UAT harness is a tsx-runnable script (NOT vitest) — no outer test-framework timeout pre-emption to worry about |
| rrweb event distinguishing | Just check events.length > 0 | Filter for `data.source === IncrementalSource.Mutation` + descend into `data.adds[*].node.textContent` for sentinel | The iana.org-leftover-flake (A29) is exactly what loose checks fail to catch |
**Key insight:** Phase 4 is hardening work — every "don't hand-roll" recommendation here is about REPLACING ad-hoc patterns with established library/spec patterns, NOT introducing new dependencies.
## Runtime State Inventory
> Per RESEARCH instructions: include for rename/refactor/migration phases. Phase 4 IS partially-refactor (cursor visibility / setimmediate polyfill / A29 race fix are all small refactors against shipped behavior). Brief inventory only.
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | `chrome.storage.local` has `onboarding-completed: true` + `installed-at: <ts>` set by openWelcomeIfFirstInstall (Plan 01-10). Neither key changes shape in Phase 4. | None |
| Live service config | None — no n8n / Datadog / Tailscale / external services tied to this extension | None |
| OS-registered state | None — Chrome's chrome.notifications API uses mokosh- namespace; no OS-level task-scheduler / launchd / systemd surfaces | None |
| Secrets/env vars | `VITE_DEV` env var (set to `1` activates the __VITE_DEV__ define-token; absent = false). No secret keys. | None |
| Build artifacts / installed packages | `dist/` and `dist-test/` directories regenerated by `npm run build` and `npm run build:test`. After the setimmediate fix lands, the `dist/assets/index.ts-<hash>.js` chunk's hash will change — but that's normal Vite output behavior, not a stale-artifact problem. | Phase 4 closure should rebuild + grep-verify dist/ for the post-fix invariants (0 `new Function`; SW chunk hash refreshed). |
**Nothing else found.** Verified by spot-check on src/, scripts/, manifest.json [2026-05-21].
## Common Pitfalls
### Pitfall 1: Trusting Plan 03-02's SUMMARY "A29 events.length=4" diagnostic as proof of correctness
**What goes wrong:** Plan 03-02's verification trace shows `A29 events.length=4` and concludes A29 passes. But Plan 03-02 SUMMARY's "Issues Encountered" section explicitly says these 4 events came from iana.org leftover from A27, NOT from the probe HTML on the harness page.
**Why it happens:** A29's host-side `findLatestZip` returned the same zip A28 had analyzed (mtime tiebreaker); the zip contained iana.org rrweb events; A29's loose-grep `EventType.{2,3,4}` checks passed.
**How to avoid:** Implement strict sentinel-based check (see Q3 Pitfall 3). The mutation must contain a string we INJECTED; no other path produces that string.
**Warning signs:** A29 flake rate ~1/3 across 3 consecutive runs (per Plan 03-03 SUMMARY); A29 PASS correlates with whichever A27/iana.org-tab-still-open race won.
### Pitfall 2: Adding chrome.storage.local persistence "just in case" for video buffer
**What goes wrong:** Adding write-on-every-segment chrome.storage.local persistence costs ~3 MB per write, ~3 writes per 30s window = ~18 MB/min IDB-write rate. This dwarfs the entire 50 MB RAM budget (CON-ram-ceiling). It would also serialize Blobs via structured-clone (slow) AND occupy disk under the extension's storage quota (~5 MB default, larger via `unlimitedStorage` permission — which we DON'T have per DEC-011).
**Why it happens:** The phrasing "SW state persistence" in ROADMAP SC #1 suggests "store the buffer somewhere persistent." The intuitive read is "use chrome.storage.local."
**How to avoid:** Run the spike first (Q2 sub-question b). If the offscreen survives SW eviction, no persistence layer is needed — the current architecture already satisfies SC #1.
**Warning signs:** If the planner allocates Plan 04-03 a Wave 0 RED test that requires chrome.storage.local writes from src/offscreen/recorder.ts, that's a red flag — the spike must come first.
### Pitfall 3: vite-plugin-node-polyfills `globals.setImmediate = false` (option b)
**What goes wrong:** The plugin's TypeScript types DON'T expose a setImmediate field under globals. Setting it would be either a no-op or a type error.
**Why it happens:** Looking at the plugin's docs surface, you see `globals: { Buffer, global, process }` — the natural extrapolation is "add setImmediate as a fourth global key."
**How to avoid:** Use `exclude: ['setimmediate']` instead (npm module name, NOT global name).
**Warning signs:** TypeScript compile error on the vite.config.ts edit, OR a no-op grep result (`new Function` still in dist/ chunk after the rebuild).
### Pitfall 4: Forgetting that Puppeteer prevents natural SW idle eviction
**What goes wrong:** Writing a test that "waits 5 minutes and assumes SW is dead" — the SW will STILL be alive because Puppeteer's CDP attach keeps it alive.
**Why it happens:** The 30s idle-eviction rule is well-publicized; the "tests can't reproduce this" caveat is less so.
**How to avoid:** Always use `worker.close()` via CDP in tests. See Q2 sub-question (a).
**Warning signs:** A33 returns "video buffer survived" on EVERY run — including runs where the underlying architecture would actually fail in production. The test is vacuously passing because the SW never died.
### Pitfall 5: A29 chrome.scripting.executeScript injecting MAIN world instead of ISOLATED
**What goes wrong:** rrweb's MutationObserver lives in the ISOLATED world (content script's world). MAIN world DOM mutations DO cross over for DOM events but NOT for the rrweb wrapper's specific listeners.
**Why it happens:** The default world is ISOLATED, so omitting `world:` is fine. But if someone explicitly sets `world: 'MAIN'` thinking "the page realm sees more"... it doesn't.
**How to avoid:** Explicitly write `world: 'ISOLATED'` for documentation/grep value (matches A30/A31's explicit declaration).
**Warning signs:** A29 PASS rate degrades after the cs-injection-world port if the world is set to MAIN.
## Code Examples
Verified patterns from cited sources.
### Pattern 1: Force SW termination in Puppeteer test
```typescript
// Source: https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer
async function stopServiceWorker(browser: Browser, extensionId: string): Promise<void> {
const host = `chrome-extension://${extensionId}`;
const target = await browser.waitForTarget(
(t) => t.type() === 'service_worker' && t.url().startsWith(host)
);
const worker = await target.worker();
if (worker !== null) {
await worker.close();
}
}
```
### Pattern 2: setimmediate polyfill replacement
```typescript
// vite.config.ts (modified — Phase 4 Plan 04-05):
nodePolyfills({
include: ['buffer'],
exclude: ['setimmediate'], // NEW — CSP-hardening per Plan 04-05
globals: { Buffer: true, global: false, process: false },
protocolImports: false,
}),
// src/background/index.ts (top of module, before any other imports
// that might transitively call setImmediate):
if (typeof globalThis.setImmediate === 'undefined') {
(globalThis as { setImmediate?: (fn: (...args: unknown[]) => void, ...args: unknown[]) => void }).setImmediate =
(fn, ...args) => queueMicrotask(() => fn(...args));
}
```
### Pattern 3: A29 cs-injection-world (verbatim port of A30 + sentinel guard)
```typescript
// tests/uat/extension-page-harness.ts (assertA29 rewritten):
// Source: derived from existing assertA30/assertA31; Plan 03-02 + 03-03 SUMMARY
const A29_PROBE_TAB_URL = 'https://example.com/';
const A29_TAB_NAVIGATION_WAIT_MS = 1_500;
const A29_SEGMENT_SETTLE_MS = 11_000;
const A29_MUTATION_SETTLE_MS = 500;
const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel';
const A29_PROBE_DIV_ID = 'a29-probe-mutation';
async function assertA29(): Promise<AssertionResult> {
const result: AssertionResult = { name: 'A29', passed: false, checks: [], diagnostics: [] };
let probeTab: chrome.tabs.Tab | null = null;
try {
diag(result, 'Step 1: setupFreshRecording');
await setupFreshRecording();
diag(result, `Step 2: chrome.tabs.create(${A29_PROBE_TAB_URL})`);
probeTab = await chrome.tabs.create({ url: A29_PROBE_TAB_URL, active: true });
diag(result, `Step 3: wait ${A29_TAB_NAVIGATION_WAIT_MS}ms for content script attach`);
await new Promise((r) => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS));
diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS));
diag(result, 'Step 5: chrome.scripting.executeScript ISOLATED world — inject DOM mutation');
await chrome.scripting.executeScript({
target: { tabId: probeTab.id! },
world: 'ISOLATED',
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],
});
diag(result, `Step 6: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb mutation observer`);
await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS));
diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is active)');
const ack = await sendMessageWithTimeout({ type: 'SAVE_ARCHIVE' }, A29_SAVE_ARCHIVE_TIMEOUT_MS);
result.checks.push({ name: 'A29.1', passed: ack.success === true, expected: 'true', actual: String(ack.success) });
} finally {
if (probeTab?.id !== undefined) {
try { await chrome.tabs.remove(probeTab.id); } catch { /* T-02-04-04 silent-ignore */ }
}
}
result.passed = result.checks.every((c) => c.passed);
return result;
}
```
```typescript
// tests/uat/lib/harness-page-driver.ts (driveA29 — host-side sentinel grep):
// Source: derived from existing driveA30/driveA31 filter-pipeline pattern
import { EventType, IncrementalSource } from '@rrweb/types'; // already imported
const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel'; // mirrors page-side
export async function driveA29(...): Promise<...> {
const r = await harness.assertA29();
// ... existing setup ...
const zipPath = await findLatestZip(downloadsDir);
const zip = await JSZip.loadAsync(fs.readFileSync(zipPath));
const sessionJson = await zip.file('rrweb/session.json')?.async('string') ?? '[]';
const events = JSON.parse(sessionJson) as Array<{ type: EventType; data?: any }>;
// Strict A29.2: filter for rrweb v2 IncrementalSnapshot whose mutation
// payload contains our injected sentinel — proves the mutation came
// from OUR injection, NOT from leftover iana.org page activity.
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)
);
});
r.checks.push({
name: 'A29.2',
passed: sentinelEvents.length >= 1,
expected: `>=1 mutation containing '${A29_MUTATION_SENTINEL}'`,
actual: String(sentinelEvents.length),
});
// ... existing A29.3+ checks for FullSnapshot / Meta / etc. ...
}
```
### Pattern 4: 5-min idle test skeleton (Plan 04-03)
```typescript
// tests/uat/lib/harness-page-driver.ts (driveA33 — new):
// Source: derived from Q2 research + Plan 03-02 cs-injection-world pattern
const A33_IDLE_WAIT_MS = 5 * 60 * 1000;
const A33_NEW_SW_BOOT_MS = 500;
const A33_OVERALL_TIMEOUT_MS = A33_IDLE_WAIT_MS + 60_000;
export async function driveA33(
page: Page,
browser: Browser,
extensionId: string,
downloadsDir: string,
): Promise<AssertionRecord> {
const r: AssertionRecord = { name: 'A33', passed: false, checks: [], diagnostics: [] };
// Step 1: prime recording on the probe tab
await page.evaluate(() => harness.setupFreshRecordingForA33());
// Step 2: wait the 5-min idle interval (real wall-clock)
r.diagnostics.push(`waiting ${A33_IDLE_WAIT_MS}ms for SW idle window`);
await new Promise((r) => setTimeout(r, A33_IDLE_WAIT_MS));
// Step 3: force SW termination via CDP (Puppeteer ≥22.1.0)
await stopServiceWorker(browser, extensionId);
r.diagnostics.push('SW terminated via worker.close()');
// Step 4: brief wait for the SW to fully tear down
await new Promise((r) => setTimeout(r, A33_NEW_SW_BOOT_MS));
// Step 5: dispatch SAVE_ARCHIVE — this wakes the SW back up as an event
const saveResult = await page.evaluate(() => harness.dispatchSaveArchiveForA33());
r.checks.push({
name: 'A33.1',
passed: saveResult.success === true,
expected: 'true',
actual: String(saveResult.success),
});
// Step 6: verify the resulting zip contains a non-empty video buffer
const zipPath = await findLatestZip(downloadsDir);
const zip = await JSZip.loadAsync(fs.readFileSync(zipPath));
const videoEntry = zip.file('video/last_30sec.webm');
const videoSize = videoEntry !== null ? (await videoEntry.async('uint8array')).byteLength : 0;
r.checks.push({
name: 'A33.2',
passed: videoSize > 0,
expected: '>0',
actual: String(videoSize),
});
r.checks.push({
name: 'A33.3',
passed: videoSize > 100_000, // sanity: at least 100 KB (real archives are 1-3 MB)
expected: '>100000',
actual: String(videoSize),
});
r.passed = r.checks.every((c) => c.passed);
return r;
}
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Test SW eviction by waiting 30s idle | `worker.close()` via CDP | Puppeteer 22.1.0 (2024) | Tests can now actually reproduce SW eviction; before, CDP-attached SW never died |
| chrome.alarms for SW keepalive | Long-lived chrome.runtime.Port message traffic | Chrome 110+ (2023) | Port message traffic resets SW idle timer; alarms callbacks DO NOT reset it (audit P1 #8) |
| chrome.storage.sync only | + chrome.storage.session (in-memory) + chrome.storage.local + IndexedDB | Chrome 102+ chrome.storage.session shipped (2022) | Multiple persistence options; chrome.storage.session NOT cross-restart |
| MAIN-world page.evaluate for content-script tests | chrome.scripting.executeScript ISOLATED world via chrome.tabs.create | Plan 03-02 (2026-05-20) | Direct access to ISOLATED-world wrapped APIs (e.g., src/content/index.ts:167 fetch wrapper) |
| `chrome.runtime.reload()` to bounce SW state | `worker.close()` via CDP | Puppeteer 22.1.0+ (2024) | Doesn't re-trigger onInstalled/onStartup/offscreen-recreate; surgical for SW-only restart tests |
**Deprecated/outdated** (carry-over from prior research; no Phase-4-relevant change):
- chrome.tabCapture (replaced by chrome.offscreen + getDisplayMedia per D-01)
- `await import(...)` in SW chunks (BLOCKED per Plan 01-11 spike; w3c/webextensions#212 still open as of 2026)
- chrome.alarms as SW keepalive (replaced by port traffic per D-17)
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The setimmediate polyfill in dist/ comes from `vite-plugin-node-polyfills`'s `setimmediate` transitive dep, NOT from a different bundler-injected source | Q1 Findings | Wrong source → `exclude: ['setimmediate']` is a no-op → `new Function` stays in dist/. Plan 04-05 must verify post-fix grep returns 0. |
| A2 | JSZip's inline `setImmediate` polyfill chain (MessageChannel / postMessage / setTimeout) works correctly in MV3 SW context when no `globalThis.setImmediate` is present | Q1 Findings | JSZip might break in production after `exclude: ['setimmediate']` lands. Pre-checkpoint UAT 33/33 GREEN gate would catch this. |
| A3 | The offscreen document survives SW eviction as long as MediaRecorder is actively recording (per W3C / Chrome offscreen API spirit) | Q2 sub-question (b) | If wrong, the 5-min idle test fails; persistence work needed (Option C IndexedDB). Spike-first approach hedges. |
| A4 | Puppeteer 25.x (our pin) exposes `worker.close()` consistently across the test runs | Q2 Pattern 1 | If `worker.close()` is unavailable on some Puppeteer 25.x patch versions, the test can't simulate SW eviction. WebFetch confirmed ≥22.1.0 supports it; 25.x is comfortably past. |
| A5 | The rrweb v2 IncrementalSource.Mutation enum value AND the `data.adds[*].node.textContent` shape are stable across our pinned rrweb 2.0.0-alpha.4 version | Q3 Pattern 3 code | If wrong, A29's strict sentinel check might fail. Mitigation: add a fallback loose check that asserts events.length >= 1 + first event is Meta; flag the alpha-pin instability for v1.1 rrweb upgrade. |
| A6 | The CONTEXT's listing of "cursor visibility implementation" was authored BEFORE Plan 01-09's opportunistic shipping of `cursor: 'always'` | Finding 4 | If wrong (the CONTEXT was authored with awareness that cursor is shipped), then finding-4 is just a doc-correction task as already framed. Confidence: HIGH that this is a CONTEXT framing oversight given the inline recorder.ts comment explicitly cites Plan 01-09. |
**If this table is empty:** All claims in this research were verified or cited — no user confirmation needed.
(Table is not empty; A1-A6 should be acknowledged by the planner. A1 + A2 in particular have post-fix grep + UAT-green verification gates that catch them quickly.)
## Open Questions
1. **Will the offscreen document actually survive 5 minutes of SW idle?**
- What we know: Chrome docs say offscreen has its own lifecycle independent of SW; recorder.ts MediaRecorder is the "compelling reason" to keep offscreen alive.
- What's unclear: empirical behavior in current Chrome (M132? newer?) — docs don't give a hard answer; behavior may differ if the offscreen is also idle (no segment rotation happening).
- Recommendation: spike-first (Q2 sub-question b). Decide persistence work based on empirical result.
2. **Should A33 (5-min idle test) be in the main UAT loop or env-gated?**
- What we know: A33 alone adds ~5 min to harness wall-clock (95s → ~395s ≈ 6.6 min).
- What's unclear: developer-velocity preference. Some teams accept 6+ min UAT; others gate long tests behind `SKIP_LONG_UAT`.
- Recommendation: env-gate with default-OFF for per-commit runs; default-ON for `npm run test:uat:full` / nightly CI. The planner picks based on user preference.
3. **Should the A29 cs-injection-world rewrite also re-target existing A30/A31 to use a uniform sentinel approach?**
- What we know: A30/A31 already use cs-injection-world but with looser sentinel approaches (A30 uses 5-tuple presence count; A31 uses defense-in-depth absence + presence pair).
- What's unclear: whether the planner wants to unify the sentinel approach across A29/A30/A31 (consistency wins) or keep each plan's existing pattern intact (minimum-surface wins).
- Recommendation: keep them separate. A29's strict approach is special because of the iana.org-leftover flake; A30/A31 don't have that race because they verify their own freshly-typed sentinels.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | All build + test | ✓ | 25.x per package.json @types/node | — |
| npm | All install + scripts | ✓ | (system) | — |
| Chrome | Puppeteer UAT harness | ✓ | bundled by puppeteer ^25.0.2 | — |
| vite | Bundler | ✓ | ^5.4.2 | — |
| vite-plugin-node-polyfills | Buffer polyfill | ✓ | ^0.27.0 (latest 0.28.0) | — |
| tsx | UAT harness runner | ✓ | ^4.22.1 | — |
| TypeScript | Compile gate | ✓ | ^5.5.4 | — |
| ffprobe / ffmpeg | NOT REQUIRED for Phase 4 plans | — | — | — |
**Missing dependencies with no fallback:** None.
**Missing dependencies with fallback:** None.
Phase 4 is purely code/config/docs changes with no new external dependencies. All required tooling is already on the contributor machine per Phase 1's environment expectations.
## Validation Architecture
Per `.planning/config.json` (assumed `workflow.nyquist_validation` is enabled — config not explicitly disabling).
### Test Framework
| Property | Value |
|----------|-------|
| Framework | vitest ^4 (unit) + tsx-runnable harness (UAT) |
| Config file | vitest.config.ts (unit) + tests/uat/harness.test.ts (UAT, NOT vitest-discovered) |
| Quick run command | `npm test` (vitest dot reporter; ~10s) |
| Full suite command | `npm test && npm run test:uat` (~110s + 5+ min if A33 lands) |
### Phase Requirements → Test Map
Phase 4 has NO new functional REQs but verifies the following items:
| Verification Item | Behavior | Test Type | Automated Command | File Exists? |
|-------------------|----------|-----------|-------------------|-------------|
| ROADMAP SC #1 (5-min idle) | A33 harness assertion: 5-min wait + SW kill + SAVE produces non-empty video | UAT | `npm run test:uat` (env: SKIP_LONG_UAT=0) | ❌ Wave 0 — Plan 04-03 creates assertA33+driveA33 |
| ROADMAP SC #2 (fetch + XHR network_error) | A33+ or A30 extension: synthetic fetch + XHR with 404 produces 2 network_error entries | UAT | `npm run test:uat` | ❌ Wave 0 — Plan 04-04 extends driveA30 or creates driveA34 |
| ROADMAP SC #3 (generate-icons ESM/CJS) | `npm run build && node generate-icons.cjs` both succeed | unit | `node generate-icons.cjs` smoke OR new test in tests/build/ | ❌ Wave 0 — Plan 04-05 |
| ROADMAP SC #4 (dead-code grep) | `rg 'permissions\.request' src/` returns 0; `rg '<inline-offscreen-string>' src/ vite.config.ts` returns 0 | unit | `npm test` + new tests/build/dead-code-grep.test.ts | ❌ Wave 0 — Plan 04-05 |
| Audit P1 #11 (fetch URL extraction) | URL extraction correctly handles Request arg + string arg | unit | `pytest tests/content/fetch-interception.test.ts` (or vitest equivalent) | ❌ Wave 0 — Plan 04-02 |
| Audit P1 #14 (navigation URL tracking) | previousUrl module-level tracking; nav events emit prior URL not "unknown" | unit | `npm test -- tests/content/navigation-tracking.test.ts` | ❌ Wave 0 — Plan 04-02 |
| Audit P1 #15 (rrweb timestamps) | rrweb buffer cleanup emits Unix-epoch timestamps not page-load-relative | unit | `npm test -- tests/content/rrweb-timestamps.test.ts` | ❌ Wave 0 — Plan 04-02 |
| A29 race fix | A29 strict sentinel check; PASS 5/5 across consecutive runs | UAT | `for i in {1..5}; do npm run test:uat; done` (manual stress; or one-time confirmation) | ✗ assertA29 + driveA29 EXIST but need rewrite |
| Cursor visibility (verification-only) | grep src/offscreen/recorder.ts for `cursor: 'always'` returns ≥ 1 | unit | `npm test -- tests/build/cursor-visibility.test.ts` | ❌ Wave 0 — Plan 04-06 (optional; current grep gates don't pin this) |
| setimmediate polyfill removal | `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0 | build-gate | `npm run build && grep -c 'new Function' dist/assets/index.ts-*.js` | ❌ Wave 0 — Plan 04-05 extends tests/build/no-test-hooks-in-prod-bundle.test.ts OR adds new build gate |
| Dark-logo currentColor strategy | mokosh-mark.svg stroke = 'currentColor'; welcome.ts uses ?raw + DOMParser inline injection | unit + UAT | `npm test -- tests/welcome/inline-svg.test.ts` + A17.8 update | ❌ Wave 0 — Plan 04-06 |
| ROADMAP backfill (Plans 01-08..01-13) | ROADMAP.md contains plan rows for 01-08, 01-09, 01-10, 01-11, 01-12, 01-13 (5 plans) | docs-grep | `for p in 01-08 01-09 01-10 01-11 01-12 01-13; do grep -c "Plan $p" .planning/ROADMAP.md; done` | ✗ planner-discretion |
### Sampling Rate
- **Per task commit:** `npm test` (vitest unit suite; ~10s)
- **Per wave merge:** `npm test && npm run test:uat` (with SKIP_LONG_UAT=1 default for fast iteration; A33 skipped)
- **Phase gate (Plan 04-08 closure):** `npm test && SKIP_LONG_UAT=0 npm run test:uat` (full A33 5-min idle test runs)
### Wave 0 Gaps
- [ ] `tests/build/no-new-function-in-sw-chunk.test.ts` — covers Q1 setimmediate-polyfill-removal assertion
- [ ] `tests/build/dead-code-grep.test.ts` — covers ROADMAP SC #4
- [ ] `tests/content/fetch-interception.test.ts` — covers audit P1 #11
- [ ] `tests/content/navigation-tracking.test.ts` — covers audit P1 #14
- [ ] `tests/content/rrweb-timestamps.test.ts` — covers audit P1 #15
- [ ] `tests/welcome/inline-svg.test.ts` — covers UI-SPEC inline-SVG injection contract
- [ ] (Optional) `tests/build/cursor-visibility.test.ts` — pins the `cursor: 'always'` constant (defends against accidental future deletion)
- Framework install: none needed (vitest + tsx already in place)
## Security Domain
`security_enforcement` not explicitly set in config; defaulting to enabled per RESEARCH instructions.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | Phase 4 doesn't touch auth surfaces; extension is local-only |
| V3 Session Management | no | No server-side sessions; extension is local-only |
| V4 Access Control | partial | T-1-04 sender-id check + T-1-NEW-05-01 mitigation already in place (SW + offscreen onMessage handlers check `sender.id === chrome.runtime.id`); Phase 4 changes do NOT touch these guards |
| V5 Input Validation | yes | Audit P1 #11 (fetch URL extraction) is an input-validation fix — `args[0]?.toString()` becomes `args[0] instanceof Request ? args[0].url : String(args[0])`. This IS the V5 standard pattern (type-narrow before string-conversion). |
| V6 Cryptography | no | No cryptographic surfaces |
| V7 Error Handling | partial | Audit P1 #15 (rrweb timestamps) is error-data hygiene — wrong-unit timestamps in events.json would confuse downstream analysis. Phase 4 fixes this. |
| V8 Data Protection | partial | REQ-password-confidentiality is OUT OF SCOPE per D-P3-02 charter. A31's existing line-82 filter remains the v1 minimum. Phase 4 does NOT introduce new data-protection surfaces. |
| V14 Configuration | yes | Q1 setimmediate fix tightens MV3 CSP posture by removing the `new Function` static-analysis red flag in the SW chunk. |
### Known Threat Patterns for MV3 Chrome Extension stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Cross-extension message tampering | Spoofing | sender.id === chrome.runtime.id check (already in place) |
| Port hijack from rogue extension | Spoofing + Elevation of Privilege | port.sender?.id === chrome.runtime.id check on every onConnect (already in place) |
| `new Function(string)` in SW = static-analysis red flag for stricter future CSP | Elevation of Privilege | Q1 — replace with safe `queueMicrotask`-based polyfill |
| Test-only hook surface leaking into production bundle | Information Disclosure | Tier-1 FORBIDDEN_HOOK_STRINGS grep gate (12 entries, 0 hits in dist/) (already enforced; Phase 4 preserves) |
| URL.createObjectURL leaks via SW context | Information Disclosure + DoS (memory leak) | D-P2-01 offscreen-minted URL bridge + revoke-on-terminal-state (already in place); Phase 4 does NOT touch this |
| iana.org tab leftover masking test failure | Repudiation (test misleadingly passes) | Q3 strict sentinel guard (Plan 04-01 A29 rewrite) |
| Race between SW eviction and SAVE flow | Tampering (silent data loss) | Q2 — verify offscreen-survives-SW behavior; persist iff broken |
## Sources
### Primary (HIGH confidence)
- [Chrome — extension service worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) — fetched 2026-05-21
- [Chrome — Test SW termination with Puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer) — fetched 2026-05-21
- [Chrome — chrome.storage API reference](https://developer.chrome.com/docs/extensions/reference/api/storage)
- [Chrome — chrome.scripting reference](https://developer.chrome.com/docs/extensions/reference/api/scripting)
- [Chrome — content scripts (match patterns)](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts)
- `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md` (verbatim cs-injection-world pattern + A30 trace)
- `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md` (A31 + A29-flake-disclosure)
- `.planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md` (setimmediate polyfill discovery + deferred-items linkage)
- `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` (setimmediate Phase-5-hardening entry)
- `.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md` (Plan 01-09 cursor visibility constraint — note "deferred to Phase 5" line is stale)
- src/offscreen/recorder.ts (line 285 `cursor: 'always'` confirmed; line 91 `let segments: Blob[] = []` confirmed) [VERIFIED via Read 2026-05-21]
- src/background/index.ts (SW state surface confirmed; lines 74-77 module globals; lines 1110-1133 hasDocument re-detect on init) [VERIFIED via Read 2026-05-21]
- vite.config.ts (current nodePolyfills config — no `exclude` array currently) [VERIFIED via Read 2026-05-21]
- dist/assets/index.ts-8LkXuqac.js (1 `new Function` reference confirmed; ~370 KB) [VERIFIED via grep 2026-05-21]
### Secondary (MEDIUM confidence)
- [Chrome — longer ESW lifetimes blog](https://developer.chrome.com/blog/longer-esw-lifetimes)
- [Chrome — eyeo testing journey blog](https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension)
- [vite-plugin-node-polyfills npm](https://www.npmjs.com/package/vite-plugin-node-polyfills) — confirms `exclude` config option
- [vite-plugin-node-polyfills GitHub](https://github.com/davidmyersdev/vite-plugin-node-polyfills) — same
- [SvelteKit issue #13937](https://github.com/sveltejs/kit/issues/13937) — known production-vs-dev SW polyfill divergence (cross-reference for our scenario)
### Tertiary (LOW confidence — flagged for re-verification if used)
- None.
## Metadata
**Confidence breakdown:**
- Q1 setimmediate polyfill: HIGH — upstream docs + local grep + production-validated pattern
- Q2 SW persistence (a) Puppeteer pattern: HIGH — canonical Chrome devrel pattern
- Q2 SW persistence (b) buffer survives offscreen: MEDIUM — architecture inference; spike-first recommended
- Q2 SW persistence (c) 5-min timeout: HIGH — verified via vitest config inspection
- Q3 A29 cs-injection-world: HIGH — established 2-plan precedent (03-02 + 03-03)
- Finding 4 cursor already shipped: HIGH — verified at src/offscreen/recorder.ts:285
**Research date:** 2026-05-21
**Valid until:** 2026-06-21 (30 days; stable surfaces — Chrome MV3 docs change slowly; vite-plugin-node-polyfills hasn't changed its `exclude` API in ~2 years)
---
## RESEARCH COMPLETE
**Phase:** 04 - harden-clean-up-optional
**Confidence:** HIGH
### Key Findings
- **Q1 setimmediate:** Adopt option (a) — `exclude: ['setimmediate']` + 4-line `queueMicrotask` polyfill in SW entry. Verifiable by grep against built dist/. Drops `new Function` to 0. JSZip falls back to its inline polyfill chain cleanly.
- **Q2 SW persistence:** Use `worker.close()` via Puppeteer CDP (supported on our ^25 pin). Architecture analysis shows segments only live in offscreen RAM — recommend SPIKE-FIRST to verify if the offscreen survives 5-min idle BEFORE committing to persistence work. If offscreen survives: Plan 04-03 is verification-only. If not: use IndexedDB in offscreen for Blob persistence (NOT chrome.storage.session, which is in-memory).
- **Q3 A29 cs-injection-world:** Direct verbatim port of Plan 03-02 / 03-03 pattern. Use `https://example.com/`, ISOLATED world, 1.5s tab-attach wait. **Critical addition:** host-side `driveA29` must check for INJECTED-sentinel string in rrweb mutation payload (NOT just generic EventType counts) to close the iana.org-leftover-flake gap.
- **Finding 4 (out-of-charter):** `cursor: 'always'` is ALREADY SHIPPED at src/offscreen/recorder.ts:285 (Plan 01-09 opportunistic). Plan 04-06 task downgrades from "implement" to "verify + correct stale SUMMARY".
- **Test infrastructure:** 6 new test files needed at Wave 0 (no-new-function-in-sw-chunk, dead-code-grep, 3 content/*.test.ts files for P1 fixes, welcome/inline-svg.test.ts for UI-SPEC).
### File Created
`.planning/phases/04-harden-clean-up-optional/04-RESEARCH.md` (absolute: /home/parf/projects/work/repremium/.planning/phases/04-harden-clean-up-optional/04-RESEARCH.md)
### Confidence Assessment
| Area | Level | Reason |
|------|-------|--------|
| Q1 setimmediate polyfill | HIGH | Upstream docs + local grep verification; canonical fix pattern; reversible |
| Q2 (a) Puppeteer SW kill pattern | HIGH | Canonical Chrome devrel doc; version-confirmed |
| Q2 (b) offscreen lifecycle | MEDIUM | Architecture inference; Chrome docs leave offscreen-vs-SW interplay implicit; spike-first hedges |
| Q2 (c) 5-min timeout integration | HIGH | Verified via vitest config inspection (UAT runs OUTSIDE vitest = no outer timeout) |
| Q3 A29 cs-injection-world | HIGH | Established 2-plan precedent (03-02 + 03-03 both PASS); sentinel-guard refinement is new but obviously-correct |
| Finding 4 cursor shipped | HIGH | Verified via grep at src/offscreen/recorder.ts:285 |
| Audit P1 #11/#14/#15 | (deferred to planner per scope brief) | CONTEXT `<specifics>` has exact diff snippets; planner works from those |
| Dark-logo strategy | (deferred to UI-SPEC) | UI-SPEC `currentColor` strategy already locked + approved 5/6 PASS + 1 FLAG |
### Open Questions (carried forward)
1. Empirical: does the offscreen document survive 5-min SW idle? (Spike-first; informs Plan 04-03 scope.)
2. Should A33 be env-gated (`SKIP_LONG_UAT=0` to enable) or always-on? (Developer-velocity tradeoff; planner picks.)
3. Should A29/A30/A31 unify on the strict sentinel approach? (Recommend no — minimum-surface wins.)
### Ready for Planning
Research complete. Planner can now create PLAN.md files for 04-01..04-08 (per CONTEXT suggested grouping, with finding-4 downgrading Plan 04-06's cursor task and Q2's spike-first recommendation shaping Plan 04-03's Wave 0 structure).
---
*Phase: 04-harden-clean-up-optional*
*Research date: 2026-05-21*