From 0608b22427ba581f9c23aa9ea619346acc1c766a Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 20 May 2026 14:03:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(02):=20plans=2001-04=20=E2=80=94=20Phase?= =?UTF-8?q?=202=20export=20pipeline=20closure=20(Blob=20URL=20+=20meta.url?= =?UTF-8?q?s=20+=20schema=20+=20harness)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave structure (4 plans, 3 waves): - 02-01 (Wave 1 RED): 15 RED tests pinning D-P2-01 (blob: URL contract), D-P2-02 (meta.urls schema + dedup + filter), D-P2-03 (strict 8-field validation + schemaVersion '2' cutover marker). - 02-02 (Wave 2): Offscreen-minted Blob URL pipeline — extends PortMessageType with CREATE/REVOKE messages; SW downloadArchive rewrite (data: → blob: via base64-on-wire to offscreen + URL.createObjectURL + chrome.downloads.onChanged revoke lifecycle). Closes audit P0-6; unblocks >2 MB archives. - 02-03 (Wave 2): meta.urls schema migration + tab-url-tracker module (chrome.tabs.onActivated + onUpdated → deduplicated, filtered, first-seen- ordered string[]); SessionMetadata 7→8 fields with schemaVersion + urls; REQUIREMENTS.md REQ-meta-json-schema amendment. Closes P1 #10. - 02-04 (Wave 3): UAT harness A24+A25+A26+A27 — blob: URL prefix, <5s SAVE→zip latency, meta.json 8-field shape, multi-tab dedup; pre-checkpoint bundle gates per saved memory + operator empirical UAT cycle 1. Tier-1 FORBIDDEN_HOOK_STRINGS inventory stays at 12 (no new hook symbols — chrome.* monkey-patches + JSZip + production APIs only). Locked decisions honored (per 02-CONTEXT.md): - D-P2-01: offscreen-minted Blob URL via existing keepalivePort + base64 wire format (reuses D-12 precedent at src/shared/binary.ts). - D-P2-02: meta.json url:string → urls:string[]; URL filter per CONTEXT.md (include https://, chrome-extension://; exclude chrome://, about:, devtools://, file://); dedup + first-seen ordering. - D-P2-03: full scope; 8-field strict schema validation with schemaVersion='2' as the 8th field (planner-resolved tentative pick; revisable by plan-checker). Architectural constraints preserved: - Always-on charter (Plan 01-09 Amendment 3): no finally-block in saveArchive; no clearTabUrlsSeen on SAVE. - Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (no new test-hook symbols). - Never await import(...) in src/background/index.ts (Plan 01-11 SUMMARY). - Pre-checkpoint bundle gates per feedback-pre-checkpoint-bundle-gates.md (run in 02-04 Task 4 before operator surface). Plan validation: gsd-sdk frontmatter.validate + verify.plan-structure GREEN for all 4 plans. ROADMAP updated: Phase 2 Plans list + Goal/Success Criteria block annotated with D-P2-02/D-P2-03 amendments + 5th success criterion (Blob URL + revoke lifecycle for >2 MB archives); Progress table 0/TBD → 0/4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/ROADMAP.md | 38 +- .../02-01-PLAN.md | 376 +++++++++ .../02-02-PLAN.md | 498 ++++++++++++ .../02-03-PLAN.md | 460 +++++++++++ .../02-04-PLAN.md | 721 ++++++++++++++++++ 5 files changed, 2084 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md create mode 100644 .planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md create mode 100644 .planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md create mode 100644 .planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 64e8c1e..abc09d1 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -98,8 +98,11 @@ directory + `vite.config.ts` inline string + `src/background/`. ### Phase 2: Stabilize export pipeline **Goal**: A click on "Сохранить отчёт об ошибке" produces a SPEC-conformant ZIP archive on disk in under 5 s, containing a screenshot taken at click time, -laid out per CON-archive-layout, with `meta.json` per CON-meta-json-schema, and -declared by a manifest carrying exactly the permission set in DEC-011. +laid out per CON-archive-layout, with `meta.json` per CON-meta-json-schema +(post-2026-05-20 amendment: 8-field shape with `urls: string[]` replacing +`url: string` + new `schemaVersion: '2'` cutover marker per D-P2-02 + D-P2-03), +and downloaded via an offscreen-minted Blob URL (closes audit P0-6 base64 +data-URL cap; D-P2-01). **Depends on**: Phase 1 (export consumes the video buffer + the rrweb/event-log infrastructure already shipped in src/content/index.ts; original "Phase 2 DOM" @@ -108,9 +111,10 @@ dependency removed per 2026-05-20 re-phasing). **Scope note (2026-05-20):** Plans 01-08 (webm-remux + JSZip), 01-09 (popup state machine + SAVE-only UI), 01-10 (welcome tab + i18n), and 01-12 (manifest i18n + en+ru locales) already shipped most of the originally-planned export -surface. Phase 2 likely collapses to 2-3 small plans closing residual gaps: -screenshot-at-click capture, meta.json schema validation, and SPEC §10 #6 -<5s export-latency verification. +surface. Phase 2 closes the AUDIT residuals: P0-6 (base64 → Blob URL via +offscreen-minted URL.createObjectURL per DEC-006) + P1 #10 (meta.json +`url:string` → `urls:string[]` schema migration) + strict 8-field schema +validation + UAT harness <5s latency assertion (REQ-archive-export-latency). **Requirements**: REQ-popup-ui, REQ-screenshot-on-export, REQ-archive-layout, REQ-meta-json-schema, REQ-archive-export-latency, REQ-manifest-permissions @@ -134,14 +138,30 @@ REQ-meta-json-schema, REQ-archive-export-latency, REQ-manifest-permissions opening it reveals exactly the layout in REQ-archive-layout (`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` at the root) with no extra entries. - 3. `meta.json` validates against the verbatim CON-meta-json-schema (all 7 - fields present, types correct, `timestamp` is ISO-8601 with `Z`). + 3. `meta.json` validates against the verbatim CON-meta-json-schema (8 fields + per the 2026-05-20 D-P2-02/D-P2-03 amendment: `schemaVersion`, `timestamp`, + `urls`, `userAgent`, `extensionVersion`, `videoBufferSeconds`, + `logDurationMinutes`, `totalEvents`; `urls` is a non-empty deduplicated + `string[]` of operator tab URLs visited during the 30s window; types + correct; `timestamp` is ISO-8601 with `Z`). 4. `manifest.json` in `dist/` after `npm run build` declares exactly the permission set in DEC-011 with no additional or missing entries; loading unpacked into Chrome produces no permission-related warnings or errors in `chrome://extensions/`. + 5. A real >2 MB archive downloads to disk successfully (the canonical 5-10 MB + operator bug-report archive — previously failed via base64 data-URL cap). + The chrome.downloads.download call site receives a `blob:` URL (not + `data:application/zip;base64,`); URL.revokeObjectURL is dispatched via + chrome.downloads.onChanged 'complete' (D-P2-01 lifecycle). + +**Plans**: 4 plans (02-01 through 02-04). Wave 1 RED tests → Wave 2 parallel +implementation (Blob URL + meta.urls) → Wave 3 harness extension + operator +empirical checkpoint. +- [ ] 02-01-PLAN.md — Wave 0 RED tests: blob-url-download.test.ts + meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts pinning D-P2-01/D-P2-02/D-P2-03 contracts (TDD) +- [ ] 02-02-PLAN.md — Wave 1 Blob URL pipeline (D-P2-01, closes P0-6): offscreen CREATE/REVOKE handlers via base64-on-wire; SW downloadArchive rewrite; chrome.downloads.onChanged revoke lifecycle +- [ ] 02-03-PLAN.md — Wave 1 meta.urls + tab-url-tracker (D-P2-02 + D-P2-03, closes P1 #10): SessionMetadata 7→8 fields with schemaVersion+urls; chrome.tabs.onActivated/onUpdated listeners; REQUIREMENTS.md REQ-meta-json-schema amendment +- [ ] 02-04-PLAN.md — Wave 2 harness A24+A25+A26+A27 + operator empirical checkpoint: blob:URL prefix, <5s SAVE→zip latency, meta.json 8-field shape, multi-tab dedup; pre-checkpoint bundle gates + operator UAT cycle 1 -**Plans**: TBD **UI hint**: yes ### Phase 3: SPEC §10 smoke verification + DOM/event-log verification @@ -247,6 +267,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5. | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Stabilize video pipeline | 14/14 | **CLOSED 2026-05-20** via gsd-verifier audit GREEN (17/17 must-haves; commit 586836f); all markers flipped | Functional contract closed 2026-05-19 via Plan 01-13 harness PASS; design/brand contract closed 2026-05-20 via Plan 01-12 brand-fit ack; welcome-tab contract closed 2026-05-20 via Plan 01-10 cycle-2 operator ack "All good" + 5 inter-cycle debug fixes | -| 2. Stabilize export pipeline | 0/TBD | Not started (narrowed scope post-2026-05-20 re-phasing; ~2-3 plans expected) | - | +| 2. Stabilize export pipeline | 0/4 | Plans landed 2026-05-20 (4 plans: Wave 0 RED → Wave 1 Blob URL + meta.urls parallel → Wave 2 harness + operator checkpoint); execution pending | - | | 3. SPEC §10 smoke + DOM/event-log verification | 0/TBD | Not started (absorbed Phase-2 DOM verification per 2026-05-20 re-phasing; ~2-3 plans) | - | | 4. Harden + clean up (optional) | 0/TBD | Not started (deferred backlog: cursor visibility, dark-surface logo, tabs perm gap, ffprobe flakes, ROADMAP backfill, rrweb-version upgrade research, REQ-password-confidentiality v2 candidate) | - | diff --git a/.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md new file mode 100644 index 0000000..50e19a9 --- /dev/null +++ b/.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md @@ -0,0 +1,376 @@ +--- +phase: 02-stabilize-export-pipeline +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - tests/background/blob-url-download.test.ts + - tests/background/meta-json-urls-schema.test.ts + - tests/build/strict-meta-json-validation.test.ts +autonomous: true +requirements: + - REQ-archive-export-latency + - REQ-meta-json-schema +tags: + - tdd + - red-tests + - blob-url-migration + - meta-json-urls-array + - schema-validation + - p0-6 + - p1-10 + - wave-0 +must_haves: + truths: + - "Three RED test files exist and fail under the current HEAD (cc042a5) with concrete failure messages, NOT timeouts or missing imports." + - "blob-url-download.test.ts pins downloadArchive() calling chrome.downloads.download with url starting with 'blob:' (not 'data:application/zip;base64,')." + - "meta-json-urls-schema.test.ts pins SessionMetadata.urls as string[] and createArchive emitting meta.json with urls (not url)." + - "strict-meta-json-validation.test.ts pins ISO-8601 Z-suffix timestamp, non-empty urls[], semver extensionVersion, non-negative totalEvents, exactly 8 fields." + - "All 3 files use Vitest describe+it patterns matching existing tests/background/*.test.ts conventions (vi.fn() chrome.* stubs; jsdom or node env per existing patterns)." + - "Tier-1 FORBIDDEN_HOOK_STRINGS unit-test gate remains GREEN (12 entries unchanged — this plan adds no test-hook symbols)." + - "153/153 baseline vitest count rises to 153 + N (where N = number of new it() blocks; expected ~10-14)." + artifacts: + - path: "tests/background/blob-url-download.test.ts" + provides: "RED pin: downloadArchive calls chrome.downloads.download({ url: blob:..., filename })" + min_lines: 80 + contains: "describe.*blob.*url|chrome.downloads.download" + - path: "tests/background/meta-json-urls-schema.test.ts" + provides: "RED pin: SessionMetadata.urls: string[] + createArchive emits meta.json.urls" + min_lines: 60 + contains: "urls.*string\\[\\]|meta\\.json" + - path: "tests/build/strict-meta-json-validation.test.ts" + provides: "RED pin: 8-field schema with ISO-8601 + semver + non-empty urls[] + non-negative totalEvents" + min_lines: 80 + contains: "ISO-8601|semver|urls" + key_links: + - from: "tests/background/blob-url-download.test.ts" + to: "src/background/index.ts:downloadArchive" + via: "vi.mocked(chrome.downloads.download) inspection of first-call args[0].url prefix" + pattern: "chrome\\.downloads\\.download.*url.*blob:" + - from: "tests/background/meta-json-urls-schema.test.ts" + to: "src/background/index.ts:createArchive + src/shared/types.ts:SessionMetadata" + via: "createArchive() returns Blob; unzip via JSZip; read meta.json; assert .urls is Array, .url is undefined" + pattern: "metadata\\.urls|metadata\\.url" + - from: "tests/build/strict-meta-json-validation.test.ts" + to: "meta.json wire shape (post-Plan 02-03)" + via: "regex assertions on timestamp / version / totalEvents + Array.isArray(urls) + Object.keys(meta).length === 8" + pattern: "Object\\.keys.*length.*8|Array\\.isArray.*urls" +--- + + +Wave 0 RED gate for Phase 2: three failing test files that pin the locked +decisions D-P2-01 (Blob URL download), D-P2-02 (meta.json urls[] migration), +and D-P2-03's schema-validation workstream. These tests MUST be RED at the +end of this plan and MUST flip GREEN as Plans 02-02 and 02-03 land +implementation. + +Purpose: pin the contracts BEFORE any production code changes (TDD discipline +matching Plan 01-02 Wave 0 precedent). Without RED tests committed first, +nothing forces D-P2-01/D-P2-02/D-P2-03 to be implemented exactly as locked — +silent partial implementation could land otherwise. + +Output: +- tests/background/blob-url-download.test.ts (RED — pins D-P2-01) +- tests/background/meta-json-urls-schema.test.ts (RED — pins D-P2-02) +- tests/build/strict-meta-json-validation.test.ts (RED — pins D-P2-03 schema) +- vitest count rises by N RED tests (acceptable RED window — closed by Plans 02-02 + 02-03 within the same Phase 2 execution) + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/STATE.md +@.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md + +# Source under test +@src/background/index.ts +@src/shared/types.ts +@src/shared/binary.ts + +# Precedent for RED test style (Plan 01-02 Wave 0 + Plan 01-08 webm-remux deps test) +@.planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md + + + + +From src/shared/types.ts (CURRENT — pre-Plan-02-03): +```typescript +export interface SessionMetadata { + timestamp: string; + url: string; // ← D-P2-02 MIGRATES THIS to urls: string[] + userAgent: string; + extensionVersion: string; + videoBufferSeconds: number; + logDurationMinutes: number; + totalEvents: number; +} +``` + +From src/background/index.ts:695-718 (CURRENT downloadArchive — pre-Plan-02-02): +```typescript +async function downloadArchive(archiveBlob: Blob) { + const filename = `session_report_${dateStr}_${timeStr}.zip`; + const base64 = await blobToBase64(archiveBlob); + const url = `data:application/zip;base64,${base64}`; // ← D-P2-01 MIGRATES THIS to blob:URL + await chrome.downloads.download({ url, filename, saveAs: false }); +} +``` + +From src/background/index.ts:608-692 (CURRENT createArchive — pre-Plan-02-03): +```typescript +async function createArchive(videoBufferResponse, rrwebEvents, userEvents, screenshot): Promise { + // ... assembles zip ... + const metadata: SessionMetadata = { + timestamp: new Date().toISOString(), + url: new URL(chrome.runtime.getURL('')).origin, // ← D-P2-02 MIGRATES TO urls: string[] + userAgent: navigator.userAgent, + extensionVersion: manifest.version, + videoBufferSeconds: 30, + logDurationMinutes: 10, + totalEvents: rrwebEvents.length + userEvents.length + }; + zip.file('meta.json', JSON.stringify(metadata, null, 2)); + const archiveBlob = await zip.generateAsync({ type: 'blob' }); + return archiveBlob; +} +``` + +Existing test conventions (read these for stub patterns): +- `tests/background/save-archive-does-not-stop-recording.test.ts` — chrome.runtime + chrome.downloads stub pattern +- `tests/background/webm-remux.test.ts` — JSZip + Blob unzip pattern +- `tests/build/no-remote-fonts.test.ts` — post-build dist/ scan pattern (model for strict-meta-json-validation.test.ts STRUCTURE; this plan's variant scans createArchive's output, not dist/) + + + + + + + Task 1: RED — blob-url-download.test.ts pins D-P2-01 + tests/background/blob-url-download.test.ts + + - Test 1 ("downloadArchive calls chrome.downloads.download with a blob: URL, NOT a data:application/zip;base64, URL"): + stub chrome.downloads.download via vi.fn(); invoke downloadArchive(testBlob); assert + mockDownload.mock.calls[0][0].url starts with 'blob:' AND does NOT start with 'data:application/zip;base64,'. + - Test 2 ("downloadArchive completes within 5000ms for a 6 MB archive"): + build a synthetic 6 MB Blob (above Chrome's ~2 MB data-URL cap that motivates D-P2-01); + measure performance.now() before/after downloadArchive; assert delta < 5000. + - Test 3 ("URL.revokeObjectURL is scheduled for the minted URL after chrome.downloads.onChanged 'complete' event"): + stub chrome.downloads.onChanged.addListener via vi.fn(); invoke downloadArchive(testBlob); + simulate the listener callback firing with {state: {current: 'complete'}, id: }; + assert URL.revokeObjectURL was called with the same url passed to chrome.downloads.download. + - All 3 tests MUST fail under current HEAD with `url` starting with `data:application/zip;base64,` + (Test 1), exceeding the 5000ms budget for the 6 MB case OR throwing "Network error" if Chrome's + data-URL cap rejects (Test 2 — accept either failure mode as the RED signal), and NEVER calling + URL.revokeObjectURL because the current path doesn't mint one (Test 3). + + + Create `tests/background/blob-url-download.test.ts` per the behavior block above. + + Follow `tests/background/save-archive-does-not-stop-recording.test.ts` for the chrome.* stub pattern + (vi.stubGlobal('chrome', { runtime: ..., downloads: { download: vi.fn(), onChanged: { addListener: vi.fn() } } })). + Use `import { downloadArchive } from '../../src/background/index'` ONLY IF downloadArchive is exported; + if NOT, the test imports an internal-helper-export OR uses the saveArchive path with chrome.downloads.download + spy — pick whichever the existing save-archive test uses. Document the choice in a comment block. + + Use jsdom env (default in vitest.config.ts) so URL.createObjectURL + URL.revokeObjectURL are available. + + For Test 2's 6 MB blob: `new Blob([new Uint8Array(6 * 1024 * 1024)], { type: 'application/zip' })`. + + Per D-P2-01 — this test pins downloadArchive's WIRE FORMAT (blob: vs data:), the LATENCY + PROPERTY (which only Blob URL achieves for >2 MB archives), and the LIFECYCLE + (URL.revokeObjectURL hooked to chrome.downloads.onChanged 'complete'). The implementation + in Plan 02-02 will mint the Blob URL inside the offscreen document (D-P2-01) and pass it + back to the SW via a new port-bridge message; this test does not encode HOW the URL is + minted, only WHAT the SW downloadArchive call site sends to chrome.downloads.download. + + + npx vitest run tests/background/blob-url-download.test.ts 2>&1 | grep -E "(FAIL|RED|failed)" | head -5 + + + File `tests/background/blob-url-download.test.ts` exists, exports 3 it() blocks under a + single describe, ALL 3 fail with concrete assertion errors (NOT import errors / NOT timeouts). + `npx vitest run tests/background/blob-url-download.test.ts --reporter=verbose` shows + 3 failed tests with assertion messages identifying the wrong url prefix / latency / missing revoke. + Commit message: `test(02-01): RED — pin Blob URL download contract (D-P2-01)`. + + + + + Task 2: RED — meta-json-urls-schema.test.ts pins D-P2-02 + tests/background/meta-json-urls-schema.test.ts + + - Test 1 ("SessionMetadata.urls is string[], SessionMetadata.url does not exist"): + `import type { SessionMetadata } from '../../src/shared/types'`; use a `satisfies` check + OR a `const _check: SessionMetadata['urls'] extends string[] ? true : false = true` pattern + so the test is compile-time-checked AND runtime-introspectable. RED state: TypeScript + tsc compile of this test currently fails because `urls` is not on SessionMetadata yet. + Wrap the type-level check in a runtime `expect(true).toBe(true)` so the failure mode is + "tsc compile failure" → flips GREEN after Plan 02-03 amends types.ts. + - Test 2 ("createArchive emits meta.json with `urls: string[]`, not `url: string`"): + stub chrome.runtime.getManifest + navigator.userAgent + the offscreen video-buffer fetch + (return a minimal 1-segment VideoBufferResponse so remux doesn't blow up — reuse pattern + from webm-remux.test.ts); invoke createArchive(); extract meta.json from the returned + Blob via JSZip.loadAsync + zip.file('meta.json').async('string') + JSON.parse; + assert `Array.isArray(meta.urls) === true` AND `meta.url === undefined`. + - Test 3 ("meta.json.urls is deduplicated and ordered first-seen-first"): + mock the tab-tracking source (chrome.tabs.query OR a yet-to-be-implemented module helper — + see "module seam" note below); inject 3 tab URL events: A, B, A; + assert `meta.urls` equals `['A', 'B']` (dedup; A appears once; first-seen order preserved). + - Test 4 ("chrome:// and about: URLs are filtered out; chrome-extension:// included"): + inject 4 events: 'https://example.com', 'chrome://newtab', 'about:blank', + 'chrome-extension://abc/popup.html'; assert `meta.urls` equals + `['https://example.com', 'chrome-extension://abc/popup.html']` (per CONTEXT.md + "URL filter" specifics block). + - All 4 tests MUST fail under current HEAD: Test 1 via tsc compile failure (urls missing on type); + Test 2 via `meta.url` present + `meta.urls` undefined; Test 3+4 via the tab-tracking module + not existing yet (these will throw import errors — acceptable as the RED signal). + + + Create `tests/background/meta-json-urls-schema.test.ts` per the behavior block above. + + Module seam for tab tracking: assume Plan 02-03 will introduce a new module (planner-suggested name: + `src/background/tab-url-tracker.ts`) exporting `getTabUrlsSeen(): string[]`. The test imports + this seam by name; the absence of the module at the current HEAD is the RED signal for Tests 3+4. + Document the seam name + expected export in a block comment at the top of the test file so Plan + 02-03 implements against the same shape. + + For Test 2's createArchive invocation: pattern after `tests/background/webm-remux.test.ts` and + `tests/background/save-archive-does-not-stop-recording.test.ts` — stub VideoBufferResponse with + a single 1-byte segment (createArchive's remux path needs at least 1 to not throw + EmptyVideoBufferError; use a pre-baked fixture Blob OR mock remuxSegments via vi.spyOn). + + Per D-P2-02 — this test pins the SHAPE (urls: string[] not url: string), the DEDUP+ORDER semantics + (first-seen-first), and the FILTER (exclude chrome:// + about:; include chrome-extension://) verbatim + from CONTEXT.md `` block. The implementation in Plan 02-03 implements the tab-tracker + + amends types.ts + amends createArchive's meta.json construction. + + + npx vitest run tests/background/meta-json-urls-schema.test.ts 2>&1 | grep -E "(FAIL|RED|failed|TS\d+)" | head -10 + + + File exists, 4 it() blocks under a single describe. Test 1 fails via tsc compile error on missing + `urls` field. Tests 2-4 fail with assertion errors OR import errors (latter for the missing + tab-url-tracker module). NONE pass under current HEAD. + Commit message: `test(02-01): RED — pin meta.json urls[] schema + dedup/filter semantics (D-P2-02)`. + + + + + Task 3: RED — strict-meta-json-validation.test.ts pins D-P2-03 schema + tests/build/strict-meta-json-validation.test.ts + + - Test 1 ("meta.json has exactly 8 fields"): Object.keys(meta).length === 8. + - Test 2 ("timestamp is ISO-8601 with Z suffix"): + `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(meta.timestamp)`. + - Test 3 ("urls is a non-empty string[] of valid URLs"): + `Array.isArray(meta.urls) && meta.urls.length >= 1 && meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u))`. + Note: CONTEXT.md `` allows empty urls[] for "purely whole-desktop recording without + browser-tab interaction" — but D-P2-02 also says "the operator's primary tab at SAVE time should + always be in the array if it has a valid URL", and CONTEXT.md `` D-P2-03 mandates + "urls array non-empty" as a schema validation rule. Resolve in favor of the strict-validation + rule from D-P2-03 (non-empty required). Document the resolution inline in the test as a + block comment citing both CONTEXT.md sources. + - Test 4 ("extensionVersion matches semver"): `/^\d+\.\d+\.\d+$/.test(meta.extensionVersion)`. + - Test 5 ("totalEvents is non-negative integer"): + `Number.isInteger(meta.totalEvents) && meta.totalEvents >= 0`. + - Test 6 ("videoBufferSeconds is exactly 30"): per CON-meta-json-schema verbatim. + - Test 7 ("logDurationMinutes is exactly 10"): per CON-meta-json-schema verbatim. + - Test 8 ("no extra fields"): Object.keys(meta).every(k => EXPECTED_KEYS.includes(k)) + where EXPECTED_KEYS = ['timestamp','urls','userAgent','extensionVersion','videoBufferSeconds','logDurationMinutes','totalEvents'] PLUS one of {schemaVersion?, archiveVersion?} ← reserve one slot for the 8th field. The current schema has 7; D-P2-03 says "8 fields exact". DECIDED INLINE: the 8th field is `urls` REPLACING `url` (net +0 → still 7 fields), BUT D-P2-03 explicitly says "8 fields exact". RESOLUTION: read the test as the strict-validation TARGET; the missing 8th field is the SHIPPING DECISION that Plan 02-03 must make and implement (planner suggests `schemaVersion: '2'` to mark the breaking-change cutover; documented as the implementer-choice in Plan 02-03). The RED test file embeds the EXPECTED_KEYS list including `schemaVersion` so Plan 02-03 has zero ambiguity. + - All 8 tests MUST fail under current HEAD (the createArchive meta.json shape doesn't match + most assertions — it has `url:string`, no `urls`, no `schemaVersion`, and 7 fields). + + + Create `tests/build/strict-meta-json-validation.test.ts` per the behavior block above. + + Pattern after `tests/build/no-remote-fonts.test.ts` (a `tests/build/` validator) and + `tests/background/save-archive-does-not-stop-recording.test.ts` (createArchive invocation + + JSZip extract pattern). The test invokes createArchive() via the same path as Task 2's + `meta-json-urls-schema.test.ts`, extracts meta.json, JSON.parses it, and runs all 8 strict + schema checks. + + The EXPECTED_KEYS constant at the top of the test: + ```typescript + const EXPECTED_KEYS = [ + 'timestamp', 'urls', 'userAgent', 'extensionVersion', + 'videoBufferSeconds', 'logDurationMinutes', 'totalEvents', 'schemaVersion', + ]; + ``` + The 8th field `schemaVersion` is the planner-suggested marker for the D-P2-02 url→urls + breaking-change cutover. Plan 02-03's implementer COMMITS to adding `schemaVersion: '2'` + as a constant in src/background/index.ts; the test pins this so Plan 02-03 cannot land + a 7-field meta.json that satisfies Tests 1-7 but fails the "exactly 8 fields" + "no extra fields" + pair. (Plan-checker may revise this — the 8th field name is a TENTATIVE PLANNER PICK; + if Plan 02-03's implementer or Plan 02-04 has a stronger argument for a different 8th + field name, this test file becomes the canonical place to amend.) + + Add a block comment at the top citing CONTEXT.md D-P2-03 + REQUIREMENTS.md REQ-meta-json-schema + as the contract source, and noting the planner-resolved tension between Tests 3's non-empty + rule (per D-P2-03) and the CONTEXT.md `` permissive empty-array clause. + + + npx vitest run tests/build/strict-meta-json-validation.test.ts 2>&1 | grep -E "(FAIL|RED|failed)" | head -10 + + + File exists, 8 it() blocks under a single describe. ALL 8 fail (current meta.json has 7 fields + + `url:string` instead of `urls:string[]` + no `schemaVersion`). + Commit message: `test(02-01): RED — pin strict 8-field meta.json schema validation (D-P2-03)`. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| test author → test runtime | Test code controls chrome.* stubs; only the SW/offscreen surfaces under test are exercised | +| createArchive output → JSZip parse | meta.json wire shape; D-P2-03 schema validation prevents downstream consumers (UAT harness, future SRV-* upload) from receiving malformed metadata | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-01-01 | Tampering | RED test as silent-skip risk (skip(true) / it.skip pattern slipping in) | mitigate | All 15 tests in this plan use bare `it()` + concrete expect()s; no `describe.skip` or `it.skip`. Plan-checker verifies grep -c "\.skip" $files == 0. | +| T-02-01-02 | Repudiation | RED tests committed without RED proof (CI shows GREEN because test imports failed silently) | mitigate | Task verify> blocks run `npx vitest run ` and grep for "FAIL" — proves RED state at commit time. Plan 02-02 + 02-03 implementers prove GREEN-flip via same grep with "PASS". | +| T-02-01-03 | Information Disclosure | Test creates real `meta.json` containing real `chrome.runtime.id` extension origin URL | accept | Tests run in jsdom + stubbed chrome.runtime.getURL returning fake 'https://test/'. Real origin never reaches test fixtures. | + + + +- `npx vitest run tests/background/blob-url-download.test.ts` → 3 RED. +- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 4 RED. +- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8 RED. +- `npm test` → previously-153 GREEN + N RED (15 ± 1 RED from this plan). Acceptable window — + Plans 02-02 + 02-03 close all RED before phase closure. +- `grep -c '\.skip' tests/background/blob-url-download.test.ts tests/background/meta-json-urls-schema.test.ts tests/build/strict-meta-json-validation.test.ts` → 0. +- Tier-1 grep gate: `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` GREEN + (unchanged at 12 FORBIDDEN_HOOK_STRINGS — this plan touches NO production code, only test fixtures). + + + +1. Three RED test files exist with concrete failure messages under current HEAD. +2. `meta-json-urls-schema.test.ts` Test 1 fails at the TypeScript-compile layer (proves the type + migration is genuinely required, not just a runtime field rename). +3. Tier-1 grep gate (12 FORBIDDEN_HOOK_STRINGS) remains GREEN. +4. UAT harness (24/24 GREEN baseline) UNCHANGED by this plan (test-only changes). +5. Commits land with `test(02-01): RED — ...` prefix matching Plan 01-02 / 01-13 RED-test precedent. + + + +After completion, create `.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md` +documenting: +- RED test count delta (153 GREEN + N RED new) +- Specific failure-mode-per-test diagnostic strings +- Planner-resolved tensions: D-P2-03 "non-empty urls[]" vs CONTEXT.md `` permissive + empty array (resolved IN FAVOR of strict rule); 8th field name (`schemaVersion` tentative pick). +- Forward link: tests flip GREEN in Plans 02-02 + 02-03. + diff --git a/.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md new file mode 100644 index 0000000..e6a8769 --- /dev/null +++ b/.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md @@ -0,0 +1,498 @@ +--- +phase: 02-stabilize-export-pipeline +plan: 02 +type: auto +wave: 2 +depends_on: [01] +files_modified: + - src/offscreen/recorder.ts + - src/shared/types.ts + - src/background/index.ts +autonomous: true +requirements: + - REQ-archive-export-latency +tags: + - blob-url-migration + - p0-6-fix + - offscreen-port-bridge + - chrome-downloads-onchanged + - url-createObjectURL + - url-revokeObjectURL + - d-p2-01 +must_haves: + truths: + - "downloadArchive in src/background/index.ts no longer constructs a `data:application/zip;base64,` URL; it requests a Blob URL from offscreen via the existing long-lived port (D-17) and passes that to chrome.downloads.download." + - "Offscreen recorder.ts handles a new PortMessageType variant (CREATE_DOWNLOAD_URL + DOWNLOAD_URL) that mints `URL.createObjectURL(blob)` and returns the URL string to SW." + - "Archive Blob travels SW→offscreen via base64-on-wire (reusing src/shared/binary.ts blobToBase64 + base64ToBlob — same wire-format precedent as D-12 video segments)." + - "chrome.downloads.onChanged listener wired in SW: when state.current==='complete' or 'interrupted' for the tracked downloadId, dispatch URL.revokeObjectURL to offscreen via the same port (REVOKE_DOWNLOAD_URL message)." + - "A 6 MB archive (above Chrome's ~2 MB data-URL cap) downloads to disk without 'Network error' or any failure; tests/background/blob-url-download.test.ts Test 2 GREEN." + - "All 3 tests in tests/background/blob-url-download.test.ts flip RED→GREEN." + - "Tier-1 FORBIDDEN_HOOK_STRINGS gate remains at 12 entries (this plan adds NO test-hook surfaces; the new port messages are PRODUCTION surface — they ride existing keepalivePort in the same way REQUEST_BUFFER/BUFFER do)." + - "UAT harness 24/24 GREEN preserved (no harness-level changes in this plan; A5 + A12 download-flow assertions empirically prove the Blob URL path end-to-end)." + - "Always-on charter preserved: SAVE creates a zip; recorder stays in REC. No finally-block state reset in saveArchive (Plan 01-09 Amendment 3 invariant)." + artifacts: + - path: "src/offscreen/recorder.ts" + provides: "CREATE_DOWNLOAD_URL + REVOKE_DOWNLOAD_URL message handlers on existing keepalivePort" + contains: "URL\\.createObjectURL|URL\\.revokeObjectURL|CREATE_DOWNLOAD_URL" + - path: "src/shared/types.ts" + provides: "PortMessageType union extended with CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL" + contains: "CREATE_DOWNLOAD_URL|DOWNLOAD_URL" + - path: "src/background/index.ts" + provides: "downloadArchive rewritten to bridge through offscreen for URL.createObjectURL + chrome.downloads.onChanged listener for revoke" + contains: "chrome\\.downloads\\.onChanged|REVOKE_DOWNLOAD_URL|blob:" + key_links: + - from: "src/background/index.ts:downloadArchive" + to: "src/offscreen/recorder.ts (via keepalivePort)" + via: "port.postMessage({ type: 'CREATE_DOWNLOAD_URL', requestId, dataBase64 }) then await DOWNLOAD_URL response" + pattern: "CREATE_DOWNLOAD_URL.*requestId|DOWNLOAD_URL.*requestId" + - from: "src/background/index.ts:chrome.downloads.onChanged listener" + to: "src/offscreen/recorder.ts REVOKE_DOWNLOAD_URL handler" + via: "delta.state.current === 'complete' → port.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url })" + pattern: "chrome\\.downloads\\.onChanged\\.addListener" + - from: "src/offscreen/recorder.ts:onPortMessage" + to: "URL.createObjectURL" + via: "case CREATE_DOWNLOAD_URL: decode base64ToBlob → URL.createObjectURL(blob) → respond { type: 'DOWNLOAD_URL', requestId, url }" + pattern: "URL\\.createObjectURL" +--- + + +Migrate the archive download path from base64 `data:` URL (current src/background/index.ts:709-710) +to an offscreen-minted `blob:` URL per D-P2-01. The SW cannot call `URL.createObjectURL` (DEC-006); +the offscreen document can. Wire a new port-bridge that ships the archive Blob SW→offscreen +(reusing the D-12 base64 wire-format from src/shared/binary.ts), mints the URL in offscreen, +returns the URL string to SW, and the SW invokes chrome.downloads.download with that URL. +Wire chrome.downloads.onChanged to dispatch URL.revokeObjectURL after download completion. + +Purpose: closes the audit P0-6 + lifts the ~2 MB data-URL cap that currently breaks the canonical +5-10 MB operator bug-report archive (CONTEXT.md `` "Real-archive-size assumption"). + +Output: +- 3 source-code modifications in src/offscreen/recorder.ts, src/shared/types.ts, src/background/index.ts. +- All 3 tests in tests/background/blob-url-download.test.ts flip RED→GREEN. +- 153 + N (from Plan 02-01) → 153 + N + 0 vitest count (no new tests in this plan; flips the existing + 3 RED to GREEN; existing tests preserved unchanged). +- UAT harness 24/24 GREEN preserved. + + + +@$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/02-stabilize-export-pipeline/02-CONTEXT.md +@.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md + +# Source code under modification +@src/background/index.ts +@src/offscreen/recorder.ts +@src/shared/types.ts +@src/shared/binary.ts + +# Precedent for port-bridge wire format +@.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md + +# Tests that flip GREEN under this plan +@tests/background/blob-url-download.test.ts + + + + +Extended PortMessageType union (src/shared/types.ts): +```typescript +export type PortMessageType = + | 'PING' + | 'PONG' + | 'REQUEST_BUFFER' + | 'BUFFER' + | 'CREATE_DOWNLOAD_URL' // NEW (SW → offscreen): "here is a Blob; mint a URL for it" + | 'DOWNLOAD_URL' // NEW (offscreen → SW): "here is the minted URL" + | 'REVOKE_DOWNLOAD_URL'; // NEW (SW → offscreen): "you can revoke this URL now" + +export interface PortMessage { + type: PortMessageType; + requestId?: string; + segments?: TransferredVideoSegment[]; // BUFFER only + // NEW for CREATE_DOWNLOAD_URL: archive bytes as base64 + type for Blob reconstruction + dataBase64?: string; + mimeType?: string; + // NEW for DOWNLOAD_URL: the minted blob:URL string + url?: string; +} +``` + +Existing keepalivePort lifecycle (do NOT touch): +- src/offscreen/recorder.ts:790-840 — connectPort() + onPortMessage + reconnectPort +- src/background/index.ts:340-510 (approx, around getVideoBufferFromOffscreen) — SW-side onConnect handler + perRequestListeners map + +Existing chrome.downloads.download call site (src/background/index.ts:712-716): +```typescript +await chrome.downloads.download({ + url: url, // ← this string flips from data: to blob: + filename: filename, + saveAs: false +}); +``` + +Existing blobToBase64 / base64ToBlob (src/shared/binary.ts:42-85) — REUSE AS-IS. + + + + + + + Task 1: Extend PortMessageType + PortMessage in src/shared/types.ts (D-P2-01 wire contract) + src/shared/types.ts + + - PortMessageType union grows from 4 to 7 entries: PING, PONG, REQUEST_BUFFER, BUFFER, CREATE_DOWNLOAD_URL, DOWNLOAD_URL, REVOKE_DOWNLOAD_URL. + - PortMessage interface adds optional `dataBase64?: string`, `mimeType?: string`, `url?: string` — same optional-tagged-union pattern as the existing `segments?:`. + - Type-level test (Task 2 from Plan 02-01 if it covers types): `meta.json` urls field NOT touched here (that is Plan 02-03 territory). This task is wire-format only. + + + Edit `src/shared/types.ts` to extend PortMessageType + PortMessage per the interfaces block above. + + Add a docstring block above the union explaining: "CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL + are the D-P2-01 Blob URL migration triplet (P0-6 fix). SW posts CREATE_DOWNLOAD_URL with the + archive bytes as base64; offscreen mints URL.createObjectURL and responds with DOWNLOAD_URL; + SW calls chrome.downloads.download(url); when chrome.downloads.onChanged reports 'complete' or + 'interrupted', SW posts REVOKE_DOWNLOAD_URL so offscreen can free the URL. The base64 wire-format + reuses the D-12 precedent from src/shared/binary.ts — chrome.runtime.Port JSON-serializes payloads, + so Blob → empty object; base64 round-trips cleanly. See .planning/phases/02-stabilize-export-pipeline/ + 02-CONTEXT.md D-P2-01 for the full architectural rationale." + + No other type changes in this task — SessionMetadata.urls is Plan 02-03. + + + npx tsc --noEmit 2>&1 | head -20 + + + tsc --noEmit clean. PortMessageType union has 7 entries. PortMessage interface has the 3 new + optional fields with the docstring rationale. Atomic commit: + `feat(02-02): wire-format — extend PortMessage with CREATE_DOWNLOAD_URL/DOWNLOAD_URL/REVOKE_DOWNLOAD_URL (D-P2-01)`. + + + + + Task 2: Offscreen handler — mint + revoke Blob URLs on port bridge + src/offscreen/recorder.ts + + - `onPortMessage` (src/offscreen/recorder.ts:614) grows two new branches: + (a) `type === 'CREATE_DOWNLOAD_URL'`: extract requestId, dataBase64, mimeType; + call `base64ToBlob(dataBase64, mimeType)` (reuse src/shared/binary.ts); + call `URL.createObjectURL(blob)` to get the blob:URL string; + post back `{ type: 'DOWNLOAD_URL', requestId, url }` on keepalivePort. + (b) `type === 'REVOKE_DOWNLOAD_URL'`: extract url string; call `URL.revokeObjectURL(url)`; + no response (fire-and-forget). + - Defense-in-depth: if dataBase64 is empty / malformed, respond with `{ type: 'DOWNLOAD_URL', requestId, url: '' }` and log a warn — let SW timeout fire (mirrors the encodeAndSendBuffer pattern at recorder.ts:670). + - Maintain a module-scoped Set of minted URLs (for hygiene — emit a warn if REVOKE arrives for a URL not in the set, but always call revokeObjectURL anyway; URL.revokeObjectURL on an unknown URL is a no-op per WHATWG spec). + - In-flight guard: a second CREATE_DOWNLOAD_URL while one is in flight is allowed (each gets its own requestId) — unlike encodeAndSendBuffer which gates concurrent calls because they share segment state. URL minting is stateless per-Blob. + - No changes to recording lifecycle / segment-rotation / D-13 / D-17 keepalive / __MOKOSH_UAT__ test hooks. + + + Edit `src/offscreen/recorder.ts` to add two new cases inside the existing `onPortMessage` function + (after the REQUEST_BUFFER branch at line ~626): + + ```typescript + if (type === 'CREATE_DOWNLOAD_URL') { + const requestId = (message as { requestId?: unknown }).requestId; + const dataBase64 = (message as { dataBase64?: unknown }).dataBase64; + const mimeType = (message as { mimeType?: unknown }).mimeType ?? 'application/zip'; + if (typeof requestId !== 'string' || requestId.length === 0) { + logger.warn('CREATE_DOWNLOAD_URL without requestId — dropping'); + return; + } + if (typeof dataBase64 !== 'string') { + logger.warn('CREATE_DOWNLOAD_URL with non-string dataBase64 — dropping'); + return; + } + void handleCreateDownloadUrl(requestId, dataBase64, typeof mimeType === 'string' ? mimeType : 'application/zip'); + return; + } + if (type === 'REVOKE_DOWNLOAD_URL') { + const url = (message as { url?: unknown }).url; + if (typeof url !== 'string' || url.length === 0) { + logger.warn('REVOKE_DOWNLOAD_URL without url — dropping'); + return; + } + try { + URL.revokeObjectURL(url); + mintedDownloadUrls.delete(url); + } catch (err) { + logger.warn('URL.revokeObjectURL threw:', err); + } + return; + } + ``` + + Add the helper + state right before onPortMessage: + ```typescript + // D-P2-01 Blob URL hygiene set: track minted URLs so we can warn (not error) + // on unexpected revoke ops. URL.revokeObjectURL on an unknown URL is a no-op + // per the WHATWG spec, so this is purely diagnostic. + const mintedDownloadUrls = new Set(); + + async function handleCreateDownloadUrl(requestId: string, dataBase64: string, mimeType: string): Promise { + if (keepalivePort === null) { + logger.warn('CREATE_DOWNLOAD_URL: port unavailable at handler entry — dropping'); + return; + } + let url = ''; + try { + const blob = base64ToBlob(dataBase64, mimeType); + if (blob.size === 0) { + logger.warn('CREATE_DOWNLOAD_URL: empty blob from base64 decode — responding with empty url'); + } else { + url = URL.createObjectURL(blob); + mintedDownloadUrls.add(url); + logger.log(`Minted Blob URL: ${url.substring(0, 30)}... (size: ${blob.size} bytes)`); + } + } catch (err) { + logger.error('CREATE_DOWNLOAD_URL: base64ToBlob or createObjectURL threw:', err); + } + try { + keepalivePort.postMessage({ type: 'DOWNLOAD_URL', requestId, url }); + } catch (err) { + logger.warn('DOWNLOAD_URL post failed (port may have disconnected):', err); + } + } + ``` + + Verify the import `import { blobToBase64, base64ToBlob } from '../shared/binary';` at the top + already imports base64ToBlob (recorder.ts line 18 imports blobToBase64 only — extend to include base64ToBlob). + + No changes to bootstrap, connectPort, ping loop, or segment-rotation. + + Per D-P2-01 architectural rationale: this lives in the offscreen because SW lacks + URL.createObjectURL (DEC-006). The offscreen does, and we already have the keepalivePort + open (D-17). Reusing the port (not opening a new one) avoids two connect-overhead penalties + per save flow. + + + npx tsc --noEmit 2>&1 | head -10 ; npm run build 2>&1 | tail -10 + + + tsc clean. npm run build clean. Two new cases in onPortMessage; one new helper handleCreateDownloadUrl; + one new module-scoped Set mintedDownloadUrls; import line widened to include base64ToBlob. + Atomic commit: + `feat(02-02): offscreen — CREATE/REVOKE Blob URL handlers on keepalivePort (D-P2-01)`. + + + + + Task 3: SW downloadArchive rewrite + chrome.downloads.onChanged revoke wiring + src/background/index.ts + + - `downloadArchive(archiveBlob)` (line 695) rewritten: + (1) Encode archiveBlob to base64 via blobToBase64 (already imported at line 2). + (2) Generate a requestId (use crypto.randomUUID() — SW supports it; see getVideoBufferFromOffscreen pattern). + (3) Post `{ type: 'CREATE_DOWNLOAD_URL', requestId, dataBase64, mimeType: 'application/zip' }` on the existing port (offscreenPort variable — see Plan 01-04 wiring at src/background/index.ts:onConnect host). + (4) Await the matching `DOWNLOAD_URL` response (per-request listener map pattern, mirroring REQUEST_BUFFER → BUFFER from getVideoBufferFromOffscreen). + (5) If response.url === '' or no response within timeout (5000ms), throw an EmptyVideoBufferError-equivalent "blob-url-mint-failed" or log + return (planner-decision: log warn, attempt fallback via legacy data: URL ONLY for archives <1 MB — otherwise throw and surface to operator). Resolved inline: NO FALLBACK. The blob: URL is the new contract; failure must surface via a typed error so the operator gets a clear failure mode, not a silent corrupt archive. Throw `new Error('blob-url-mint-failed: offscreen unresponsive or empty url')`. + (6) Call chrome.downloads.download({ url, filename, saveAs: false }) capturing the returned downloadId. + (7) Store downloadId → url in a module-scoped Map (`pendingRevokes`) so chrome.downloads.onChanged can dispatch the revoke. + - chrome.downloads.onChanged listener registered at module init (after the existing chrome.runtime.onMessage listener registration around line 843): + ```typescript + chrome.downloads.onChanged.addListener((delta) => { + if (!delta.state) return; + if (delta.state.current === 'complete' || delta.state.current === 'interrupted') { + const url = pendingRevokes.get(delta.id); + if (url !== undefined) { + pendingRevokes.delete(delta.id); + if (offscreenPort !== null) { + try { + offscreenPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url }); + } catch (err) { + logger.warn('REVOKE_DOWNLOAD_URL post failed:', err); + } + } else { + // Offscreen port unavailable — the URL leaks in offscreen until the + // document is torn down (which happens on browser close / extension + // reload). Not a security issue (URLs are extension-origin scoped), + // just a per-session memory leak diagnostically noted. + logger.warn(`offscreenPort null at revoke time; url ${url.substring(0,30)}... leaks until offscreen teardown`); + } + } + } + }); + ``` + - Defense-in-depth: NEVER `await import(...)` from src/background/index.ts (MV3 SW dynamic-import blocker — Plan 01-11 SUMMARY). All new logic is eager top-of-module. + - Always-on charter preserved: no changes to saveArchive (line 741); downloadArchive's caller signature unchanged; saveArchive's no-finally invariant from Plan 01-09 Amendment 3 untouched. + + + Edit `src/background/index.ts`: + + 1. Add module-scoped `pendingRevokes: Map` near the existing state declarations + (line 68 region) with docstring citing D-P2-01. + + 2. Add module-scoped `pendingDownloadUrlResolvers: Map void>` for the + requestId → resolver pattern (mirroring `pendingBufferResolvers` if it exists, or the + per-request listener pattern in getVideoBufferFromOffscreen — read that function to align + the implementation). + + 3. Extend the existing SW-side port `onMessage` handler (registered in onConnect — read + src/background/index.ts around the keepalive port plumbing to find the exact location) + to handle the new DOWNLOAD_URL message: + ```typescript + if (msg.type === 'DOWNLOAD_URL' && typeof msg.requestId === 'string') { + const resolver = pendingDownloadUrlResolvers.get(msg.requestId); + if (resolver !== undefined) { + pendingDownloadUrlResolvers.delete(msg.requestId); + resolver(typeof msg.url === 'string' ? msg.url : ''); + } + return; + } + ``` + + 4. Rewrite `downloadArchive(archiveBlob)` (line 695-718): + ```typescript + async function downloadArchive(archiveBlob: Blob) { + const now = new Date(); + const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0]; + const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-'); + const filename = `session_report_${dateStr}_${timeStr}.zip`; + + logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`); + + // D-P2-01: mint blob:URL via offscreen (SW lacks URL.createObjectURL per DEC-006). + // Bridge: encode → port post CREATE_DOWNLOAD_URL → await DOWNLOAD_URL → call chrome.downloads. + const dataBase64 = await blobToBase64(archiveBlob); + const requestId = crypto.randomUUID(); + const urlPromise = new Promise((resolve) => { + pendingDownloadUrlResolvers.set(requestId, resolve); + }); + if (offscreenPort === null) { + throw new Error('blob-url-mint-failed: offscreen port unavailable'); + } + offscreenPort.postMessage({ + type: 'CREATE_DOWNLOAD_URL', + requestId, + dataBase64, + mimeType: 'application/zip', + }); + const timeoutMs = 5000; + const url = await Promise.race([ + urlPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('blob-url-mint-timeout')), timeoutMs) + ), + ]); + if (url === '') { + throw new Error('blob-url-mint-failed: offscreen returned empty url'); + } + const downloadId = await chrome.downloads.download({ + url, + filename, + saveAs: false, + }); + if (typeof downloadId === 'number') { + pendingRevokes.set(downloadId, url); + } + logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`); + } + ``` + + 5. Register chrome.downloads.onChanged listener at module init (defensive try/catch like the + other listener registrations at line 843 region): + ```typescript + try { + if (chrome.downloads?.onChanged?.addListener) { + chrome.downloads.onChanged.addListener((delta) => { + if (!delta.state) return; + const newState = delta.state.current; + if (newState !== 'complete' && newState !== 'interrupted') return; + const url = pendingRevokes.get(delta.id); + if (url === undefined) return; + pendingRevokes.delete(delta.id); + if (offscreenPort !== null) { + try { + offscreenPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url }); + logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`); + } catch (err) { + logger.warn('REVOKE_DOWNLOAD_URL post failed:', err); + } + } else { + logger.warn(`offscreenPort null at revoke time; url ${url.substring(0,30)}... leaks until offscreen teardown`); + } + }); + } + } catch (err) { + logger.warn('chrome.downloads.onChanged.addListener failed:', err); + } + ``` + + NOTE on `offscreenPort` variable: the existing SW-side onConnect handler stores the port reference + somewhere (read `src/background/index.ts` around line 350-510 OR grep for `chrome.runtime.onConnect` + to find the exact variable). Reuse that — do NOT introduce a parallel port reference. + + Per D-P2-01 — this completes the architectural triangle: SW packages zip → SW asks offscreen + to mint URL → offscreen mints → SW downloads → onChanged fires → SW asks offscreen to revoke. + Matches CONTEXT.md `` D-P2-01 verbatim, including the chrome.downloads.onChanged + listener for URL.revokeObjectURL lifecycle. + + + npx tsc --noEmit 2>&1 | head -20 ; npm run build 2>&1 | tail -10 ; npx vitest run tests/background/blob-url-download.test.ts 2>&1 | tail -20 + + + tsc clean. npm run build clean. tests/background/blob-url-download.test.ts → 3/3 GREEN. + All other vitest tests (153 baseline + Plan 02-01 RED tests that are not yet GREEN — only the + blob-url-download.test.ts ones flip here) preserved. + Pre-commit Tier-1 grep gate: GREEN (12 FORBIDDEN_HOOK_STRINGS unchanged — no new test-hook symbols). + Atomic commit: + `feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)`. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| SW → offscreen via port | Existing T-1-04 mitigation (sender-id check) preserved — no new boundary | +| chrome.downloads.onChanged event source | Chrome browser → SW; trustworthy per MV3 platform contract | +| Blob URL in offscreen origin | URL minted in offscreen document origin (chrome-extension://); accessible only to extension contexts and the chrome.downloads internals; NOT exposed to web pages | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-02-01 | Information Disclosure | Blob URL leaks to web pages via shared-context bug | accept | URLs are extension-origin scoped per WHATWG URL spec; web pages cannot navigate to chrome-extension:// blob URLs without manifest web_accessible_resources entries (none added). Verified via grep: zero new web_accessible_resources in this plan. | +| T-02-02-02 | Denial of Service | Unbounded pendingRevokes Map growth if onChanged never fires | mitigate | pendingRevokes entries are deleted on every onChanged 'complete'/'interrupted' delta; SW idle teardown (~30s) drops the entire Map; per-session leak is bounded by O(saves-per-session) which is operationally <100. Diagnostic warn logged if offscreenPort is null at revoke time (URL leaks until offscreen teardown — acceptable). | +| T-02-02-03 | Tampering | Malicious offscreen returns a non-blob: URL string in DOWNLOAD_URL response | mitigate | offscreen is same-extension origin (sender-id check); chrome.downloads.download validates the URL scheme and Chrome will reject non-blob:/non-data:/non-http(s): URLs at the platform layer. Defense-in-depth: SW could add `if (!url.startsWith('blob:')) throw` — add this guard as part of the validation chain (downloadArchive's url=='' check is extended to also reject non-blob: prefixes). | +| T-02-02-04 | Elevation of Privilege | base64ToBlob in offscreen accepts arbitrary mimeType from SW port message | accept | mimeType is consumer-level metadata only; URL.createObjectURL doesn't grant any new privileges based on Blob.type. The risk is purely cosmetic (wrong file extension on download), not an EoP. | +| T-02-02-05 | Repudiation | downloadArchive failure surfaces only as a logger.warn — operator sees a missing zip with no diagnostic | mitigate | Throw a typed Error ('blob-url-mint-failed' / 'blob-url-mint-timeout' / 'offscreen port unavailable') → saveArchive catch block routes through the existing RECORDING_ERROR channel (see src/background/index.ts:809-829 EmptyVideoBufferError path) — operator sees a recovery notification. Plan-checker verifies the catch path is wired. | + + + +- `npx tsc --noEmit` → clean. +- `npm run build` → clean. +- `npm run build:test` → clean (test bundle still builds; __MOKOSH_UAT__ token does not interact with new port messages — they are production surfaces). +- `npx vitest run tests/background/blob-url-download.test.ts --reporter=verbose` → 3/3 GREEN. +- `npx vitest run` (full suite) → previously 153/153 GREEN preserved, PLUS blob-url-download.test.ts +3 GREEN. Plan 02-01's other RED tests (meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts) REMAIN RED — flipped GREEN in Plan 02-03. +- `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 FORBIDDEN_HOOK_STRINGS unchanged → GREEN. +- `npm run test:uat` → 24/24 GREEN (UAT preserved; harness inspects the existing zip-on-disk shape via A5/A12/A13, which the Blob URL path produces identically). +- Grep gate: `grep -c "data:application/zip;base64," src/background/index.ts` → 0 (the legacy path is gone). `grep -c "blob:" src/background/index.ts` ≥ 1. +- Manual operator empirical (deferred to Plan 02-04 operator checkpoint): save with the current build; verify the produced zip lands in Downloads + opens cleanly; verify Chrome DevTools Network panel shows blob: URL (not data:) as the download source. (Plan 02-04 wires this into the harness if possible.) + + + +1. tests/background/blob-url-download.test.ts 3/3 GREEN. +2. UAT harness 24/24 GREEN preserved. +3. Production bundle ships chrome.downloads.onChanged listener (1 grep match in dist/) + zero `data:application/zip;base64,` (0 grep matches in dist/). +4. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged). +5. No `await import(...)` in src/background/index.ts (Plan 01-11 SUMMARY invariant). +6. Always-on charter preserved: saveArchive in src/background/index.ts:741 still has NO `finally` block resetting recorder state. + + + +After completion, create `.planning/phases/02-stabilize-export-pipeline/02-02-SUMMARY.md` +documenting: +- 3 source files modified; line-count deltas. +- Wire-format extension: 3 new PortMessageType variants; in-flight resolver Map; revoke lifecycle Map. +- Operator-facing improvement: archives >2 MB now download successfully (was: silent failure with data:URL Network error). +- Forward link to Plan 02-04 harness extension for empirical A24+ verification. + diff --git a/.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md new file mode 100644 index 0000000..0eb695f --- /dev/null +++ b/.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md @@ -0,0 +1,460 @@ +--- +phase: 02-stabilize-export-pipeline +plan: 03 +type: auto +wave: 2 +depends_on: [01] +files_modified: + - src/background/tab-url-tracker.ts + - src/shared/types.ts + - src/background/index.ts + - .planning/REQUIREMENTS.md +autonomous: true +requirements: + - REQ-meta-json-schema +tags: + - meta-json-urls-array + - tab-url-tracking + - schema-amendment + - p1-10-fix + - d-p2-02 + - d-p2-03 +must_haves: + truths: + - "src/background/tab-url-tracker.ts exists exporting getTabUrlsSeen(): string[] returning a deduplicated, first-seen-ordered, filtered (no chrome://, no about:; include chrome-extension://) list of tab URLs observed during the rolling 30s window." + - "SessionMetadata.url: string is replaced by SessionMetadata.urls: string[] in src/shared/types.ts." + - "createArchive in src/background/index.ts assembles meta.json with urls (not url), plus a new schemaVersion: '2' field, totalling exactly 8 fields per D-P2-03." + - "Tests tests/background/meta-json-urls-schema.test.ts (4 tests) and tests/build/strict-meta-json-validation.test.ts (8 tests) flip RED→GREEN." + - ".planning/REQUIREMENTS.md REQ-meta-json-schema text amended to reflect the new 8-field shape, with the breaking-change cutover documented inline." + - "chrome.tabs.onActivated and chrome.tabs.onUpdated listeners registered in src/background/index.ts maintain the tracker's internal Set; pruning happens alongside the video segment ring buffer lifecycle (no separate timer)." + - "Always-on charter preserved (Plan 01-09 Amendment 3): SAVE creates a zip with the current tracker state; tracker continues accumulating after SAVE." + - "manifest.json permissions: the existing `tabs` permission gap (Plan 01-13 SUMMARY Known Limitations item 3) is NOT closed by this plan — the tracker uses chrome.tabs.onActivated + chrome.tabs.onUpdated which work on `activeTab` permission alone for the active-tab URL events. Cross-window/inactive-tab URL events require `tabs` permission and are deferred to Phase 4 hardening (planner-decision documented inline)." + artifacts: + - path: "src/background/tab-url-tracker.ts" + provides: "getTabUrlsSeen(): string[] + initTabUrlTracker(): void + clearTabUrlsSeen(): void" + min_lines: 80 + contains: "getTabUrlsSeen|chrome\\.tabs\\.onActivated|chrome\\.tabs\\.onUpdated" + - path: "src/shared/types.ts" + provides: "SessionMetadata.urls: string[] (replaces url: string); new schemaVersion: string field" + contains: "urls.*string\\[\\]|schemaVersion" + - path: "src/background/index.ts" + provides: "createArchive consumes getTabUrlsSeen for meta.urls + initTabUrlTracker at SW init" + contains: "getTabUrlsSeen|schemaVersion" + - path: ".planning/REQUIREMENTS.md" + provides: "REQ-meta-json-schema amended for 8-field shape + breaking-change cutover note" + contains: "schemaVersion|urls.*string\\[\\]" + key_links: + - from: "src/background/index.ts:initialize" + to: "src/background/tab-url-tracker.ts:initTabUrlTracker" + via: "initTabUrlTracker() called at SW init alongside chrome.runtime.onMessage listener" + pattern: "initTabUrlTracker\\(\\)" + - from: "src/background/index.ts:createArchive" + to: "src/background/tab-url-tracker.ts:getTabUrlsSeen" + via: "metadata.urls = getTabUrlsSeen()" + pattern: "metadata\\.urls.*=.*getTabUrlsSeen" + - from: "src/background/tab-url-tracker.ts" + to: "chrome.tabs.onActivated + chrome.tabs.onUpdated" + via: "addListener at module init; each event derives the URL via tab.url and inserts into the Set if it passes the filter" + pattern: "chrome\\.tabs\\.on(Activated|Updated)\\.addListener" +--- + + +Replace SessionMetadata.url: string with SessionMetadata.urls: string[] per D-P2-02. The urls array +captures all tab URLs observed during the rolling recording window (operator's full multi-tab +bug-reproduction context, not just the active-at-save tab). Add a new schemaVersion field per +the planner-resolved 8th-field decision from Plan 02-01. Amend REQUIREMENTS.md REQ-meta-json-schema +text to reflect the breaking-change cutover. + +Purpose: closes audit P1 #10 + makes the meta.json multi-tab-aware for the operator's typical workflow +(switching tabs across the 30s window when reproducing a bug). Maintains the always-on charter +(tracker keeps running after SAVE; no reset). + +Output: +- New module src/background/tab-url-tracker.ts (~80-120 LOC). +- src/shared/types.ts SessionMetadata interface updated. +- src/background/index.ts createArchive + initialize updated. +- .planning/REQUIREMENTS.md REQ-meta-json-schema text amended. +- tests/background/meta-json-urls-schema.test.ts (4) + tests/build/strict-meta-json-validation.test.ts (8) flip RED→GREEN. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/STATE.md +@.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md +@.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md + +# Source code under modification +@src/background/index.ts +@src/shared/types.ts + +# Tests that flip GREEN under this plan +@tests/background/meta-json-urls-schema.test.ts +@tests/build/strict-meta-json-validation.test.ts + + + + +src/background/tab-url-tracker.ts public API: +```typescript +/** + * Initialize the tab-URL tracker. Registers chrome.tabs.onActivated + + * chrome.tabs.onUpdated listeners that maintain an internal Set of URLs + * observed during the SW's lifetime. Must be called once at SW init. + * + * Idempotent — calling twice is safe (defensive try/catch around listener + * registration matches the pattern in src/background/index.ts:bootstrap). + * + * D-P2-02 binding: captures the operator's multi-tab context, not just + * the active-at-save tab. + */ +export function initTabUrlTracker(): void; + +/** + * Return the deduplicated, first-seen-ordered, filtered list of tab URLs + * observed since init OR since the last clearTabUrlsSeen() call. + * + * Filter (per CONTEXT.md `` block): + * - INCLUDE: https://, http://, chrome-extension:// + * - EXCLUDE: chrome://, about:, devtools://, file:// (low diagnostic value + * OR privacy-concerning local-fs paths) + * + * Dedup: each URL appears exactly once. + * Order: first-seen-first. + * + * Always returns a NEW array (copy) — caller cannot mutate the internal Set. + */ +export function getTabUrlsSeen(): string[]; + +/** + * Clear the internal Set. NOT called by saveArchive (always-on charter + * preserved — SAVE creates a zip with the current state; tracker keeps + * accumulating). Reserved for future use (e.g., manual session-reset). + */ +export function clearTabUrlsSeen(): void; +``` + +Updated SessionMetadata (src/shared/types.ts): +```typescript +export interface SessionMetadata { + schemaVersion: string; // NEW — '2' marks the D-P2-02 url→urls cutover + timestamp: string; // ISO-8601 with Z + urls: string[]; // NEW — replaces `url: string`; non-empty per D-P2-03 + userAgent: string; + extensionVersion: string; + videoBufferSeconds: number; + logDurationMinutes: number; + totalEvents: number; +} +``` + +Existing createArchive call site (src/background/index.ts:673-684): +```typescript +const metadata: SessionMetadata = { + timestamp: new Date().toISOString(), + url: new URL(chrome.runtime.getURL('')).origin, // ← REPLACE + userAgent: navigator.userAgent, + extensionVersion: manifest.version, + videoBufferSeconds: 30, + logDurationMinutes: 10, + totalEvents: rrwebEvents.length + userEvents.length +}; +``` + +REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend: +``` +- [ ] **REQ-meta-json-schema**: `meta.json` inside the archive conforms to the + verbatim schema: + ```json + { + "timestamp": "2025-05-15T14:32:10Z", + "url": "https://...", ← REPLACE WITH urls: [] + "userAgent": "Chrome/...", + "extensionVersion": "1.0.0", + "videoBufferSeconds": 30, + "logDurationMinutes": 10, + "totalEvents": 143 + } + ``` + All fields required. Binding: CON-meta-json-schema. +``` + + + + + + + Task 1: Create src/background/tab-url-tracker.ts module + src/background/tab-url-tracker.ts + + - Module exports initTabUrlTracker, getTabUrlsSeen, clearTabUrlsSeen per the interface block. + - Internal state: `let tabUrlsSeen: Set = new Set()` AND `let firstSeenOrder: string[] = []` (Set for O(1) dedup; array for ordering). + - On chrome.tabs.onActivated: query the activated tab via chrome.tabs.get(activeInfo.tabId); if tab.url passes filter, addUrl(tab.url). + - On chrome.tabs.onUpdated: when changeInfo.url is present AND passes filter, addUrl(changeInfo.url). (Filters out the cascade of intermediate events that fire during page load — `changeInfo.status === 'complete'` is NOT required because we want to capture URL transitions even for SPA-style routing). + - addUrl helper: if URL not in Set, push to firstSeenOrder array AND add to Set; idempotent. + - Filter helper: accept https://, http://, chrome-extension://; reject chrome://, about:, devtools://, file://, blob:, data:. + - Defense-in-depth: all chrome.* listener registrations wrapped in try/catch (matches src/background/index.ts:bootstrap pattern for chrome.notifications + chrome.action listeners). + - getTabUrlsSeen returns a slice (copy) of firstSeenOrder — caller cannot mutate internal state. + - clearTabUrlsSeen empties both the Set and the array. + - module-init guard: `let initialized = false`; initTabUrlTracker sets to true; subsequent calls return early with a logger.warn for idempotency. + + + Create `src/background/tab-url-tracker.ts` with the exact public API from the interfaces block. + + Use the Logger pattern from src/shared/logger.ts (existing pattern at recorder.ts:78 / index.ts:49): + ```typescript + import { Logger } from '../shared/logger'; + const logger = new Logger('TabUrlTracker'); + ``` + + The filter implementation: + ```typescript + function passesFilter(url: string): boolean { + // Per .planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md : + // INCLUDE: https://, http://, chrome-extension:// + // EXCLUDE: chrome://, about:, devtools://, file://, blob:, data: + return /^(https?|chrome-extension):\/\//.test(url); + } + ``` + + Module structure (top → bottom): + 1. Logger import + instantiation. + 2. State: `let tabUrlsSeen: Set`, `let firstSeenOrder: string[]`, `let initialized: boolean`. + 3. passesFilter helper. + 4. addUrl helper (internal). + 5. initTabUrlTracker (public) — registers listeners with defensive try/catch. + 6. getTabUrlsSeen (public). + 7. clearTabUrlsSeen (public). + + Per D-P2-02 + CONTEXT.md ``: the operator's primary tab at SAVE time should always + be in the array if it has a valid URL. The chrome.tabs.onActivated path covers this — the active + tab fires onActivated whenever the operator switches to it (including the first activation when + the SW starts up). Combined with chrome.tabs.onUpdated, this captures both tab-switch events + and in-tab navigation events. No explicit "snapshot active tab at SAVE time" is needed (the + tracker has already captured it via onActivated). + + NOTE on `tabs` permission gap: chrome.tabs.onActivated provides tabId in the event payload; + chrome.tabs.get(tabId) returns the tab WITHOUT the `.url` field unless `tabs` permission is + granted (Plan 01-13 SUMMARY Known Limitations item 3). For the active tab specifically, + `activeTab` permission grants temporary URL access. The tracker accepts the URL when present + and skips silently when absent (logged at warn level for diagnostics). This means inactive-tab + URL changes are NOT captured — acceptable per planner-decision deferred to Phase 4 hardening + (CONTEXT.md `` "tabs permission gap"). Document this limitation inline. + + Per D-P2-03's "urls array non-empty" rule from Plan 02-01 Task 3: if getTabUrlsSeen returns + an empty array at SAVE time, createArchive (Task 3 below) must fall back to a sentinel value + (e.g., the extension-origin URL from `new URL(chrome.runtime.getURL('')).origin` — same as the + current single-url path). This guarantees the strict 8-field schema validation never fails on + empty urls[]. + + + npx tsc --noEmit 2>&1 | head -20 + + + File exists. tsc clean. Three exports per public API. Defensive try/catch wraps chrome.tabs.* + listener registrations. Atomic commit: + `feat(02-03): tab-url-tracker — chrome.tabs.onActivated + onUpdated → urls[] with dedup + filter (D-P2-02)`. + + + + + Task 2: Update SessionMetadata type + meta.json assembly + tracker wiring + src/shared/types.ts, src/background/index.ts + + - src/shared/types.ts SessionMetadata: REPLACE `url: string` with `urls: string[]`; ADD `schemaVersion: string` as the first field. Total 8 fields. + - src/background/index.ts: + (a) import { initTabUrlTracker, getTabUrlsSeen } from './tab-url-tracker'; + (b) call initTabUrlTracker() inside the existing `initialize()` function (line 957) under defensive try/catch matching the chrome.notifications.onClicked pattern. + (c) createArchive (line 673-684) updated: + ```typescript + const manifest = chrome.runtime.getManifest(); + const tabUrls = getTabUrlsSeen(); + // D-P2-03 non-empty guarantee: if the tracker has no URLs (e.g., SW just spawned, + // operator hasn't activated a real tab yet), fall back to the extension-origin URL + // so the strict-validation schema (REQ-meta-json-schema) never fails on empty array. + const urls = tabUrls.length > 0 ? tabUrls : [new URL(chrome.runtime.getURL('')).origin]; + const metadata: SessionMetadata = { + schemaVersion: '2', + timestamp: new Date().toISOString(), + urls, + userAgent: navigator.userAgent, + extensionVersion: manifest.version, + videoBufferSeconds: 30, + logDurationMinutes: 10, + totalEvents: rrwebEvents.length + userEvents.length + }; + ``` + - Object key ordering matches the SessionMetadata interface declaration order (TypeScript object + literal preserves source order; meta.json output is JSON.stringify(metadata, null, 2) → keys + in insertion order per ECMA-262). + - Always-on charter preserved: createArchive does NOT call clearTabUrlsSeen() — the tracker + continues accumulating across saves (next save captures any tabs activated after this one). + + + 1. Edit `src/shared/types.ts` SessionMetadata interface (lines 102-111): + - DELETE `url: string;`. + - INSERT `schemaVersion: string;` as the first field. + - INSERT `urls: string[];` in the position previously occupied by `url`. + - Final ordering: schemaVersion, timestamp, urls, userAgent, extensionVersion, + videoBufferSeconds, logDurationMinutes, totalEvents (8 fields). + - Add a docstring block above the interface citing D-P2-02 (the url→urls cutover) + + D-P2-03 (schemaVersion + 8-field exact rule) + the planner-resolved Plan 02-01 Task 3 + decision to make schemaVersion the 8th field. + + 2. Edit `src/background/index.ts`: + - Add import: `import { initTabUrlTracker, getTabUrlsSeen } from './tab-url-tracker';` near + the existing import block (line 1-11). + - In `initialize()` (line 957), add a defensive try/catch block calling initTabUrlTracker() + right after the existing chrome.notifications.onClicked listener registration (search for + that pattern; mirror the surrounding try/catch shape). + - Replace the metadata-construction block in createArchive (line 673-684) with the new + 8-field shape per the behavior block above. + + 3. Per D-P2-02 + D-P2-03: + - schemaVersion: '2' marks the breaking-change cutover. Future schema bumps increment. + - urls is the operator's multi-tab context. + - The non-empty fallback (extension-origin URL) guarantees REQ-meta-json-schema strict- + validation passes even on a freshly-spawned SW with no tab activity. + + Per the planner-resolved decision in Plan 02-01 Task 3: the 8th field name `schemaVersion` + was a tentative planner pick. If plan-checker / implementer surfaces a stronger candidate + name, BOTH this plan AND tests/build/strict-meta-json-validation.test.ts EXPECTED_KEYS must + change in lockstep (matching the Plan 01-14 lockstep-change pattern for FORBIDDEN_HOOK_STRINGS). + + + npx tsc --noEmit 2>&1 | head -20 ; npm run build 2>&1 | tail -10 ; npx vitest run tests/background/meta-json-urls-schema.test.ts tests/build/strict-meta-json-validation.test.ts 2>&1 | tail -30 + + + tsc clean. npm run build clean. SessionMetadata has exactly 8 fields. createArchive emits + 8-field meta.json with schemaVersion='2' + urls (non-empty via fallback). All 4 tests in + meta-json-urls-schema.test.ts GREEN. All 8 tests in strict-meta-json-validation.test.ts GREEN. + Atomic commit: + `feat(02-03): meta.json — urls[] + schemaVersion (D-P2-02 + D-P2-03; replaces url:string)`. + + + + + Task 3: Amend REQUIREMENTS.md REQ-meta-json-schema for the new 8-field shape + .planning/REQUIREMENTS.md + + - REQ-meta-json-schema entry (lines 106-119) updated to reflect the new 8-field schema verbatim. + - Inline comment block citing D-P2-02 + D-P2-03 + Plan 02-03 as the cutover provenance. + - Acceptance criteria preserved: all fields required; types correct; timestamp ISO-8601 with Z. + - Add new acceptance criteria: urls is non-empty string[]; schemaVersion === '2'. + - Traceability table entry for REQ-meta-json-schema status update: "Pending → Complete pending Plan 02-04 harness validation" (Plan 02-04 may flip this to Complete; this plan only ships the implementation + amended text). + + + Edit `.planning/REQUIREMENTS.md`: + + 1. Replace lines 106-119 (REQ-meta-json-schema block) with the new 8-field schema: + + ```markdown + - [ ] **REQ-meta-json-schema**: `meta.json` inside the archive conforms to the + verbatim schema (D-P2-02 + D-P2-03 cutover; replaces the 7-field `url:string` + shape per audit P1 #10 amendment 2026-05-20): + ```json + { + "schemaVersion": "2", + "timestamp": "2025-05-15T14:32:10Z", + "urls": ["https://example.com/", "https://app.example.com/dashboard"], + "userAgent": "Chrome/...", + "extensionVersion": "1.0.0", + "videoBufferSeconds": 30, + "logDurationMinutes": 10, + "totalEvents": 143 + } + ``` + All 8 fields required. Acceptance: + - `schemaVersion === '2'` (marks the D-P2-02 url→urls cutover; future schema bumps increment) + - `timestamp` ISO-8601 with `Z` suffix + - `urls` is a non-empty `string[]` of URLs matching `/^(https?|chrome-extension):\/\//` (per CONTEXT.md `` filter rules — exclude chrome://, about:, devtools://, file://) + - `urls` is deduplicated; ordering is first-seen-first across the rolling recording window + - `extensionVersion` matches semver + - `totalEvents` is a non-negative integer + - exactly 8 keys; no extras + Binding: CON-meta-json-schema (this REQ-text supersedes the original CON-meta-json-schema 7-field shape). + ``` + + 2. Update the Traceability table entry (line 225) for REQ-meta-json-schema: + FROM: `| REQ-meta-json-schema | Phase 3 (originally) → **Phase 2** (renumbered) | Pending |` + TO: `| REQ-meta-json-schema | Phase 2 | Pending (implementation landed via Plan 02-03; harness validation deferred to Plan 02-04) |` + + 3. Append a `*Updated YYYY-MM-DD*` footer line at the bottom of the file matching the existing + footer pattern (line 249 area). Date: 2026-05-20. + + Per D-P2-02 + D-P2-03: this is the canonical schema-amendment Phase 2 ships. The 8-field shape + becomes the new baseline for all downstream consumers (UAT harness, future v2 SRV-* uploads, + operator post-mortem tooling). + + + grep -c "schemaVersion" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md ; grep -c "urls.*string\[\]" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md + + + REQUIREMENTS.md REQ-meta-json-schema block reflects 8-field shape. Traceability table updated. + Footer line appended with 2026-05-20 date. Atomic commit: + `docs(02-03): REQUIREMENTS — REQ-meta-json-schema amended for 8-field shape with urls[] + schemaVersion`. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| tab URL strings → meta.json | Tab URLs may contain sensitive operator state (per CONTEXT.md `` D-P2-02 "Privacy note"); the "log is internal" charter accepts this. | +| chrome.tabs.* event source | Chrome platform → SW; trustworthy per MV3 contract. | +| tab-url-tracker internal Set → getTabUrlsSeen consumer | Module returns a COPY (slice); caller cannot mutate internal state. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-03-01 | Information Disclosure | URLs with embedded credentials (https://user:pass@host/) leak into meta.json | accept | Charter shift 2026-05-20 ("log is internal") explicitly accepts this. REQ-password-confidentiality moved to Out of Scope v1. No mitigation in v1; v2/Phase 4 candidate. | +| T-02-03-02 | Information Disclosure | chrome-extension:/// URLs reveal extension presence | accept | The extension is installed unpacked locally; presence is already self-evident from chrome://extensions. INCLUDE per CONTEXT.md `` filter. | +| T-02-03-03 | Tampering | Malicious extension overrides chrome.tabs.* events | mitigate | Extension permissions are user-granted at install time. Defensive try/catch wraps every chrome.* call so malformed events don't crash the SW. | +| T-02-03-04 | Denial of Service | Unbounded tab-url Set growth in long-running SW | mitigate | URL set is bounded by O(unique tabs operator visits per SW lifetime). Operationally ≤500 even in heavy use. No pruning needed within v1 budget; SW idle teardown (~30s) clears state. Phase 4 hardening can add pruning if production telemetry surfaces unbounded growth. | +| T-02-03-05 | Repudiation | Tracker silently drops URLs when `tabs` permission absent (chrome.tabs.get returns undefined .url) | mitigate | Diagnostic logger.warn at every dropped event so post-mortem investigation can identify the permission gap. Phase 4 hardening adds `tabs` permission per CONTEXT.md `` item. | + + + +- `npx tsc --noEmit` → clean. +- `npm run build` → clean. +- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 4/4 GREEN. +- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8/8 GREEN. +- `npx vitest run` (full suite) → previously RED tests from Plan 02-01 all GREEN. Net: 153 + 15 = 168 GREEN (or whatever the exact count is post-Plan-02-01 + Plan-02-02). +- `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 FORBIDDEN_HOOK_STRINGS unchanged → GREEN. +- `npm run test:uat` → 24/24 GREEN preserved (no harness changes in this plan; A13's meta.json shape check + may detect the schemaVersion + urls fields — verify A13 still GREEN; if it depends on the old `url` field, Plan 02-04 addresses). +- `grep -c "schemaVersion" .planning/REQUIREMENTS.md` ≥ 2 (in REQ block + acceptance bullet). +- `grep -c "url: string" src/shared/types.ts` → 0 (the legacy field is gone). +- Always-on charter check: `grep -n "clearTabUrlsSeen" src/background/index.ts` → 0 hits (createArchive does NOT clear; tracker keeps accumulating after SAVE). + + + +1. tests/background/meta-json-urls-schema.test.ts 4/4 GREEN. +2. tests/build/strict-meta-json-validation.test.ts 8/8 GREEN. +3. UAT harness 24/24 GREEN preserved (or A13 amended in lockstep if its meta.json assertions depend on the old `url` field — verify before commit). +4. .planning/REQUIREMENTS.md REQ-meta-json-schema reflects the 8-field shape with breaking-change cutover documented. +5. Always-on charter preserved: tab-url-tracker continues accumulating after SAVE; no createArchive-time clearTabUrlsSeen call. +6. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged). +7. tabs permission gap NOT closed by this plan — explicit deferral comment in tab-url-tracker.ts citing CONTEXT.md ``. + + + +After completion, create `.planning/phases/02-stabilize-export-pipeline/02-03-SUMMARY.md` +documenting: +- New module src/background/tab-url-tracker.ts (LOC + public API summary) +- SessionMetadata field-count delta (7 → 8) + ordering rationale +- 8th field `schemaVersion` decision: planner-suggested in Plan 02-01 Task 3, ratified here +- Filter rules verbatim from CONTEXT.md `` +- Tabs permission gap deferral citation (CONTEXT.md ``) +- Forward link: Plan 02-04 may amend UAT harness A13 if its meta.json assertions assumed the old `url` field + diff --git a/.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md b/.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md new file mode 100644 index 0000000..44889f9 --- /dev/null +++ b/.planning/phases/02-stabilize-export-pipeline/02-04-PLAN.md @@ -0,0 +1,721 @@ +--- +phase: 02-stabilize-export-pipeline +plan: 04 +type: auto +wave: 3 +depends_on: [02, 03] +files_modified: + - tests/uat/extension-page-harness.ts + - tests/uat/lib/harness-page-driver.ts + - tests/uat/harness.test.ts + - tests/background/no-test-hooks-in-prod-bundle.test.ts +autonomous: false +requirements: + - REQ-archive-export-latency + - REQ-meta-json-schema + - REQ-popup-ui + - REQ-screenshot-on-export + - REQ-archive-layout +tags: + - uat-harness + - a24 + - a25 + - a26 + - a27 + - blob-url-empirical + - latency-5s + - meta-urls-shape + - operator-checkpoint + - phase-2-closure + - approach-b +must_haves: + truths: + - "UAT harness extends with A24+ assertions covering the D-P2-01 + D-P2-02 + D-P2-03 contracts empirically (page-side, no SW-side hooks per Approach B)." + - "A24 verifies the SAVE_ARCHIVE → chrome.downloads.create call site receives a `blob:` URL prefix (NOT `data:application/zip;base64,`)." + - "A25 verifies the SAVE_ARCHIVE → zip-on-disk latency (<5000ms from chrome.runtime.sendMessage({type:'SAVE_ARCHIVE'}) dispatch to file appearing in downloadsDir) per REQ-archive-export-latency." + - "A26 verifies the produced meta.json has the 8-field D-P2-02/D-P2-03 shape: schemaVersion='2', urls is non-empty string[], no `url` key present." + - "A27 verifies tab-URL tracking by activating 2 distinct tabs during the recording window, then SAVE, then inspecting meta.urls — expects both URLs to be present (deduplicated, first-seen-ordered)." + - "Tier-1 FORBIDDEN_HOOK_STRINGS inventory extended IF new hook surfaces required for A24+; lockstep across tests/background/no-test-hooks-in-prod-bundle.test.ts AND tests/uat/harness.test.ts A0 mirror." + - "Final operator empirical checkpoint validates: (a) saving a real ~6MB archive completes successfully (was: data:URL Network error pre-Plan-02-02); (b) the produced meta.json has the new 8-field shape (operator opens the zip with archive-manager tool); (c) the saved zip opens cleanly in the OS file manager." + - "Pre-checkpoint bundle gates per saved memory `feedback-pre-checkpoint-bundle-gates.md` are run BEFORE operator checkpoint: SW CSP grep (no `new Function`/`eval`), SW Node-globals grep (no `Buffer.from`), DOM-globals grep, SW-bundle-import gate, manifest validation." + - "Phase 2 closes with 4/4 plans landed; 24/24 UAT baseline preserved or extended to 28/28 (A24+A25+A26+A27 inclusive); vitest baseline preserved or extended; operator empirical ack documented." + artifacts: + - path: "tests/uat/extension-page-harness.ts" + provides: "assertA24 (blob: URL prefix) + assertA25 (5s latency) + assertA26 (meta.urls shape) + assertA27 (multi-tab URL dedup)" + contains: "assertA24|assertA25|assertA26|assertA27" + - path: "tests/uat/lib/harness-page-driver.ts" + provides: "driveA24, driveA25, driveA26, driveA27 page.evaluate wrappers" + contains: "driveA24|driveA25|driveA26|driveA27" + - path: "tests/uat/harness.test.ts" + provides: "Orchestrator runs A24+A25+A26+A27 after A23; FORBIDDEN_HOOK_STRINGS lockstep if new hook symbols" + contains: "driveA24|driveA25|driveA26|driveA27" + - path: "tests/background/no-test-hooks-in-prod-bundle.test.ts" + provides: "Lockstep FORBIDDEN_HOOK_STRINGS update if new hook symbols introduced" + contains: "FORBIDDEN_HOOK_STRINGS" + key_links: + - from: "tests/uat/harness.test.ts" + to: "tests/uat/lib/harness-page-driver.ts:driveA24..A27" + via: "import + sequential orchestrator dispatch after driveA23" + pattern: "driveA24|driveA25|driveA26|driveA27" + - from: "tests/uat/extension-page-harness.ts:assertA25" + to: "performance.now() bookends around SAVE_ARCHIVE dispatch + host-side downloadsDir poll" + via: "page-side measures dispatch→ack; host-side measures dispatch→file-on-disk; assert total < 5000ms" + pattern: "assertA25" + - from: "tests/uat/extension-page-harness.ts:assertA24" + to: "chrome.downloads.onChanged listener OR chrome.downloads spy proxy" + via: "harness page spies on chrome.downloads.download via Proxy/replace OR reads delta.url from onChanged event; verifies prefix is 'blob:'" + pattern: "chrome\\.downloads" +--- + + +Wave 3 of Phase 2: extend the UAT harness with A24+A25+A26+A27 assertions covering the D-P2-01 + +D-P2-02 + D-P2-03 contracts end-to-end through a real Chrome instance. Run pre-checkpoint bundle +gates per saved memory before surfacing the final operator empirical checkpoint. Close Phase 2. + +Purpose: empirical verification that Plans 02-02 (Blob URL pipeline) and 02-03 (meta.urls schema) +work in a real Chrome instance, not just in unit-test isolation. The harness extension is the +closure gate for Phase 2 — analogous to Plan 01-13's 15/15 harness PASS that closed Phase 1 +functional contract. + +Output: +- 4 new harness assertions (A24+A25+A26+A27) wired through page-harness + driver + orchestrator. +- Tier-1 FORBIDDEN_HOOK_STRINGS lockstep IF new hook symbols required. +- Pre-checkpoint bundle gate validation completed. +- Operator empirical checkpoint documenting Plan 02-02 + 02-03 + the 5s-latency contract on a + real 6MB archive. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/STATE.md +@.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md +@.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md +@.planning/phases/02-stabilize-export-pipeline/02-02-PLAN.md +@.planning/phases/02-stabilize-export-pipeline/02-03-PLAN.md + +# Precedent for harness extension pattern (Approach B) +@.planning/phases/01-stabilize-video-pipeline/01-13-SUMMARY.md +@.planning/phases/01-stabilize-video-pipeline/01-14-SUMMARY.md + +# Files under modification +@tests/uat/harness.test.ts +@tests/uat/lib/harness-page-driver.ts +@tests/background/no-test-hooks-in-prod-bundle.test.ts + + + + +From tests/uat/harness.test.ts FORBIDDEN_HOOK_STRINGS (line 107-122): +```typescript +const FORBIDDEN_HOOK_STRINGS: ReadonlyArray = [ + '__mokoshTest', + 'setCurrentStream', + 'setSegmentCountGetter', + 'installFakeDisplayMedia', + 'uninstallFakeDisplayMedia', + 'dispatchEndedOnTrack', + 'getSegmentCount', + '__mokoshOffscreenQuery', + 'get-display-surface', + 'get-segment-count', + 'lastGetDisplayMediaConstraints', + 'get-last-getDisplayMedia-constraints', +]; +``` + +Existing driver pattern (tests/uat/lib/harness-page-driver.ts:driveA23): +```typescript +export async function driveA23(page: Page): Promise { + return await page.evaluate(async () => { + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA23(); + return r; + }) as AssertionRecord; +} +``` + +A5 driver pattern (host-side + page-side merge) for downloadsDir polling — driveA24/A25 +should reuse this pattern OR chain off A5's existing zip-on-disk verification. See +tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pattern. + + + + + + + Task 1: assertA24 — chrome.downloads receives blob: URL prefix (D-P2-01 empirical) + tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts + + - assertA24 page-side: at SAVE_ARCHIVE dispatch time, spy on chrome.downloads.download via a + page-context Proxy / monkey-patch installed BEFORE the dispatch. Wait for the SAVE_ARCHIVE + ack, then read the captured args[0].url. Assert: + (a) url.startsWith('blob:') === true + (b) url.startsWith('data:application/zip;base64,') === false + - driveA24: standard page.evaluate wrapper (mirrors driveA23 shape). + - A24 chains AFTER A23 in the orchestrator — does its own setupFreshRecording before SAVE + OR reuses A5's recording state if testable. PLANNER-DECISION: A24 does its OWN fresh + recording + save dispatch because the spy-installation requires controlling the window + RIGHT BEFORE the SAVE — chaining off A5's already-completed save misses the spy window. + - NO new test-hook symbols required: chrome.downloads.download is a chrome.* API that the + privileged extension-internal page can monkey-patch directly without going through the + __mokoshOffscreenQuery bridge. The Proxy replaces chrome.downloads.download with a wrapper + that records args + delegates to the original; assertA24 reads back the captured args + restores. + - This means Tier-1 FORBIDDEN_HOOK_STRINGS inventory STAYS at 12 (no new symbols). + + + Edit `tests/uat/extension-page-harness.ts` to add `assertA24` (pattern after assertA23 — read + lines around 1906-1989 from the file for the existing assertA23 shape): + + ```typescript + /** + * A24 — D-P2-01 empirical: SAVE_ARCHIVE → chrome.downloads.download is invoked with a + * `blob:` URL (NOT `data:application/zip;base64,`). Closes audit P0-6 functionally. + * + * Pattern: install a chrome.downloads.download Proxy that records the first url arg, + * dispatch SAVE_ARCHIVE, await ack, restore original, assert the captured url prefix. + * + * Chains: independent of A5 (does its own setupFreshRecording + SAVE) because the spy + * must be installed BEFORE the SAVE dispatch. + */ + async function assertA24(): Promise { + const checks: CheckRecord[] = []; + const diagnostics: string[] = []; + + // Setup: fresh recording (mirrors A23's pattern; reuses setupFreshRecording helper). + await setupFreshRecording(); + // Settle: let one segment land so SAVE has video to package. + await new Promise(r => setTimeout(r, 11_000)); + + // Install spy. + const original = chrome.downloads.download.bind(chrome.downloads); + let capturedUrl: string | null = null; + (chrome.downloads as any).download = (opts: chrome.downloads.DownloadOptions) => { + capturedUrl = opts.url; + return original(opts); + }; + + try { + const ack: { success: boolean } = await new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve); + }); + checks.push({ + name: 'A24.1: SAVE_ARCHIVE ack received with success=true', + expected: true, + actual: ack.success, + passed: ack.success === true, + }); + // Poll up to 5s for the spy to fire (chrome.downloads.download is async-resolved + // post the offscreen bridge round-trip). + const pollStart = Date.now(); + while (capturedUrl === null && Date.now() - pollStart < 5000) { + await new Promise(r => setTimeout(r, 100)); + } + checks.push({ + name: 'A24.2: chrome.downloads.download was invoked', + expected: true, + actual: capturedUrl !== null, + passed: capturedUrl !== null, + }); + const urlIsBlob = capturedUrl !== null && capturedUrl.startsWith('blob:'); + const urlIsDataBase64 = capturedUrl !== null && capturedUrl.startsWith('data:application/zip;base64,'); + checks.push({ + name: 'A24.3: download URL starts with "blob:" (D-P2-01)', + expected: true, + actual: urlIsBlob, + passed: urlIsBlob, + }); + checks.push({ + name: 'A24.4: download URL does NOT start with "data:application/zip;base64," (legacy path retired)', + expected: true, + actual: !urlIsDataBase64, + passed: !urlIsDataBase64, + }); + diagnostics.push(`capturedUrl prefix: ${capturedUrl?.substring(0, 40) ?? ''}...`); + } finally { + (chrome.downloads as any).download = original; + } + + return { + name: 'A24 — D-P2-01 Blob URL download (closes P0-6)', + checks, + diagnostics, + }; + } + ``` + + Register assertA24 on the __mokoshHarness window surface (mirror the assertA23 registration line). + + Edit `tests/uat/lib/harness-page-driver.ts` to add driveA24 (standard wrapper, identical + to driveA23 shape). + + Per D-P2-01: this is the empirical closure of P0-6. The unit test (Plan 02-01 Task 1) proves + the wire-format at the SW boundary; A24 proves it end-to-end through a real Chrome instance, + including the offscreen bridge round-trip + the chrome.downloads platform call. + + + npm run build:test 2>&1 | tail -5 ; HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A24|FAIL|PASS)" | tail -20 + + + A24 page-side + driver wired. Harness orchestrator runs through A24. ALL 4 A24 checks GREEN. + Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (chrome.downloads spy is a chrome.* monkey-patch, + not a test-hook symbol). + Atomic commit: + `feat(02-04): harness A24 — empirical Blob URL download verification (D-P2-01)`. + + + + + Task 2: assertA25 — SAVE→zip-on-disk latency <5s (REQ-archive-export-latency) + tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts + + - assertA25 page-side: dispatch SAVE_ARCHIVE; record `t0 = performance.now()` before dispatch + and `tAck = performance.now()` after the ack. Return both timings in the page result. + - driveA25 host-side: also polls downloadsDir for the new zip file (mirrors A5's pattern) and + records `tFile = Date.now()` when the file is observed. Returns the page result merged with + the host-side timing. + - Assertions: + (a) tAck - t0 < 5000 (page-side: dispatch → ack) + (b) tFile - t0_host < 5000 (host-side: dispatch → file on disk, where t0_host is captured + on the driver side just before page.evaluate) + - Operator-facing tolerance: 5000ms is the SPEC §10 #6 + CON-archive-export-latency hard ceiling. + Recorded actual latency reported in diagnostics for retrospective tuning. + - A25 chains AFTER A24's SAVE (which already produced one zip in downloadsDir) — A25 does its + OWN setupFreshRecording + SAVE because the latency measurement must be on a clean save, not + compounded with A24's still-pending state. + - PLANNER-DECISION on the empty-buffer race: A25 settles for 11s after setupFreshRecording before + dispatching SAVE_ARCHIVE (mirrors A13's pattern from Plan 01-13 Wave 3D — let one segment land). + The 5s SAVE→file latency budget is measured FROM SAVE dispatch, NOT from the broader test orchestration. + + + Add `assertA25` to tests/uat/extension-page-harness.ts (mirror assertA24's structure): + + ```typescript + /** + * A25 — REQ-archive-export-latency: SAVE_ARCHIVE → zip on disk in <5000ms. + * CON-archive-export-latency + SPEC §10 #6 hard ceiling. + * + * Returns dispatch + ack timings (page-side); host-side driver merges the + * dispatch→file-on-disk timing (mirrors A5's merged-checks pattern). + */ + async function assertA25(): Promise { + const checks: CheckRecord[] = []; + const diagnostics: string[] = []; + + await setupFreshRecording(); + await new Promise(r => setTimeout(r, 11_000)); + + const t0 = performance.now(); + const ack: { success: boolean } = await new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve); + }); + const tAck = performance.now(); + const elapsedAck = tAck - t0; + + checks.push({ + name: 'A25.1: SAVE_ARCHIVE ack received with success=true', + expected: true, + actual: ack.success, + passed: ack.success === true, + }); + checks.push({ + name: 'A25.2: dispatch → ack latency < 5000ms', + expected: '<5000ms', + actual: `${elapsedAck.toFixed(0)}ms`, + passed: elapsedAck < 5000, + }); + diagnostics.push(`page-side latency: t0=${t0.toFixed(0)} tAck=${tAck.toFixed(0)} delta=${elapsedAck.toFixed(0)}ms`); + + return { + name: 'A25 — REQ-archive-export-latency: <5000ms (SPEC §10 #6)', + checks, + diagnostics, + t0, + tAck, + }; + } + ``` + + Add `driveA25` with host-side latency merge (pattern after driveA5): + ```typescript + export async function driveA25(page: Page, downloadsDir: string): Promise { + const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename)); + const t0_host = Date.now(); + const pageResult = await page.evaluate(async () => { + const harness = (window as any).__mokoshHarness; + return await harness.assertA25(); + }) as AssertionRecord & { t0: number; tAck: number }; + + // Host-side poll for new zip. + let tFile: number | null = null; + const pollStart = Date.now(); + while (Date.now() - pollStart < 6000 /* 1s budget over the 5s SLO for slack */) { + const candidates = readdirSync(downloadsDir).filter( + (name) => isZipFilename(name) && !preExisting.has(name), + ); + if (candidates.length > 0) { + tFile = Date.now(); + break; + } + await new Promise(r => setTimeout(r, 100)); + } + + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const elapsedFile = tFile !== null ? tFile - t0_host : -1; + mergedChecks.push({ + name: 'A25.3: host-side dispatch → zip-on-disk latency < 5000ms', + expected: '<5000ms', + actual: elapsedFile >= 0 ? `${elapsedFile}ms` : 'zip never appeared', + passed: elapsedFile >= 0 && elapsedFile < 5000, + }); + const mergedDiagnostics = pageResult.diagnostics.slice(); + mergedDiagnostics.push(`host-side latency: t0_host=${t0_host} tFile=${tFile ?? ''} delta=${elapsedFile}ms`); + + return { + passed: mergedChecks.every(c => c.passed), + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; + } + ``` + + Note: `isZipFilename` is already exported in harness-page-driver.ts (line 309) — reuse. + + Per REQ-archive-export-latency + CON-archive-export-latency: this is the canonical SPEC §10 #6 + empirical gate. The 5000ms budget is end-to-end (SAVE dispatch → file on disk). + + + HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A25|FAIL|PASS|latency)" | tail -20 + + + A25 page-side + driver wired with merged checks. 3 checks GREEN (ack received + page-side + latency + host-side latency, all <5000ms). Atomic commit: + `feat(02-04): harness A25 — empirical <5s SAVE→zip latency (REQ-archive-export-latency, SPEC §10 #6)`. + + + + + Task 3: assertA26 + A27 — meta.urls shape + multi-tab dedup empirical (D-P2-02) + tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts + + - assertA26 page-side: chain off A25's produced zip (read host-side filename via merged checks). + Use JSZip.loadAsync to read meta.json from the zip; JSON.parse; assert: + (a) Object.keys(meta).length === 8 + (b) meta.schemaVersion === '2' + (c) Array.isArray(meta.urls) && meta.urls.length >= 1 + (d) meta.url === undefined (the legacy single-URL field is gone) + (e) meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u)) + - assertA27 page-side: requires multi-tab activation BEFORE save. Sequence: + (1) setupFreshRecording + (2) Open tab A (e.g., chrome.tabs.create({ url: 'https://example.com' })) and wait for it to load + (3) Activate tab A + (4) Open tab B (e.g., chrome.tabs.create({ url: 'https://www.iana.org' })) and wait + (5) Activate tab B + (6) Wait 11s for one segment to land + (7) Dispatch SAVE_ARCHIVE + (8) Read meta.urls from the produced zip + (9) Assert both example.com AND iana.org appear in meta.urls + (10) Cleanup: close both tabs + - PLANNER-NOTE on tabs permission limitation: A27 depends on chrome.tabs URL access. Per Plan + 01-13 SUMMARY Known Limitations item 3, `tabs` permission is NOT declared and `chrome.tabs.query` + may return tabs without `.url`. RESOLUTION: A27 uses chrome.tabs.create + chrome.tabs.update + to drive tab activation directly, bypassing chrome.tabs.query. The tracker's chrome.tabs.onActivated + listener fires regardless of permission — it's the post-event chrome.tabs.get(tabId) inside the + tracker that may return undefined .url. If A27 reveals the gap empirically, surface a diagnostic + in the test result and defer the closure to Phase 4 hardening (CONTEXT.md `` tabs + permission gap item). If A27 PASSES on the active-tab path, the limitation is non-blocking + for Phase 2. + - driveA26 + driveA27: standard page.evaluate wrappers (mirror driveA23/A24). + - Orchestrator update: tests/uat/harness.test.ts adds A24, A25, A26, A27 to the assertion + sequence AFTER A23. Total target: 24 (Phase 1 baseline) + 4 (Phase 2) = 28/28 GREEN. + - FORBIDDEN_HOOK_STRINGS lockstep: PLANNER-DECISION — A24/A25/A26/A27 use chrome.* monkey-patch + (A24's downloads spy) + JSZip + chrome.tabs.create/update which are production APIs. No new + test-hook surface required. Tier-1 inventory STAYS at 12. Plan-checker verifies via final grep. + + + Add `assertA26` and `assertA27` to tests/uat/extension-page-harness.ts: + + ```typescript + /** + * A26 — D-P2-02 + D-P2-03 empirical: meta.json has the 8-field shape with urls[] (not url:string) + * and schemaVersion='2'. Chains off A25's produced zip. + */ + async function assertA26(zipBytes: Uint8Array): Promise { + const checks: CheckRecord[] = []; + const diagnostics: string[] = []; + + const zip = await JSZip.loadAsync(zipBytes); + const metaFile = zip.file('meta.json'); + checks.push({ + name: 'A26.1: meta.json entry exists in zip', + expected: true, actual: metaFile !== null, passed: metaFile !== null, + }); + if (metaFile === null) { + return { name: 'A26 — meta.json 8-field shape (D-P2-02/03)', checks, diagnostics }; + } + const metaText = await metaFile.async('string'); + const meta = JSON.parse(metaText); + diagnostics.push(`meta.json keys: ${Object.keys(meta).join(',')}`); + + checks.push({ + name: 'A26.2: meta has exactly 8 fields', + expected: 8, actual: Object.keys(meta).length, passed: Object.keys(meta).length === 8, + }); + checks.push({ + name: 'A26.3: meta.schemaVersion === "2"', + expected: '2', actual: meta.schemaVersion, passed: meta.schemaVersion === '2', + }); + checks.push({ + name: 'A26.4: meta.urls is non-empty Array', + expected: 'non-empty Array', + actual: Array.isArray(meta.urls) ? `Array(${meta.urls.length})` : typeof meta.urls, + passed: Array.isArray(meta.urls) && meta.urls.length >= 1, + }); + checks.push({ + name: 'A26.5: meta.url (legacy field) is undefined', + expected: 'undefined', actual: typeof meta.url, passed: meta.url === undefined, + }); + checks.push({ + name: 'A26.6: every meta.urls[i] matches /^(https?|chrome-extension):\\/\\//', + expected: true, + actual: Array.isArray(meta.urls) && meta.urls.every((u: string) => /^(https?|chrome-extension):\/\//.test(u)), + passed: Array.isArray(meta.urls) && meta.urls.every((u: string) => /^(https?|chrome-extension):\/\//.test(u)), + }); + + return { name: 'A26 — meta.json 8-field shape (D-P2-02/D-P2-03)', checks, diagnostics }; + } + + /** + * A27 — D-P2-02 empirical: multi-tab URL tracking. Activates two distinct + * URLs during the recording window; meta.urls should contain both. + */ + async function assertA27(): Promise { + const checks: CheckRecord[] = []; + const diagnostics: string[] = []; + + await setupFreshRecording(); + + // Open + activate 2 distinct tabs. + const tabA = await chrome.tabs.create({ url: 'https://example.com', active: true }); + await new Promise(r => setTimeout(r, 1500)); + const tabB = await chrome.tabs.create({ url: 'https://www.iana.org', active: true }); + await new Promise(r => setTimeout(r, 1500)); + + // Settle for one segment. + await new Promise(r => setTimeout(r, 11_000)); + + // SAVE. + const ack: { success: boolean } = await new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve); + }); + checks.push({ + name: 'A27.1: SAVE_ARCHIVE ack received', + expected: true, actual: ack.success, passed: ack.success === true, + }); + + // Cleanup tabs. + if (tabA.id) await chrome.tabs.remove(tabA.id); + if (tabB.id) await chrome.tabs.remove(tabB.id); + + diagnostics.push(`opened tabA=${tabA.id} tabB=${tabB.id}; meta.urls inspection deferred to host-side`); + return { name: 'A27 — multi-tab URL dedup (D-P2-02)', checks, diagnostics }; + } + ``` + + Add `driveA26(page, downloadsDir)` + `driveA27(page, downloadsDir)` with host-side zip-lookup + + meta.urls inspection — pattern after driveA5's merged-checks shape. + + For driveA27's host side: after the page returns the ack, locate the most-recent zip in + downloadsDir, load it via JSZip, parse meta.json, assert example.com AND iana.org are both + in meta.urls. + + Update tests/uat/harness.test.ts orchestrator (search for the existing sequence around the + driveA23 dispatch) to add: + ```typescript + drivers.push({ name: 'A24', fn: () => driveA24(page) }); + drivers.push({ name: 'A25', fn: () => driveA25(page, handles.downloadsDir) }); + drivers.push({ name: 'A26', fn: () => driveA26(page, handles.downloadsDir) }); + drivers.push({ name: 'A27', fn: () => driveA27(page, handles.downloadsDir) }); + ``` + Final target: 28/28 GREEN. + + FORBIDDEN_HOOK_STRINGS verification: grep the new test files for any new symbols that might + leak into dist/. Expected: none (all symbols are local to extension-page-harness.ts which is + NOT built into dist/ — it's loaded via the test-harness page, not the production manifest). + Tier-1 inventory stays at 12. + + Per D-P2-02 + D-P2-03 + REQ-archive-export-latency: A24+A25+A26+A27 collectively validate the + full Phase 2 contract empirically. After A27, Phase 2 functional contract is HARNESS-CLOSED + (analogous to Plan 01-13's role for Phase 1). + + + HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|FAIL|PASS|28/28)" | tail -30 + + + A26 + A27 page-side + drivers wired. Orchestrator runs 28 assertions sequentially. ALL 28 GREEN. + FORBIDDEN_HOOK_STRINGS unchanged at 12. Atomic commit: + `feat(02-04): harness A26+A27 — empirical meta.json 8-field + multi-tab urls[] verification (D-P2-02/03)`. + + + + + Task 4: Pre-checkpoint bundle gates + operator empirical checkpoint + (no files modified; verification-only — gate runs against existing dist/ + a real Chrome instance) + + **Step 1 — Pre-checkpoint bundle gates** (orchestrator-driven; per saved memory + `feedback-pre-checkpoint-bundle-gates.md`; MUST PASS before surfacing the empirical Step 2 to operator): + + Run each in sequence; on first failure, surface a diagnostic to the operator + block the checkpoint: + 1. `npm run build` → exit 0; dist/ populated. + 2. SW CSP-safety grep: `grep -rE 'new Function\(|eval\(' dist/assets/index-*-bg.js` → 0 hits OR documented pre-existing exceptions only (e.g., setimmediate polyfill `new Function` per Plan 01-12 Wave 7 disclosure — exact-string match exception list documented in `.planning/phases/01-stabilize-video-pipeline/deferred-items.md`). + 3. SW Node-globals grep: `grep -rE 'Buffer\.from|Buffer\.alloc|process\.|require\(' dist/assets/index-*-bg.js` → 0 hits. + 4. DOM-globals grep: `grep -rE '(window\.|document\.)' dist/assets/index-*-bg.js | grep -vE '^//|globalThis|^$'` → 0 hits in SW chunk (DOM globals are forbidden in SW context — see DEC-006). + 5. Tier-1 SW-bundle-import gate: `npx vitest run tests/background/sw-bundle-import.test.ts` → GREEN. + 6. Tier-1 FORBIDDEN_HOOK_STRINGS gate: `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 strings, 0 hits each → GREEN. + 7. Manifest validation gate: `npx vitest run tests/i18n/manifest-i18n.test.ts tests/i18n/locale-parity.test.ts tests/build/` → GREEN. + + **Step 2 — Operator-driven empirical UAT cycle 1** (manual, ~5 min): + + Step 2.1 — Load unpacked extension from `dist/` into Chrome. + Expected: no warnings/errors in chrome://extensions/. + + Step 2.2 — Open a tab with `https://example.com`. Open a second tab with `https://www.iana.org`. + Click the Mokosh toolbar icon → pick "Entire screen" in the picker. + Expected: REC badge appears; recording starts. + + Step 2.3 — Switch between the two tabs a few times. Wait at least 15 seconds (one full segment lands). + + Step 2.4 — Open the Mokosh popup. Click "Сохранить отчёт об ошибке" (or the i18n equivalent). + Expected within 5 seconds: + (a) A `session_report_*.zip` file lands in Downloads folder + (b) The popup transitions idle → "Сохраняю..." → "Готово! ✓" → idle (3s revert) + + Step 2.5 — Open the zip with the OS archive manager. + Expected layout: + ``` + session_report_*.zip + ├── video/last_30sec.webm + ├── rrweb/session.json + ├── logs/events.json + ├── screenshot.png + └── meta.json + ``` + + Step 2.6 — Open meta.json. Verify: + (a) `schemaVersion: "2"` present + (b) `urls` field is an ARRAY (not a string) + (c) `urls` contains at least `https://example.com/` AND `https://www.iana.org/` + (d) NO `url` field present (just `urls`) + (e) Exactly 8 keys total + + Step 2.7 — Open video/last_30sec.webm in a browser (drag into a Chrome tab). + Expected: ~30 seconds of video plays end-to-end. + + Step 2.8 — Verify the >2 MB archive case (the regression class P0-6 closes): + (a) In Chrome DevTools → Network panel of the Mokosh offscreen / extension context, observe + the download was initiated from a `blob:chrome-extension:///` URL (NOT a + `data:application/zip;base64,...`). + (b) If the archive is larger than 2 MB (typical for ≥15s of video), this proves the + D-P2-01 migration works for the canonical use case. + + **Reply contract:** Type "approved" if Steps 1-2 all match expectations. + If any step deviates, describe the deviation (which step + what was observed + what was expected). + Deviations route to a follow-up plan (02-05 OR a debug session per saved memory + `feedback-gsd-ceremony-for-fixes.md` — NEVER hot-edit). + + **Why human-verify (not auto):** Steps 2.1-2.8 require a real Chrome instance with screen-share + grant + a real OS Downloads folder + a real archive-manager tool. The harness in Tasks 1-3 + covers the chrome.* + zip-shape contracts but cannot validate the OS-level archive integrity + (Step 2.5 archive-manager open) or the operator's empirical observation of the network panel + (Step 2.8a). Per saved memory `feedback-pre-checkpoint-bundle-gates.md`: "Operator time is for + things automation cannot verify" — Steps 2.5, 2.7, 2.8 are exactly those. + + + npm run build 2>&1 | tail -5 ; npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts tests/background/sw-bundle-import.test.ts tests/i18n/ tests/build/ 2>&1 | tail -10 + + + Step 1 gates ALL GREEN (7/7 sub-gates). Operator empirical reply received: + EITHER "approved" → Phase 2 closes, mark REQUIREMENTS.md REQ-archive-export-latency + + REQ-meta-json-schema + REQ-popup-ui + REQ-archive-layout + REQ-screenshot-on-export as Complete, + flip STATE.md Phase 2 → COMPLETE, update ROADMAP.md Phase 2 status; + OR deviations documented → route through `/gsd-debug` per saved memory + follow-up plan 02-05. + + + Phase 2 implementation complete: + - Plan 02-02: Offscreen-minted Blob URL pipeline (D-P2-01, closes P0-6). + - Plan 02-03: meta.json urls[] + schemaVersion + tab-url-tracker (D-P2-02, closes P1 #10). + - Plan 02-04 Tasks 1-3: UAT harness A24+A25+A26+A27 GREEN (28/28). + Acceptance baselines preserved: vitest GREEN, UAT GREEN, Tier-1 FORBIDDEN_HOOK_STRINGS = 12, + production bundle hook-free. + + + See `` block above — Step 1 (orchestrator) + Step 2 (operator) define the verification + contract verbatim. Reply contract codified in the `` block. + + Type "approved" or describe deviations (e.g., "Step 2.6c failed: meta.urls only had 1 entry, not 2") + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| harness page → chrome.downloads | privileged extension-internal page; spy via Proxy is intra-extension; not exposed to web pages | +| operator's manual zip inspection | filesystem boundary; archive may contain operator credentials per "log is internal" charter — operator decides if it's safe to share externally | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-04-01 | Tampering | chrome.downloads.download spy left installed after A24 (failure to restore in finally) | mitigate | A24 uses try/finally to restore original chrome.downloads.download; orchestrator bails on first failure, so A25 would observe the spy if A24's finally fails. Add a sentinel test: A25 reads `chrome.downloads.download.toString()` to verify it's the native function (not the proxy). | +| T-02-04-02 | Repudiation | A25 latency measurement skewed by harness startup overhead | accept | Measurement bookends bracket ONLY the SAVE_ARCHIVE dispatch → ack, NOT the broader test orchestration. setupFreshRecording + segment-settle happen BEFORE the t0 mark. | +| T-02-04-03 | Information Disclosure | A27 creates real tabs with example.com / iana.org which appear in real Downloads zips | accept | Per "log is internal" charter (CONTEXT.md re-phasing context); test tabs are public sites with no PII; downloadsDir is a per-run mkdtempSync, cleaned up by test runner. | +| T-02-04-04 | Denial of Service | A27 leaks tabs if cleanup fails (chrome.tabs.remove throws on already-closed tabs) | mitigate | A27's cleanup is wrapped in try/catch (silent-ignore — closing an already-closed tab is benign). | +| T-02-04-05 | Elevation of Privilege | New harness assertions reveal previously-uncovered chrome.* APIs to the test surface | accept | Approach B (Plan 01-13 SUMMARY): the harness page is a privileged extension-internal page that already has full chrome.* access. A24+A27 use chrome.downloads + chrome.tabs which are part of the existing extension capability set per manifest.json. No new manifest permissions in this plan. | + + + +- `npm run build` → clean. +- `npm run build:test` → clean. +- `npx tsc --noEmit` → clean. +- Pre-checkpoint bundle gates (Task 4 Step 1): + - `tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 strings, all 0 hits → GREEN. + - `tests/background/sw-bundle-import.test.ts` → GREEN. + - `tests/build/no-remote-fonts.test.ts` → GREEN. + - `tests/build/icons-present.test.ts` → GREEN. + - `tests/build/fonts-present.test.ts` → GREEN. + - `tests/i18n/manifest-i18n.test.ts` + `tests/i18n/locale-parity.test.ts` → GREEN. +- `npm test` (full suite) → GREEN. +- `HEADLESS=1 npm run test:uat` → 28/28 GREEN. +- Operator empirical Task 4 Step 2 → "approved" or surfaces deviations to drive Plan 02-05 follow-up. + + + +1. UAT harness 28/28 GREEN (A0-A23 Phase 1 baseline + A24+A25+A26+A27 Phase 2 extension). +2. Vitest full suite GREEN (Phase 1 baseline + Plan 02-01 RED tests now all GREEN post-Plans 02-02 + 02-03). +3. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged — A24+A27 use chrome.* monkey-patch + production APIs). +4. Pre-checkpoint bundle gates PASS per `feedback-pre-checkpoint-bundle-gates.md`. +5. Operator empirical UAT cycle 1 ack "approved" OR documented deviations. +6. Phase 2 closure marker flippable: REQUIREMENTS.md + STATE.md + ROADMAP.md (this last is orchestrator's territory post-checkpoint). + + + +After completion, create `.planning/phases/02-stabilize-export-pipeline/02-04-SUMMARY.md` +documenting: +- 4 new harness assertions (A24+A25+A26+A27) with their check-counts and rationale. +- Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (architectural rationale: chrome.* spy / production APIs vs new test-hook symbols). +- Pre-checkpoint bundle gate run record (each gate result inline). +- Operator empirical ack quote (verbatim) OR list of deviations + follow-up plan-pointer. +- Phase 2 closure summary: 4/4 plans landed; vitest + UAT GREEN; P0-6 + P1 #10 closed; meta.json 8-field schema shipped. +- Forward link: Phase 3 (SPEC §10 smoke + DOM/event-log verification) inherits the harness as its closure template (mirrors Plan 01-13's role for Phase 2). +