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.
+
+
+
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.
+
+
+
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 ``.
+
+
+
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).
+
+
+