# Screenshot Diagnostic Evidence Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make screenshot threads carry complete available developer diagnostics as private, downloadable evidence while keeping normal MCP thread reads small. **Architecture:** Reuse the recording engine's Chrome debugger and page-event sources, but write screenshot evidence to a separate raw, versioned, chunked artifact. Upload chunks to private project storage and commit a small manifest after integrity checks. Web and MCP read only summaries by default; authorized follow-up reads, archive download and local materialization retrieve the full evidence. **Tech Stack:** Chrome MV3 JavaScript, IndexedDB, CDP through `chrome.debugger`, TypeScript, Zod, PostgreSQL, existing S3-compatible `AssetStore`, Express, MCP, React, Node built-in `crypto` and `zlib`. No new package is planned. **Spec:** [Screenshot diagnostic evidence design](../specs/2026-09-29-screenshot-diagnostic-evidence-design.md). ## Global Constraints - Start implementation from the verified merged revision of modular refactor PR #117. Keep its feature-folder ownership and all external entrypoints stable. - New screenshot artifacts preserve Chrome-exposed raw values. Do not call legacy `diagnosticText`, `sanitizeCapture` or `redactRecording` on their bytes. Existing recording and legacy screenshot formats retain their behavior. - The review checkbox is checked by default, follows the Settings default for new drafts, and needs no per-entry selection or extra approval. - Use 2 MiB maximum raw chunks, a 256 MiB aggregate local and server limit, and 32 KiB maximum decoded MCP read pages. Every omitted or stopped channel has explicit coverage. - Keep raw bytes outside thread JSON, SQL summaries, logs, default MCP results, issue drafts and public source. All reads recheck current thread/project authorization and new explicit scopes. - Reproduce tests with synthetic pages and fake credentials. Preserve existing migrations, volumes and object keys. A ZIP build, CI, installed Chrome, deployment and live behavior are distinct gates. ## Review Focus 1. A 5 MiB+ DOM with multibyte UTF-8 crossing a chunk boundary must round-trip exactly through upload, archive and MCP paging (Tasks 1, 3, 5). 2. A CDP disconnect or inaccessible frame must preserve collected bytes and identify missing channels, not display zero errors as healthy (Tasks 4, 5, 7). 3. Duplicate or conflicting chunk retries, simultaneous finalization and account/project changes must not link the wrong bytes or leak an object (Tasks 2, 6). 4. A screenshot during an active session recording must share the debugger event source or report a precise gap without detaching the recording (Task 4). 5. Large or malicious page text, filenames and archive labels must stay inert in preview, download and agent files; binary data must not be treated as UTF-8 (Tasks 3, 6, 7, 8). ## File map and interfaces - `src/shared/screenshot-diagnostics.ts`: browser-safe v1 manifest, file and coverage schemas; 2 MiB/256 MiB/32 KiB constants; `splitDiagnosticBytes(bytes, fileId)` and `nextDiagnosticPage(bytes, offset, limit, text)` pure functions. - `src/shared/contracts/domains/diagnostics.ts`: `diagnostics.begin`, `putChunk`, `finalize`, `list`, `describe` and `read` contracts. `src/shared/contracts/{registry,scopes,output-common}.ts` expose them without duplicating rules. - `src/server/diagnostic-evidence.ts`: upload state machine, chunk integrity, authorization and read model. `src/server/diagnostic-archive.ts`: authenticated binary chunk and `.tar.gz` stream. `src/server/migrations.ts` adds v25 tables without touching earlier migrations. - `extension/diagnostics/{evidence-store,raw-debug,archive}.js` and `extension/capture/dom-stream.js`: IndexedDB chunks, CDP event projection, prepared-DOM streaming and local archive. Existing `session/session-coordinator.js` provides a raw event tap while preserving recording projection. - `extension/capture/workflow.js`, `extension/submission/workflow.js`, `extension/background.js`, `extension/{editor,options,popup}.{js,html}`: capture lifecycle, review default, upload/retry and bounded preview. - `src/web/threads/detail.tsx` and `src/web/threads/diagnostic-evidence.tsx`: private summary, bounded channel preview and authenticated download. `src/shared/agent-workflow.ts`, `src/server/mcp.ts`, `src/cli/diagnostic-materialize.ts`: small agent hints and on-demand evidence. ### Task 1: Versioned evidence and byte paging contract **Files:** Create `src/shared/screenshot-diagnostics.ts`, `tests/screenshot-diagnostics-contract.test.ts`. Later tasks register operations separately. **Interfaces:** `DiagnosticManifestV1` has `schemaVersion:1`, UUID `id`, source origin and time range, `coverage: Record` where status is `complete|partial|unavailable|stopped`, `files` with generated UUID `fileId`, kind (`dom|console|network|body|storage|environment|performance|coverage`), MIME type, byte length, SHA-256 and ordered `{sequence,byteLength,sha256}` chunk references, plus `totalBytes`. Archive names derive from kind and fileId; raw URLs and original page labels live in evidence files, not SQL metadata. `splitDiagnosticBytes(bytes: Uint8Array, fileId: string): Promise` yields `{sequence,data:Uint8Array,byteLength,sha256}` chunks of at most `2097152` bytes. `nextDiagnosticPage(bytes: Uint8Array, offset: number, limit: number, text: boolean)` returns `{data:Uint8Array,nextOffset,text?}` with a UTF-8 boundary when `text` is true; binary remains bytes. - [ ] **Step 1: Write the failing contract test.** `tests/screenshot-diagnostics-contract.test.ts` creates 5 MiB of HTML with `é` straddling a 2 MiB boundary, asserts chunk sizes, SHA-256 and concatenated byte equality; a 32 KiB text page resumes on a code-point boundary. Invalid UTF-8 is returned as binary, a manifest over 256 MiB is rejected, and a client-supplied `archiveName: "../escape"` is rejected by the strict schema. Core assertions: `assert.deepEqual(concat(parts.map(p => p.data)), sourceBytes)` and `assert.ok(parts.every(p => p.byteLength <= 2097152))`. - [ ] **Step 2: Run the test and see the missing-module failure.** `node --import tsx --test tests/screenshot-diagnostics-contract.test.ts` must fail before implementation. - [ ] **Step 3: Implement the schemas and pure functions.** Use `TextEncoder`/`TextDecoder` and `globalThis.crypto.subtle` so shared code works in Node and Chrome; keep file paths generated from kind and UUID, never supplied names. `DIAGNOSTIC_CHUNK_BYTES=2097152`, `DIAGNOSTIC_MAX_BYTES=268435456`, `DIAGNOSTIC_READ_BYTES=32768`. - [ ] **Step 4: Run the focused test and typecheck.** Both `node --import tsx --test tests/screenshot-diagnostics-contract.test.ts` and `npm run typecheck` pass. - [ ] **Step 5: Commit.** `git add src/shared/screenshot-diagnostics.ts tests/screenshot-diagnostics-contract.test.ts && git commit -m "Add screenshot evidence byte contract"`. ### Task 2: Authorized chunk upload and immutable commit **Files:** Create `src/shared/contracts/domains/diagnostics.ts`, `src/server/diagnostic-evidence.ts`, `tests/diagnostic-evidence-upload.test.ts`, `tests/diagnostic-evidence-postgres.test.ts`; modify `src/shared/contracts/{registry,scopes,output-common}.ts`, `src/server/{migrations,operations,thread-deletion,thread-move,index}.ts`, `src/server/auth.ts`, `package.json`, and generated `docs/generated/operations.md`. **Interfaces:** `diagnostics.begin({threadId,revision,evidenceId,idempotencyKey,sourceUrl,startedAt})` returns a pending evidence summary. `diagnostics.putChunk({evidenceId,fileId,sequence,sha256,contentBase64})` accepts at most 2 MiB decoded bytes and returns the stored digest. `diagnostics.finalize({evidenceId,manifest,idempotencyKey})` verifies every listed part and total before making the manifest immutable; it returns the committed summary and updated thread. A v25 `diagnostic_evidence` row holds project/thread/owner/status/summary/manifest; `diagnostic_chunks` rows hold file/sequence/hash/size/private key, unique by `(evidence_id,file_id,sequence)`. `drainExpiredDiagnosticEvidence(db,store)` marks pending artifacts older than 24 hours expired under a row lock before removing their objects through the existing bounded maintenance loop; thread deletion enqueues all committed and pending chunk keys, and thread move updates evidence project ownership. A retry after expiry starts a new evidence ID on the same thread. - [ ] **Step 1: Write failing upload tests.** `tests/diagnostic-evidence-upload.test.ts` asserts begin requires thread write access, a new explicit scope and matching page origin; an old paired extension key is denied. Same-hash retry is idempotent, different-hash retry conflicts; missing/extra chunks, 256 MiB overflow, concurrent finalize and a changed project/account do not commit. Raw sample `Cookie`, `Authorization` and `password` bytes survive unchanged in private storage while summary omits them. `tests/diagnostic-evidence-postgres.test.ts` exercises v25 migration, conflicting concurrent finalize, 24-hour pending cleanup, thread deletion object receipts and project move authorization against native PostgreSQL; add it to `npm run test:postgres`. Core assertions: `assert.equal(await countStoredChunks(evidenceId), 1)` after an identical retry; `await assert.rejects(conflictingRetry, {code: "CONFLICT"})`; `assert.deepEqual(await store.get(key), rawBytes)`. - [ ] **Step 2: Run focused tests to confirm failure.** `node --import tsx --test tests/diagnostic-evidence-upload.test.ts` fails on missing operations. - [ ] **Step 3: Add migration and upload operations.** Use object keys under the existing private project prefix with evidence/file/sequence/hash components. Recheck authorization after object writes before SQL commit. Store pending rows so upload failure is visible; enqueue eventual cleanup for expired pending rows and deleted threads. Add only the three upload operations to registry/scopes at this stage. - [ ] **Step 4: Verify focused tests and native migration.** Run the focused test, `npm run docs:generate`, `npm run typecheck`, `npm run check:harness` and `npm run test:postgres`; all pass. - [ ] **Step 5: Commit.** Commit the contract, migration, server and test files as `Add private screenshot evidence upload`. ### Task 3: Bounded reads and streamed full download **Files:** Create `src/server/diagnostic-archive.ts`, `tests/diagnostic-evidence-read.test.ts`, `tests/diagnostic-archive.test.ts`; modify `src/server/{diagnostic-evidence,app,thread-read-model}.ts`, `src/shared/contracts/domains/diagnostics.ts`, `src/shared/contracts/{registry,scopes,output-common}.ts`, `src/shared/agent-workflow.ts`, `src/shared/operation-descriptions.ts`, and generated `docs/generated/operations.md`. **Interfaces:** `diagnostics.list({threadId,offset,limit})` pages summaries. `diagnostics.describe({evidenceId,offset,limit})` pages file metadata and channel coverage. `diagnostics.read({evidenceId,fileId,sequence,byteOffset,limitBytes})` returns `{byteLength,encoding,text?,dataBase64?,next?}` with at most 32768 decoded bytes; UTF-8 text is returned only at valid boundaries, otherwise base64. `next` carries `{sequence,byteOffset}`. `GET /api/diagnostics/:id/files/:fileId/chunks/:sequence` streams one authenticated binary chunk; `GET /api/diagnostics/:id/archive` streams generated-name `.tar.gz` with manifest, coverage and hashes. The optional `thread.diagnosticEvidence` field has count and up to three newest small summaries; legacy `thread.diagnostics` stays intact. - [ ] **Step 1: Write failing read and archive tests.** Assert cross-project and scope denial for each operation and route; a 5 MiB+ file reassembles by MCP pages and archive with identical SHA-256; `diagnostics.describe` paginates thousands of file entries; a binary body is base64, not lossy text; a malicious original filename cannot escape the archive; a pending upload is visibly pending but unreadable as complete. An old thread's legacy `diagnostics` field and v1 recording summary remain readable. Core assertions: `assert.equal(sha256(reassemble(pages)), manifest.files[0].sha256)` and `assert.equal(sha256(extractTar(archive, generatedName)), manifest.files[0].sha256)`. - [ ] **Step 2: Run the two focused test files and confirm failure.** `node --import tsx --test tests/diagnostic-evidence-read.test.ts tests/diagnostic-archive.test.ts` fails before code. - [ ] **Step 3: Implement read contracts and streams.** Reuse `threadRow` authorization and explicit scopes on every request; fetch only the selected 2 MiB object for MCP paging. Produce tar headers from generated file names, pipe through `createGzip()`, and honor response backpressure. The route accepts browser session or bearer credentials without redirects or signed-object URL exposure. - [ ] **Step 4: Verify focused tests and harness.** Run the two test files, `npm run docs:generate`, `npm run typecheck`, `npm run check:harness` and `npm run test:postgres`; all pass. - [ ] **Step 5: Commit.** Commit as `Add bounded diagnostic reads and archive download`. ### Task 4: Raw CDP channel without changing recordings **Files:** Create `extension/diagnostics/raw-debug.js`, `extension/diagnostics/evidence-store.js`, `tests/diagnostic-raw-debug.test.ts`; modify `extension/session/session-coordinator.js`, `extension/background.js`, extension packaging/fixture allowlists in `scripts/package-extension.mjs` and `scripts/qa/extension/`. **Interfaces:** `createRawDiagnosticCapture({tabId,sourceOrigin,store,debuggerSource})` exposes `start()`, `stop()` and `status()`. `sessionCoordinator.subscribeRawDebugger(tabId, async (method,params,ingressAt) => {})` returns an unsubscribe function and taps events before the recording sanitizer. The tap feeds JSONL/network-body files into `putEvidenceChunk(evidenceId,fileId,sequence,bytes)` in IndexedDB. It shares an existing debugger attachment for the same tab, or attaches for explicit **Start diagnostics**; it never detaches a recorder it does not own. CDP request/ExtraInfo/response/failure, post/response bodies, WebSocket frames, EventSource messages, Runtime, Log, page lifecycle and attachable frame/worker events become raw evidence or a coverage reason. The collector never evaluates object getters or captured code. - [ ] **Step 1: Write failing collector tests.** Feed synthetic CDP events containing raw headers/cookies, structured console arguments, redirect, WebSocket frame and text/binary body; assert exact bytes and ordering. Test debugger attach failure, detach, a response body Chrome no longer exposes, inaccessible worker/frame, navigation outside approved origin, and a simultaneous session recording that continues undisturbed. Assert the extension chunk limit equals the shared contract constant. Core assertions: `assert.deepEqual(savedBody, rawBody)` and `assert.equal(recording.debuggerDetached, false)` when screenshot capture stops. - [ ] **Step 2: Run the focused test to confirm failure.** `node --import tsx --test tests/diagnostic-raw-debug.test.ts` fails. - [ ] **Step 3: Implement raw tap and chunk store.** Keep the recording path's existing sanitization and caps unchanged. Request `Network.enable` buffers of 256 MiB total and 64 MiB per resource for the explicit screenshot collector; a refused or unavailable body gets a coverage reason. Use `Network.getRequestPostData` and `Network.getResponseBody` when Chrome provides bytes, then stop at the 256 MiB evidence budget. IndexedDB stores binary `Blob`s and a manifest, not huge `chrome.storage.local` JSON. - [ ] **Step 4: Verify test, recording regression and packaged extension.** Run `node --import tsx --test tests/diagnostic-raw-debug.test.ts tests/recording-capture.test.ts tests/recordings.test.ts`, `npm run qa:recording-browser`, `npm run qa:extension-browser` and `npm run check:release`; all pass. - [ ] **Step 5: Commit.** Commit as `Capture raw screenshot debug evidence`. ### Task 5: Prepared DOM, storage and performance snapshot **Files:** Create `extension/capture/dom-stream.js`, `tests/diagnostic-dom-capture.test.ts`; modify `extension/capture/workflow.js`, `extension/content.js`, `extension/background.js`, `scripts/qa/extension/` and packaging allowlist. **Interfaces:** `capturePreparedDom({tabId,captureEpoch,evidenceId,store})` serializes the prepared top document's UTF-8 HTML through an isolated-world port with acknowledgements and <=2 MiB messages, plus accessible frames, open shadow roots, live form state, local/session storage, read-only IndexedDB and Cache Storage values where accessible, resource/performance timing and environment. CDP fills additional frame/DOM information when available. It returns file metadata and coverage, never an inline 5 MiB response. Its capture epoch and URL must match the screenshot draft; navigation, storage API failure or port loss records partial coverage. - [ ] **Step 1: Write failing capture tests.** A synthetic site produces >5 MiB HTML with a multibyte boundary and shadow root, then compares stored/reassembled SHA-256 to the prepared DOM; changed epoch/navigation cannot attach stale DOM. An inaccessible cross-origin frame, blocked IndexedDB/Cache Storage and storage exceptions have explicit partial coverage. Captured form and storage state retain raw values until the 256 MiB budget. Core assertions: `assert.equal(sha256(reassembledDom), sha256(preparedDom))` and `assert.equal(staleEpochResult.coverage.dom.status, "partial")`. - [ ] **Step 2: Run the focused test and packaged Chromium scenario to see failure.** Run `node --import tsx --test tests/diagnostic-dom-capture.test.ts` and `npm run qa:extension-browser`. - [ ] **Step 3: Implement port streaming and screenshot hookup.** Snapshot after `prepareCapture` hides Feedbacks controls and before native pixels, with capture guard checks before/after. Always take the point-in-time snapshot for eligible screenshots, even without prior Start; mark prior console/network history unavailable in that case. - [ ] **Step 4: Verify focused and packaged browser tests.** The test file and `npm run qa:extension-browser` pass; verify >5 MiB byte and hash equality in actual packaged Chromium. - [ ] **Step 5: Commit.** Commit as `Attach full prepared DOM to screenshot evidence`. ### Task 6: Settings, review, local download and reliable send **Files:** Create `extension/diagnostics/archive.js`, `tests/diagnostic-review.test.ts`; modify `extension/{options,editor,popup}.{html,js,css}`, `extension/background.js`, `extension/submission/workflow.js`, `extension/review/review-preferences.js`, and extension browser QA. **Interfaces:** `reviewDefaults.includeDiagnostics` defaults to `true`. A new draft copies that value once; editor override persists. `sendDiagnosticEvidence(draft,thread)` performs begin/putChunk/finalize with stable IDs and checksums, retaining local chunks until finalize succeeds. `downloadDraftDiagnostics(evidenceId)` streams a generated-name `.tar.gz` using browser `CompressionStream` and verifies source hashes. Editor preview is escaped and capped by channel; no raw body/DOM is rendered in full. - [ ] **Step 1: Write failing review tests.** Assert default checked, Settings-off new draft unchecked, open-draft override stable, unchecked send uploads zero diagnostic chunks and deletes local data after success/discard, and failed chunk/finalize leaves a retryable draft and a pending thread. After 24-hour server expiry, retry starts a new evidence ID on the same thread. An account switch cannot upload to the original thread. Preview renders `