Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
5 changed files with 2084 additions and 9 deletions
Showing only changes of commit 0608b22427 - Show all commits

View File

@@ -98,8 +98,11 @@ directory + `vite.config.ts` inline string + `src/background/`.
### Phase 2: Stabilize export pipeline ### Phase 2: Stabilize export pipeline
**Goal**: A click on "Сохранить отчёт об ошибке" produces a SPEC-conformant ZIP **Goal**: A click on "Сохранить отчёт об ошибке" produces a SPEC-conformant ZIP
archive on disk in under 5 s, containing a screenshot taken at click time, 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 laid out per CON-archive-layout, with `meta.json` per CON-meta-json-schema
declared by a manifest carrying exactly the permission set in DEC-011. (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 **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" 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 **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 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 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: surface. Phase 2 closes the AUDIT residuals: P0-6 (base64 → Blob URL via
screenshot-at-click capture, meta.json schema validation, and SPEC §10 #6 offscreen-minted URL.createObjectURL per DEC-006) + P1 #10 (meta.json
<5s export-latency verification. `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, **Requirements**: REQ-popup-ui, REQ-screenshot-on-export, REQ-archive-layout,
REQ-meta-json-schema, REQ-archive-export-latency, REQ-manifest-permissions 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 opening it reveals exactly the layout in REQ-archive-layout
(`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, (`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`,
`screenshot.png`, `meta.json` at the root) with no extra entries. `screenshot.png`, `meta.json` at the root) with no extra entries.
3. `meta.json` validates against the verbatim CON-meta-json-schema (all 7 3. `meta.json` validates against the verbatim CON-meta-json-schema (8 fields
fields present, types correct, `timestamp` is ISO-8601 with `Z`). 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 4. `manifest.json` in `dist/` after `npm run build` declares exactly the
permission set in DEC-011 with no additional or missing entries; loading permission set in DEC-011 with no additional or missing entries; loading
unpacked into Chrome produces no permission-related warnings or errors in unpacked into Chrome produces no permission-related warnings or errors in
`chrome://extensions/`. `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 **UI hint**: yes
### Phase 3: SPEC §10 smoke verification + DOM/event-log verification ### 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 | | 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 | | 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) | - | | 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) | - | | 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) | - |

View File

@@ -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"
---
<objective>
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)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Contracts the tests will read from / write expectations against.
Embedded so test authors don't go on a codebase scavenger hunt. -->
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<Blob> {
// ... 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/)
</interfaces>
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — blob-url-download.test.ts pins D-P2-01</name>
<files>tests/background/blob-url-download.test.ts</files>
<behavior>
- 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: <downloadId>};
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).
</behavior>
<action>
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.
</action>
<verify>
<automated>npx vitest run tests/background/blob-url-download.test.ts 2>&1 | grep -E "(FAIL|RED|failed)" | head -5</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: RED — meta-json-urls-schema.test.ts pins D-P2-02</name>
<files>tests/background/meta-json-urls-schema.test.ts</files>
<behavior>
- 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).
</behavior>
<action>
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 `<specifics>` block. The implementation in Plan 02-03 implements the tab-tracker
+ amends types.ts + amends createArchive's meta.json construction.
</action>
<verify>
<automated>npx vitest run tests/background/meta-json-urls-schema.test.ts 2>&1 | grep -E "(FAIL|RED|failed|TS\d+)" | head -10</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="tdd" tdd="true">
<name>Task 3: RED — strict-meta-json-validation.test.ts pins D-P2-03 schema</name>
<files>tests/build/strict-meta-json-validation.test.ts</files>
<behavior>
- 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 `<specifics>` 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 `<decisions>` 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).
</behavior>
<action>
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 `<specifics>` permissive empty-array clause.
</action>
<verify>
<automated>npx vitest run tests/build/strict-meta-json-validation.test.ts 2>&1 | grep -E "(FAIL|RED|failed)" | head -10</automated>
</verify>
<done>
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)`.
</done>
</task>
</tasks>
<threat_model>
## 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 <file>` 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. |
</threat_model>
<verification>
- `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).
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md`
documenting:
- RED test count delta (153 GREEN + N RED new)
- Specific failure-mode-per-test diagnostic strings
- Planner-resolved tensions: D-P2-03 "non-empty urls[]" vs CONTEXT.md `<specifics>` permissive
empty array (resolved IN FAVOR of strict rule); 8th field name (`schemaVersion` tentative pick).
- Forward link: tests flip GREEN in Plans 02-02 + 02-03.
</output>

View File

@@ -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"
---
<objective>
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 `<specifics>` "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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Wire format for the new port messages — embedded so executor doesn't dig. -->
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.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Extend PortMessageType + PortMessage in src/shared/types.ts (D-P2-01 wire contract)</name>
<files>src/shared/types.ts</files>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Offscreen handler — mint + revoke Blob URLs on port bridge</name>
<files>src/offscreen/recorder.ts</files>
<behavior>
- `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<string> 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.
</behavior>
<action>
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<string>();
async function handleCreateDownloadUrl(requestId: string, dataBase64: string, mimeType: string): Promise<void> {
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.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | head -10 ; npm run build 2>&1 | tail -10</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: SW downloadArchive rewrite + chrome.downloads.onChanged revoke wiring</name>
<files>src/background/index.ts</files>
<behavior>
- `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<number, string> (`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.
</behavior>
<action>
Edit `src/background/index.ts`:
1. Add module-scoped `pendingRevokes: Map<number, string>` near the existing state declarations
(line 68 region) with docstring citing D-P2-01.
2. Add module-scoped `pendingDownloadUrlResolvers: Map<string, (url: string) => 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<string>((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<string>((_, 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 `<decisions>` D-P2-01 verbatim, including the chrome.downloads.onChanged
listener for URL.revokeObjectURL lifecycle.
</action>
<verify>
<automated>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</automated>
</verify>
<done>
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)`.
</done>
</task>
</tasks>
<threat_model>
## 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://<id>); 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. |
</threat_model>
<verification>
- `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.)
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-02-SUMMARY.md`
documenting:
- 3 source files modified; line-count deltas.
- Wire-format extension: 3 new PortMessageType variants; in-flight resolver Map; revoke lifecycle Map.
- Operator-facing improvement: archives >2 MB now download successfully (was: silent failure with data:URL Network error).
- Forward link to Plan 02-04 harness extension for empirical A24+ verification.
</output>

View File

@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Target shape for the tab-url-tracker module. Embedded so executor needs no codebase exploration. -->
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 `<specifics>` 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.
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create src/background/tab-url-tracker.ts module</name>
<files>src/background/tab-url-tracker.ts</files>
<behavior>
- Module exports initTabUrlTracker, getTabUrlsSeen, clearTabUrlsSeen per the interface block.
- Internal state: `let tabUrlsSeen: Set<string> = 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.
</behavior>
<action>
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 <specifics>:
// 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<string>`, `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 `<specifics>`: 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 `<deferred>` "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[].
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Update SessionMetadata type + meta.json assembly + tracker wiring</name>
<files>src/shared/types.ts, src/background/index.ts</files>
<behavior>
- 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).
</behavior>
<action>
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).
</action>
<verify>
<automated>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</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Amend REQUIREMENTS.md REQ-meta-json-schema for the new 8-field shape</name>
<files>.planning/REQUIREMENTS.md</files>
<behavior>
- 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).
</behavior>
<action>
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 `<specifics>` 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).
</action>
<verify>
<automated>grep -c "schemaVersion" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md ; grep -c "urls.*string\[\]" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md</automated>
</verify>
<done>
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`.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| tab URL strings → meta.json | Tab URLs may contain sensitive operator state (per CONTEXT.md `<decisions>` 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://<id>/ URLs reveal extension presence | accept | The extension is installed unpacked locally; presence is already self-evident from chrome://extensions. INCLUDE per CONTEXT.md `<specifics>` 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 `<deferred>` item. |
</threat_model>
<verification>
- `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).
</verification>
<success_criteria>
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 `<deferred>`.
</success_criteria>
<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-03-SUMMARY.md`
documenting:
- New module src/background/tab-url-tracker.ts (LOC + public API summary)
- SessionMetadata field-count delta (7 → 8) + ordering rationale
- 8th field `schemaVersion` decision: planner-suggested in Plan 02-01 Task 3, ratified here
- Filter rules verbatim from CONTEXT.md `<specifics>`
- Tabs permission gap deferral citation (CONTEXT.md `<deferred>`)
- Forward link: Plan 02-04 may amend UAT harness A13 if its meta.json assertions assumed the old `url` field
</output>

View File

@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Existing harness orchestrator pattern (read these before extending). -->
From tests/uat/harness.test.ts FORBIDDEN_HOOK_STRINGS (line 107-122):
```typescript
const FORBIDDEN_HOOK_STRINGS: ReadonlyArray<string> = [
'__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<AssertionRecord> {
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.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: assertA24 — chrome.downloads receives blob: URL prefix (D-P2-01 empirical)</name>
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts</files>
<behavior>
- 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).
</behavior>
<action>
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<AssertionResult> {
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) ?? '<null>'}...`);
} 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.
</action>
<verify>
<automated>npm run build:test 2>&1 | tail -5 ; HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A24|FAIL|PASS)" | tail -20</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: assertA25 — SAVE→zip-on-disk latency <5s (REQ-archive-export-latency)</name>
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts</files>
<behavior>
- 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.
</behavior>
<action>
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<AssertionResult & { t0: number; tAck: number }> {
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<AssertionRecord> {
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 ?? '<missing>'} 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).
</action>
<verify>
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A25|FAIL|PASS|latency)" | tail -20</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: assertA26 + A27 — meta.urls shape + multi-tab dedup empirical (D-P2-02)</name>
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
<behavior>
- 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 `<deferred>` 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.
</behavior>
<action>
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<AssertionResult> {
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<AssertionResult> {
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).
</action>
<verify>
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|FAIL|PASS|28/28)" | tail -30</automated>
</verify>
<done>
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)`.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Pre-checkpoint bundle gates + operator empirical checkpoint</name>
<files>(no files modified; verification-only — gate runs against existing dist/ + a real Chrome instance)</files>
<action>
**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://<id>/<uuid>` 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.
</action>
<verify>
<automated>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</automated>
</verify>
<done>
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.
</done>
<what-built>
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.
</what-built>
<how-to-verify>
See `<action>` block above — Step 1 (orchestrator) + Step 2 (operator) define the verification
contract verbatim. Reply contract codified in the `<action>` block.
</how-to-verify>
<resume-signal>Type "approved" or describe deviations (e.g., "Step 2.6c failed: meta.urls only had 1 entry, not 2")</resume-signal>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
- `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.
</verification>
<success_criteria>
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).
</success_criteria>
<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-04-SUMMARY.md`
documenting:
- 4 new harness assertions (A24+A25+A26+A27) with their check-counts and rationale.
- Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (architectural rationale: chrome.* spy / production APIs vs new test-hook symbols).
- Pre-checkpoint bundle gate run record (each gate result inline).
- Operator empirical ack quote (verbatim) OR list of deviations + follow-up plan-pointer.
- Phase 2 closure summary: 4/4 plans landed; vitest + UAT GREEN; P0-6 + P1 #10 closed; meta.json 8-field schema shipped.
- Forward link: Phase 3 (SPEC §10 smoke + DOM/event-log verification) inherits the harness as its closure template (mirrors Plan 01-13's role for Phase 2).
</output>