feat(recapture): record the Transaction Record and Bound TLS Details#1999

draft50+
main
feat/1929-record-transaction-record
opened4d agobyMMatt Donovan
View on GitHubTwo-way sync enabledPR last imported 26m ago

Closes #1929

Summary

This is a draft that writes new facts into signed records, so it merges only after the release that carries the verifier for them; it is stacked on #1996, which merges first, and the web archive writer and the character-set rulings it builds on are already on the main line. A background capture now records every request and response the hidden browser reports while the page loads, including those made inside frames from other sites and by the page's workers and service workers, and stores them beside the capture as a standard web archive with the values of four sign-in headers replaced. The capture's signed record gains the archive's hash, counts of what the archive lacks, and the browser's summary of the secure connection of the page the capture shows, or the reason it has none; if recording fails, the capture is still stored and its record says the archive is missing and why. Following the maintainer's rulings of 26 October, the archive keeps a text body only when the browser could hand over the bytes the server sent, applies the per-file size limit itself, and drops the marker that called such bodies re-encoded, and that browser behaviour was checked again on the newer browser version the main line now uses. After review, it also bounds how much a page can make the app hold, counts a request that finished with no response on its own, cuts its last steps to the time left so a slow end never loses the capture, keeps the extra history entry of the blank first page as a recorded limitation, as the maintainer ruled, no longer takes the connection summary from a later navigation the browser cancelled, such as a download, and records that summary as absent when the page then moves to a page no request loads, such as going back to that blank page, as the maintainer ruled on 29 October.

Changes

Files changed, grouped by purpose
  • Recording: src/main/services/transactionRecorder.ts (new: per-session setup in D17's order with Network.configureDurableMessages, event recording, body fetches under the 50,000,000 total and 10,000,000 per-resource budget, the two bounded end phases, Bound TLS Details or the reason they are absent), src/main/services/backgroundRenderer.ts (about:blank first, recorder wiring, collection stops before the screenshot, end reserve 20 s to 35 s, #180 comment corrected), src/main/services/testCertificateAllowance.ts (new, test-only TLS leaf switch a packaged build ignores).

  • Ingest: src/main/services/recapture.ts (capture id chosen before the render, inventory part and Bound TLS fields), src/main/services/captureLifecycle.ts (writes {id}.warc.gz, signs transactionRecordHash, boundTls/boundTlsAbsent; a Duplicate's row mirrors transactionRecordHash), src/main/services/db/captureRepo.ts (InsertCaptureParams gains the three mirrors).

  • Package: lib/collect.ts and lib/types.ts (text bodies only from durable sessions; mainDocument is the main frame's last document that replaced the page, so not one the browser cancelled nor one with neither a response nor a failure; a request the browser reported finished with no response gets its own finishedWithoutResponse count, which the entry signs), lib/write.ts (marker and its warcinfo sentence removed, records compressed one at a time), the reader moved to lib/warcReader.ts behind a new warcReader.ts entry point.

  • Shared: src/shared/constants.ts (budget), src/shared/types.ts and src/renderer/lib/notify.ts (log code backgroundRenderer.transaction_record_failed).

  • Probe (D15): scripts/capture-probe/main.mjs and run.mjs, with the command and expected output in the header; no workflow change (the maintainer opens that PR, ADR-0048).

  • Tests: tests/main/services/transactionRecorder.test.ts and testCertificateAllowance.test.ts (new); backgroundRenderer, recapture, captureLifecycle and sharing/syncPeers tests extended; package collector and writer tests updated; e2e/recapture-transaction-record.spec.ts (new), e2e/recapture.spec.ts updated.

  • Docs: CONTEXT.md (Capture, Transaction Record, Bound TLS Details), website/content/docs/features.mdx (Capture, Integrity, Export), the plan, the D16 validation record, the charset spike's re-check line, and two blocking-tier rows in the evidence-affecting paths assessment.

  • Merges: #1979 (WARC writer), #1988 (charset spike), #1996's fix rounds, and origin/feat/1904-capture-engine-verifier at 083dba1b for the schema-6 finishedWithoutResponse field (#1978), which #1996 also carries at f7a6b98f; f75d9c92 takes #1996 at c2d35f66, which holds main at 2d5f1594 with Electron 45.0.0; 41650c09 merges origin/main at a6e392f2 (command palette fixes and the sharing early-access label), with no conflicts.

  • Fix round for the review of 2026-10-27: held events bounded (25,000 or 25,000,000 bytes of JSON, then the record is failed; Network.dataReceived never held); request bodies reserved before Network.getRequestPostData; response bodies keyed by session and request id (request bodies by request id alone); the record's fields parsed with the strict schema; each end phase cut to the time left with a 1 s margin; the failed-record notice logged from recapture.ts after the Capture is stored; the probe's on pass sends durable messages and auto-attach; the collector's finished-without-response state and count, signed; validation record, plan and features.mdx updated; warcio check 1.8.1 passed on every stored record.

  • Fix round for the review of 2026-10-28: mainDocument chosen as above, from seven navigation outcomes measured on Electron 45.0.0 and recorded in the validation record, pinned by tests, five of which fail on the earlier code (three collector tests, a recorder test and an e2e test), and a collector test that fails on the && to || mutant of its condition; a recorder test pins the session key of response bodies; the 11 MB upload that fails the record named in the validation record and features.mdx; the plan's Electron 45 re-check of Network.configureDurableMessages run and recorded; the probe header rewrapped at 100 columns.

  • Fix round for the review of 2026-10-29: boundTlsOf signs the main document's details only when its URL, without the fragment, equals the URL of the window's last did-navigate, which backgroundRenderer.ts passes as committedUrl; otherwise boundTlsAbsent is not-reported. Measured on Electron 45.0.0: history.back() to the blank first page and location.href = 'about:blank' send no Network event and report did-navigate with about:blank; a 204 and an attachment report none; history.pushState reports only did-navigate-in-page. Pinned by recorder tests for both blank-page cases and a download still open at stop, a boundTlsOf test and a renderer test, each failing with the comparison removed; a recorder test for a page that keeps its details, failing when the fragment is kept; and two e2e tests that failed on a build without the comparison. CONTEXT.md, features.mdx, the validation record and the collector comment now state the rule.

  • Judged differently: the stubbed setup failures are e2e tests that patch wc.debugger.sendCommand from the main process, not a production switch; a Duplicate's row leaves bound_tls and bound_tls_absent empty, because D11 and the Duplicate's own entry carry neither; Network.enable still carries no maxPostDataSize, since dropping the event's copy of an upload changes what the collector's multipart path reads and needs its own measurement.

  • Report wording (2026-10-30, maintainer's ruling): src/main/services/reportHtml.ts describes not-reported as no security details from the browser, or a window that ended on a page the main document's response did not load; the signed value is unchanged, so released verifiers still read it.

Evidence-affecting

Yes

What could change for evidence, and how that is checked

Evidence impact

  • Evidentiary result or interpretation that could change: acquisition and the signed entry. A background Capture gains a Transaction Record file, transactionRecordHash, the Transaction Record part of inventory, and boundTls or boundTlsAbsent; the row mirrors them; Package Verification, verify.sh, sync and Duplicate now meet real records (steps 1 to 4). boundTls comes from the main frame's last document that replaced the page, and only when that document's URL, without the fragment, is the URL the window last committed; otherwise boundTlsAbsent is not-reported. Before the 2026-10-28 round a later navigation the browser cancelled, such as a 204 or a download, could supply another site's details or a false not-https; before this round a page that went back to the blank first page, or opened about:blank, was stored under about:blank with the earlier page's details. The gate fired on the issue's evidence-affecting label and on blocking-tier paths (backgroundRenderer.ts, recapture.ts, captureLifecycle.ts, db/**, src/packages/transaction-record/**, and the two new files added to the tier).
  • What verification proves and does not prove after this change: the file's SHA-256 is signed, so a changed or missing record fails verification (unit test reads tampered; two-peer sync refuses an altered record). It does not prove the record is complete: the inventory counts what it lacks, and D17's two limits (a shared worker's requests, targets below a failed auto-attach) and in-page reuse leave no count. Bound TLS Details are the browser's summary and hold no certificate bytes. A request that finished with no response is a request record with no response, counted in finishedWithoutResponse. A page that causes more network events than the bound, or uploads about 11 MB in one body, gets no record, and its entry says failed.
  • Backward verification preserved: against #1996's head, this PR changes no verifier, schema, manifest or package-layout code; entries written before keep their bodies and chain hashes, and the full suite, frozen digests included, runs in the block below.
  • Known-answer tests: the writer pins the served windows-1252 bytes and their digest and a UTF-8 body with a byte-order mark; the e2e spec pins payload digests for gzip, plain, windows-1252, Shift_JIS, byte-order-mark and mislabelled UTF-8 bodies against the served bytes, posted windows-1252, Shift_JIS and UTF-8 request bodies against the bytes sent, both redirect kinds, every partial-exchange row, a cached response, a consent block, cookie removal, Bound TLS Details against a generated certificate, an https page that then navigates to an empty http answer and keeps its own Bound TLS Details, an https page that goes back to the blank first page and one that opens about:blank, each stored under about:blank with boundTlsAbsent not-reported, the proxy case, and a looping page past the event bound; all eight e2e tests passed on Electron 45.0.0 at a46cd9b6, and this round's five Bound TLS e2e tests passed on a build of the new head; warcio check 1.8.1 exits 0 on each stored record (run at 11fcd9c2, not rerun this round).

Verification

All pre-merge checks passed

pnpm preflight at 3987f069bbe8de48459450842cb53e8783ab9915 on Node v20.20.2; diff scored against origin/main (merge base a6e392f2f). Result: pass.

  • pnpm lint - pass (exit 0)
  • pnpm format:check - pass (exit 0)
  • pnpm lint:boundaries - pass (exit 0)
  • pnpm lint:agents-md - pass (exit 0) - CLAUDE.md and AGENTS.md match
  • pnpm lint:adr - pass (exit 0)
  • vale-prose --base - pass (exit 0) - 6 changed Markdown files checked
  • pnpm typecheck - pass (exit 0)
  • pnpm build - pass (exit 0)
  • pnpm build:extension - skipped - no extension/ changes
  • pnpm test:coverage - pass (exit 0) - 8490 passed, 25 skipped (8515), thresholds met
  • pnpm coverage:diff - pass (exit 0) - 98.30% of 352 changed lines covered (floor 90%); 2 changed source files not instrumented

Created with Claude Opus 5.5 in Claude Code (T3 Code)

Checks

1 failing · 19 successful · 9 skipped
Some checks failed

Failing1

3987f06

Added 6 commits

  • docs(capture-engine): state the Transaction Record and validate step 954e5fcf
  • test(sync): hash-check a Transaction Record between two peers97c5be2
  • chore(capture-probe): commit the probe and its two-pass checkb1b60d4
  • fix(db): check the two mirrored hashes on readb3a68d4
  • test(recapture): record a page, its frame and workers end to endb832603
  • chore(merge): take the step 6 fix round into step 9cad7986
Synced from GitHub

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

yaml
1reviews:
2 auto_review:
3 drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Written by claude-opus-5-5 via Claude Code on behalf of Matt.

Review verdict: request changes

  1. A page being captured can make the app's main process keep an unlimited number of network events: one test page produced about 88,000 in ten seconds and grew memory by 60 to 100 MB, and nothing stops it before the screenshot.
  2. The 50 MB limit is checked only after each uploaded request body has been pulled into the app, so a test page that sent twenty 9 MB uploads had 180 MB pulled in for 45 MB kept.
  3. Two of the maintainer's rulings are still owed: counting a request that finished with no reply as unfinished, and the extra history entry the blank first page gives every captured page.
  4. When recording fails, the operator is told the capture was stored before the app has stored it, so a capture that is then skipped or fails still shows that message.
  5. The separate web archive checker passed the stored records, and the credential headers, the test certificate switch and the session rules hold as described.
Full report

Reviewed commit: 54e5fcf65ffead2dec0aecab5c578fe2fa9de0c3 (base feat/1928-scroll-and-screenshot-record at 7d309557). Head re-read at the end of the review: unchanged.

Scope: the diff against a scratch merge of the base with origin/feat/1906-warc-writer (b4e11287) and origin/docs/1983-transaction-record-charset-spike (33ed5a01), built with git merge-tree (no worktree merge, so no rerere state). The plan conflicted in that merge; the head keeps every plan line the three branches added except the seven #1979 lines this step rewrote on purpose (the marker text).

#severityfile:linefinding
1blockingsrc/main/services/transactionRecorder.ts:128, :223, :257, :261Every Network event of every session is kept in events with no count or byte limit until stop(); collectExchanges and the writer then process all of it on the main thread. The body budget is the only bound.
2blockingsrc/main/services/transactionRecorder.ts:208-219 (check at :213)fetchPostData sends Network.getRequestPostData for every hasPostData request with nothing reserved and compares against the cap only after the bytes arrive, then keeps fetching and discarding once the cap is reached. Mutating + to - at :213 left every test green (Test reliability), so the post-data cap is untested.
3advisory (ruling owed)src/packages/transaction-record/lib/collect.ts:99; src/main/services/backgroundRenderer.ts:647D4's inFlight widened to finished-with-no-response, and the about:blank history entry. Facts below; not a code defect, but the merge waits on both.
4advisorysrc/main/services/backgroundRenderer.ts:734-741, src/renderer/lib/notify.ts:174-175, src/main/services/recapture.ts:205-216, :222-239The transaction_record_failed entry is toasted as "...; the capture was stored without them" from inside the render, before runJob decides anything. A failed record followed by a local-address refusal, a Case exclusion on the final URL, an ingest failure, or the deadline winning during the record phases tells the Operator a capture was stored when none was. Log after ingest, or reword.
5advisorysrc/main/services/transactionRecorder.ts:371-381; src/main/services/manifest.ts:1040-1083; src/shared/verify/manifestChain.ts:283-286boundTls reaches the signed entry with no strict-schema parse, and appendManifestEntry signs what it is given; an entry the schema refuses makes the whole Case chain read Invalid entry shape. Instance: an SCT timestamp after 9999-12-31 becomes +010000-... via toISOString, which z.iso.datetime() refuses (checked in Node). Not reachable on Electron 44.9.1 as measured: a server stapling a year-11476 SCT produced an empty signedCertificateTimestampList. Parse BoundTlsSchema before returning the outcome.
6advisorysrc/main/services/backgroundRenderer.ts:37-47No test pins the 35 s reserve: the mutant at :47 (+ to -, reserve 15 s) survived, and the "inside the Recapture timeout" test (tests/main/services/backgroundRenderer.test.ts:1271) runs with instant earlier phases. The 5 s and 10 s bounds are fixed, not clamped to the time left.
7advisoryscripts/capture-probe/run.mjs:2-4, scripts/capture-probe/main.mjs:131-136The probe's on pass sends only Network.enable; renders now also send Network.configureDurableMessages and Target.setAutoAttach with waitForDebuggerOnStart. The header says the pass works "as a background render does". The CI job the maintainer adds would not see an Electron change that makes those two commands visible to a page.
8advisorysrc/main/services/transactionRecorder.ts:196; src/packages/transaction-record/lib/collect.ts:145-154Bodies are keyed by request id alone, while the durable check reads the session of the chain's last loadingFinished. A request id finishing on two sessions would have the last-settled fetch's body judged by the other session's answer. Not observed: D17 and the spike saw each request finish on one session.
9advisorye2e/recapture-transaction-record.spec.ts:662Titled "holds neither the proxy's host nor its port"; proxy and target share 127.0.0.1, so it shows the port only (its own comment says so).
10advisorysrc/main/services/transactionRecorder.ts:217, :327, :192The two own lines diff coverage misses (post-data fetch failure; URL-parse fallback in boundTlsOf, also a surviving mutant), and a surviving boundary mutant at :192.
11advisorydocs/specs/2026-10-26-capture-engine-first-slice-validation-assessment.md, Known limitationsTwo entries can now be narrowed: warcio check passed (item 8 below), and on Electron 44.9.1 Network.getRequestPostData returned windows-1252 (636166e9) and Shift_JIS (93fa967b) bodies as base64 with the sent bytes, and a UTF-8 body as text whose UTF-8 encoding equalled the sent bytes.

Failure scenario for 1: a page runs while (true) await Promise.all(Array(50).fill().map(() => fetch('/x?' + Math.random()))). Measured with a standalone Electron 44.9.1 script that held events as the recorder does: about 14,500 requests and 87,692 to 89,173 Network events in 10 s, mean 2,216 bytes of JSON per event, heap +58 to 100 MB. The load, idle and scroll phases can run 85 s, then the collector builds an exchange per request and the writer serializes two records each, all in the app's main process, which also runs every Case and sync. A remote server with HTTP/2 can go faster than this loopback run.

Failure scenario for 2: a page posts a 9,000,000-byte body twenty times in sequence. Measured with the recorder's fetch-then-check logic: 180 MB delivered into the main process, 45 MB kept, 135 MB fetched past the cap and dropped, peak RSS +93 MB. With sixty concurrent posts Chromium itself held only five bodies per session (45 MB delivered, RSS +158 MB), but each frame and worker session has its own store. Fix: stop sending the command once the cap is reached, and reserve before sending where a length is known.

Item 1, the test certificate allowance. Holds. testCertificateAllowance() (src/main/services/testCertificateAllowance.ts:31-37) returns null unless app.isPackaged === false and BIRDBRAIN_E2E_TRUST_CERT_SHA256 is set, the same gate as CE-2's switch (src/main/services/localAddressPolicy.ts:14). isPackaged is false only under Electron's own executable; package.json sets no executableName override, so the packaged binary is birdbrain. Nothing in src/ writes process.env (searched for assignments), and the only caller is the render (backgroundRenderer.ts:621-622), so no setting or IPC reaches it. When installed it sits on the render's fresh in-memory partition (recapture-<uuid>, backgroundRenderer.ts:526), accepts only the leaf whose DER SHA-256 equals the pin, and returns -3 (Chromium's own verdict) for every other certificate, so even a development build with the switch set checks every other certificate normally. Tests: tests/main/services/testCertificateAllowance.test.ts:36-53, tests/main/services/backgroundRenderer.test.ts:1294-1314, and the e2e refusal without the switch (spec:629, passed here).

Item 2, credential values. Holds. The writer replaces every line of the four names, case-insensitively, in whichever header map it writes (ExtraInfo or plain) for requests, responses and redirect hops (src/packages/transaction-record/lib/write.ts:59-70, :137, :165; redirect hops take their response from collect.ts:167). The collector copies only headers from ExtraInfo events (collect.ts:277, :280-281), so associatedCookies, blockedCookies and exemptedCookies never reach an exchange; raw events stay in memory and are never serialized, and the log gets only the error. All sessions share that path. The stored e2e record holds 19 Cookie: [value replaced by Birdbrain] lines, one set-cookie line, and no COOKIE-SECRET-1929. The fixture's cookie is for 127.0.0.1, so the frame session (localhost) carried none; page, page worker and service worker sessions did.

Item 3, the spike rulings. Page: Network.enable, then Network.configureDurableMessages, then Target.setAutoAttach, each awaited up to 5 s (transactionRecorder.ts:268-283), as Recommendation 1 and D17 item 1 ask. Frame or dedicated worker: the three sent together, resumed once all answer or 2 s pass (:153-173). Service worker: the three, then the resume at once (:166). Recorded only when all answered (:169-170, :277-282). A text body is kept only if the session that reported loadingFinished answered the durable command; a base64 body from any session (collect.ts:145-154). Per-resource limit: no fetch when the summed dataLength exceeds 10,000,000 (:180), and a returned body over it is dropped (:192). The marker and its warcinfo sentence are gone (write.ts; absent from the stored record). Can a session whose command failed still contribute a re-encoded text body? Not on any measured path: its text bodies become body-omitted, and its base64 bodies are the served bytes (spike, Question 1). Two corners measurement has not excluded: finding 8, and a service worker resumed together with its commands, which counts durable if the command answered even if an early body predated it; the spike's service worker fetches of Shift_JIS and windows-1252 bodies returned the served bytes.

Item 4, facts for the maintainer.

(a) inFlight widened. collect.ts:99 now makes a request in-flight when loadingFinished arrived with no responseReceived. D4 (plan, The inventory) defines inFlight as neither loadingFinished nor loadingFailed arrived by the time collection stopped; the plan now notes the widening as awaiting a ruling. Seen once on the fixture: the service worker's own main script on its own session. Before the change the collector called it body-omitted, the writer threw for want of a response, and the whole record was failed. Consequences: the record holds a request record and no response for a script the server sent in full, so the worker's code is not in the record while its own fetches are; the signed count no longer separates "still loading when the capture was taken" from "finished but reported no response"; and the rule applies to any request reported that way, not only service worker scripts. The schema's closed set has no other state for it, so a separate count is a schema step. The validation record also notes the fixture's install fetch was changed to read its body, because a fetch that reports data and no loadingFinished counts inFlight too.

(b) The extra history entry. backgroundRenderer.ts:647 loads about:blank before the target because Network.enable gets no answer on a window that has not navigated. The page then reads history.length 2 where a direct visit reads 1 (probe run here: off 1, on 2, exit 0). Every background Capture from this build on differs from earlier builds in this page-visible fact, and nothing in the signed entry records it beyond toolVersion; a page that branches on history length can render differently, and the stored files show that state. features.mdx, the validation record and the probe's expected output state it. The renderer spike (Rulings this asks for, item 2) lists three choices: location.replace from the blank page (target reads 1, but the main document request carries Sec-Fetch-Site: cross-site instead of none); wc.navigationHistory.clear() after commit (keeps none, but the page reads 2 at parse, DOMContentLoaded and load); or keep wc.loadURL and record the limitation, which this build does.

(c) The reserve. The 120 s timeout (recapture.ts:75) is unchanged; the end reserve is 20 s artifacts + 5 s body fetches + 10 s writer = 35 s (backgroundRenderer.ts:37-47), so load, idle waits and Scroll-to-load share at most 85 s, down from 100 s, less the new page setup, which can take up to 15 s when the page's three setup commands do not answer. Scroll-to-load keeps its own 75 s cap, so a slow page reaches the time limit up to 15 s sooner; its signed Scroll-to-load record says how the phase ended, so the entry stays true, but the files can hold less lazy-loaded content. The two phase bounds are fixed: if the screenshot and MHTML take more than their 20 s share, a slow record phase carries the render past 120 s and nothing is stored, rather than storing with the record failed. Measured headroom (validation record): the writer took 1,397 ms at the 50 MB cap. The plan's Time item authorises raising the reserve; finding 6 says no test pins it.

Item 5. Strict schema: the stored e2e entries verify (verified for the main and TLS pages here), and the captureLifecycle tests parse; finding 5 is the gap. Ruling E: page Network.enable failing or silent gives an absent failed record and not-recorded (backgroundRenderer.ts:716-733, transactionRecordFields in recapture.ts); a body fetch that never settles fails the 5 s collect bound (transactionRecorder.ts:289) and a writer that throws or runs past 10 s (:366-370) both return failed (:382-384), and the Capture stores. A body fetch that rejects leaves that body omitted and the record present, as D17 item 6 has it. D17's counts are true on every path traced (page, frame, dedicated worker, service worker, other types, attaches after the stop); a child whose answers land after collection counts unrecorded, the safe direction, which the validation record states. The widened inFlight (4a) is the one count that departs from D4's text.

Item 6, Duplicates. Confirmed. D11: "It writes no boundTls and no boundTlsAbsent". The Duplicate's entry re-anchors inventory, scrollToLoad, transactionRecordHash and pdfHash with no Bound TLS fields (captureLifecycle.ts:1694-1703), its row mirrors transactionRecordHash only (:1747-1749), and the copied record is re-checked against the source hash first (:1638-1643).

Item 7, memory. Response bodies are bounded: four thirds of the summed dataLength, rounded up to four, is reserved before each Network.getResponseBody, and no fetch is sent past 50,000,000 (transactionRecorder.ts:178-206). In my runs dataLength equalled the served length for http, blob:, data: and cached loads, so the estimate held. Request bodies and events are not bounded (findings 1 and 2), so yes, a page can push the main process past the cap.

Item 8, warcio check. warcio 1.8.1 through uv, on the main fixture record the e2e stored (33,458 bytes, 30 requests, 24 responses, one warcinfo):

1$ uvx --from warcio warcio check f346f6ce-...-cf077613-2009-4f8f-b0c0-9c91c0ba0683.warc.gz
2(no output)
3exit 0
4$ uvx --from warcio warcio check -v <same file>
555 records: 54 "digest pass", 1 "no digest to check" (warcinfo); exit 0

The TLS, proxy and child-stub records each gave no output and exit 0 too.

Diff coverage. node scripts/diff-coverage.mjs --base origin/feat/1928-scroll-and-screenshot-record: 307 of 315 changed lines (97.46%), pass. Split against the scratch merge: 179 lines are this step's own, 177 covered (98.9%; misses transactionRecorder.ts:217, :327); 136 (43%) are #1979's, 130 covered, all six misses in lib/warcReader.ts (28-30, 36, 62, 116), #1979's test reader, which this step moved from the package's tests into lib/, where it is scored for the first time. Against origin/main: 531 of 539 (98.52%), as the PR body says.

CI at head. Green: lint, typecheck, build, test, e2e, docs-build, Test reliability and the rest. merge-gate fails with "evidence-affecting with no sign-off at head", which is the maintainer's step. Test reliability: 40 of 266 mutation sites probed, 5 survived (backgroundRenderer.ts:47; transactionRecorder.ts:192, :213, :327; lib/warcReader.ts:30); 10 touched tests pass with the source reverted (nine collector tests, one recapture test), each an earlier test given the new inputs, none claiming this step's behaviour.

Evidence gate. evidence-affecting is on the PR and on #1929. Evidence impact is substantive. The known-answer test is extended: the writer pins sha256:f92b1f90c50a0068a6c9dfce9c853e9333a8337b6475fab8a4393f1b1fccd319, which I recomputed as the SHA-256 of the windows-1252 bytes 3c703e636166e93c2f703e, and the collector tests exercise the durable rule. No verifier, schema or package-layout code changes against the scratch merge; the full suite (frozen digests included) passed.

Completion controls. Attribution: the body ends with the maintainer's byline, which the PR body check accepts (exit 0). No Co-authored-by on any of the 22 commits. Auto-merge: null.

Commands run (exit codes).

  • pnpm build:test-sharp 0, pnpm lint 0, pnpm typecheck 0, pnpm build 0
  • BIRDBRAIN_TEST_SYSTEM_SHARP=1 BIRDBRAIN_REQUIRE_OPENSSL=1 BIRDBRAIN_REQUIRE_JQ=1 pnpm test:coverage 0: 482 files passed, 2 skipped; 8,119 tests passed, 24 skipped
  • node scripts/diff-coverage.mjs --base origin/feat/1928-scroll-and-screenshot-record 0; pnpm coverage:diff 0
  • xvfb-run -a npx playwright test e2e/recapture-transaction-record.spec.ts e2e/recapture.spec.ts e2e/recapture-consent.spec.ts 0 (11 passed), then the first spec again 0 (6 passed), keeping each stored record
  • xvfb-run --auto-servernum node scripts/capture-probe/run.mjs 0, output as the header expects
  • uvx --from warcio warcio check 0 on four records
  • .claude/skills/post-pr-body/scripts/check.sh on the PR body 0
  • Five standalone Electron 44.9.1 scripts under xvfb-run, outside the repository: event volume, post data (concurrent and sequential), non-UTF-8 request bodies, dataLength for blob, data and cached loads, and a stapled SCT; each exit 0

Not checked. Windows and macOS; HTTP/2, HTTP/3 or a remote server; the SCT path with a publicly trusted certificate; Vale, format and package boundaries locally (CI's docs-build and lint passed).

Added 3 commits

  • feat(verify): count requests that finished with no response30605f8
  • docs(capture-engine): define the finished-without-response count083dba1
  • fix(transaction-record): count a response-less finish on its ownbaeaad7

Written by claude-opus-5-5 via Claude Code on behalf of Matt.

Maintainer rulings of 2026-10-26 on the three questions the review above leaves owed.

  1. A request the browser reports finished with no response gets its own count, not the unfinished count: schema 6 gains a finished-without-response count in the network record's inventory (added to #1978), and this step writes it.
  2. The extra history entry from the blank first page stays, recorded as a known limitation in the validation record and the features page.
  3. The 120 second capture limit and the 35 second reserve stay, and each recording phase shrinks to the time left, so an overrun marks the network record failed and the capture is still stored.
Where each is applied

Ruling 1: #1978 at 083dba1b (schema, plan D4 and D8, glossary); this pull request's fix round writes it. Rulings 2 and 3: this pull request's fix round. The run log is docs/plans/2026-10-26-capture-engine-programme-run.md.

Added 8 commits

  • chore(merge): take the finished-without-response count from #19789b9ec9e
  • fix(recapture): bound held events and reserve request bodiese72fe12
  • fix(recapture): cut record phases to the time left, notify after storing74d1558
  • fix(capture-probe): send the page setup a render sends8abac6b
  • chore(merge): take the finished-without-response count into step 97c06ca3
  • feat(recapture): sign the finished-without-response countc1c5009
  • test(recapture): pin the event bound, request bodies and new count e2e3b23dee
  • docs(capture-engine): record the step 9 review rulings and results50f9a35

Written by claude-opus-5-5 via Claude Code on behalf of Matt.

Dispositions for the review verdict above, at the latest commit, after the maintainer's rulings of 2026-10-26.

  1. Network events held without limit: applied; past 25,000 held events or 25,000,000 bytes of them, the network record is marked failed and the capture still stores, and the recorder no longer holds events the collector does not read.
  2. Uploads fetched before the size check: applied; each upload is checked against the cap before it is fetched.
  3. The owed rulings: applied as ruled; the finished-without-response count is written, and the extra history entry is documented as a known limitation.
  4. The notice sent before storing: applied; the notice now follows the job's outcome.
  5. Signing without a schema check: applied; the entry is checked against the strict schema before it is signed.
  6. The reserve and the time cut: applied and pinned by tests; each recording phase shrinks to the time left.
  7. The probe's recording pass: applied; it sends what a real render sends.
  8. Bodies keyed by request alone: applied; they are keyed by session and request.
  9. The proxy test title: applied.
  10. The uncovered lines: applied.
  11. The two limitations the record overstated: applied; the archive tool's check passed on all five stored records, and request bodies in other character sets are stored as the bytes sent.
Commits

e72fe128 (findings 1, 2, 5, 8 and 10), 3b23deec (findings 1, 9 and 11), baeaad78, 7c06ca3f, c1c50096 and 50f9a359 (findings 3, 8 and 11), 74d1558c (findings 4 and 6), 8abac6bf (finding 7)

Added 20 commits

  • chore(merge): take the finished-without-response count from #1980fd542de
  • chore(merge): take the finished-without-response count from #1982144340c
  • chore(merge): take the finished-without-response count from #1984e46f9d3
  • feat(report): list the finished-without-response count19267c2
  • chore(merge): take the finished-without-response count from #1985f7a6b98
  • chore(merge): take the stacked count merges from #1996 into step 911fcd9c
  • test(export): pin the per-file gate for an entry anchoring one file74a1b8a
  • chore(merge): take the per-file gate test from #19848a2bae8
  • test(export): pin the chain gate on capture engine fields in an exporte1dcee4
  • test(report): pin capture engine fields from Shared Case member chainsc7219ba
  • chore(merge): take the capture engine field tests from #198584cf6c2
  • fix(recapture): lift no scroll lock on a page that starts at its end10b4aa9
  • test(recapture): pin the PNG checks, a first-row trim and a zero height51e32bb
  • docs(capture-engine): say a height that changes every step never stalls92b6453
  • fix(report): say the end height is the scrolled root's height7621203
  • docs(capture-engine): record the scroll lock run before step 67b449b2
  • docs(capture-engine): record the scroll lock on pages with a doctypefbe12b3
  • test(recapture): pin no end height after an unrecordable last height8c61fd8
  • docs(capture-engine): limit the body claim to standards mode15024fe
  • docs(capture-engine): say an unsafe integer height is left out too038d6c1
Synced from GitHub

Review verdict: request changes

  1. The description still says a request the browser finished with no response is counted as unfinished, but the code now counts it on its own and signs that count, so the description contradicts the signed record.
  2. Today's maintainer ruling asks this step to re-check, on the newer browser version the main line now uses, the browser command it relies on for text bodies; the change neither does that nor says it is left for later, though my own run on the newer version found the command still returns the bytes the server sent.
  3. One upload of about 11 MB, or two of about 6 MB, while a page loads now makes the whole network record fail, because the browser copies each upload into its event twice; the documents describe that failure only for pages that make too many requests.
  4. The branch no longer merges cleanly with the branch it is stacked on; the one conflict is in the validation write-up.
  5. The four earlier blocking points are fixed, each fix fails its own tests when I undo it, and the checks pass apart from the expected missing sign-off.
Full report

Reviewed commit: 11fcd9c2d2647dfd36f28f894757d60158cc0226 (delta from 54e5fcf65ffead2dec0aecab5c578fe2fa9de0c3; base feat/1928-scroll-and-screenshot-record, at 8c61fd85 when read). Head re-read at the end of the review: unchanged.

Scope of this round: the seven own commits baeaad78, e72fe128, 74d1558c, 8abac6bf, c1c50096, 3b23deec and 50f9a359, plus the conflict resolutions of the merges 7c06ca3f and 11fcd9c2 (git show --remerge-diff). Both resolutions keep the step 9 text and take #1978's finishedWithoutResponse definitions; nothing was dropped.

#severityfile:linefinding
1blockingPR body, Changes, "Package" bulletSays "a finished request with no response counts as in flight". The code counts it as finished-without-response (src/packages/transaction-record/lib/collect.ts:100, :337) and signs it (src/main/services/transactionRecorder.ts:506). The Evidence impact section says the opposite of this bullet.
2blockingPR body, Summary; docs/plans/2026-10-10-capture-engine-first-slice.md:1250-1259#1988 on main (14bf7977) rules that step 9 re-checks Network.configureDurableMessages on Electron 45.0.0 before relying on it. This branch is on Electron 44.9.1 (package.json), its plan copy predates that sentence, and neither the body nor the plan does the re-check or defers it.
3advisorysrc/main/services/transactionRecorder.ts:176-178, :211-212; docs/specs/2026-10-26-capture-engine-first-slice-validation-assessment.md:179; website/content/docs/features.mdx:39; PR body, Evidence impactNetwork.enable is sent without maxPostDataSize, so requestWillBeSent carries each byte-array or string upload as postData and again as base64 postDataEntries. One upload of 11,000,000 bytes makes an event of 25,667,664 JSON characters, past the 25,000,000 bound, so the record is failed. At 54e5fcf6 that page got a record with the body over the per-resource limit. The three documents name only "more requests".
4advisorydocs/specs/2026-10-26-capture-engine-first-slice-validation-assessment.mdgit merge-tree of head with the base at 8c61fd85: one content conflict, Deviations (this step's step 9 bullets against the base's new scroll-lock table). Both sides append, so the resolution keeps both.
5advisorysrc/main/services/transactionRecorder.ts:169The session-keyed body map (finding 8 of round 1) is not pinned: changing bodyKey to ignore the session leaves all 50 recorder tests green. The collector's own check (collect.ts:154-159) still drops a body from the wrong session (its mutant is caught), so the effect is a kept body that becomes omitted, never the reverse.
6advisorysrc/main/services/transactionRecorder.ts:202Test reliability survivor: deleting responseBodies.clear() in release() survives, so no test shows held bodies are released on overflow. collect throws first (:396), so no evidence effect. Only memory is affected.
7advisorysrc/packages/transaction-record/lib/collect.tsThe #1988 ruling that the page's copy of a response its service worker answered is body-omitted holds only because Chromium returns no body for it; the collector does not read fromServiceWorker. Measured on 44.9.1 and 45.0.0 with a durable page session: both a respondWith(fetch()) and a new Response(...) answer give "No data found for resource with given identifier". The e2e spec pins the first (e2e/recapture-transaction-record.spec.ts:464-467).
8advisoryPR body, Changes, "Merges" bulletSays 083dba1b was merged "ahead of #1996 taking it"; #1996 at f7a6b98f now carries it (merge 11fcd9c2).
9advisoryscripts/capture-probe/run.mjs:5A changed comment line of 143 characters, past the 100-character width.

Corrected wording.

  • 1: replace "a finished request with no response counts as in flight" with "a request the browser reported finished with no response gets its own finishedWithoutResponse count, which the entry signs".
  • 2: either run the step's e2e on Electron 45.0.0 and record it, or add to the body's Summary and the plan's step 9 "Text bodies" item: "The charset spike ran on Electron 44.9.1, and this branch's base still pins it, so the re-check of Network.configureDurableMessages on Electron 45.0.0 that the ruling asks for is left to the point this stack takes main; this step's e2e known answers for windows-1252 and Shift_JIS bodies are where a change would fail."
  • 3: in the validation record's step 9 limitation, write "A page that makes more requests than the event bound holds, such as one fetching in a loop, or that uploads about 11 MB in one body while it loads (the browser puts each upload in its event twice), gets no Transaction Record at all". Make the same change in features.mdx ("because the page made more requests, or larger uploads, than the record holds") and in the Evidence impact bullet ("more network events, or larger uploads, than the bound"). Sending maxPostDataSize with Network.enable is the alternative: with maxPostDataSize: 1 the event carried neither copy in my run. That changes what the collector's multipart path reads, though, so it needs its own tests.

Rulings of #1988 on main, against this branch.

RulingHere
Send Network.configureDurableMessagesDone, page and children (transactionRecorder.ts:381, :247)
Keep a body returned as a string only from a durable sessionDone (collect.ts:154-162); the PR's spike copy says "text body", same meaning, base64 from any session
Apply the per-resource limitDone (fetchBody and fetchUnderBudget)
Service-worker-answered page copy is body-omittedHolds by browser behaviour (finding 7), pinned in e2e
Remove x-birdbrain-decoded-textDone; no occurrence in src/ outside the writer test that checks its absence
Re-check the command on Electron 45Not done, not deferred (finding 2)

The body states three of these (text bodies, per-file limit, marker) and does not claim the other three. Nothing in the body or plan contradicts a ruling. A trial merge with main conflicts in CONTEXT.md, the plan and the spike page, all docs.

My Electron 45 check (the cached official electron-v45.0.0-linux-x64.zip, Chromium 156.0.8078.48, standalone script under xvfb-run). The page loaded about:blank, then received Network.enable with the recorder's limits, then the target, which fetched windows-1252, Shift_JIS and UTF-8 bodies and framed a windows-1252 document. Without durable messages the windows-1252 fetch, the Shift_JIS fetch and the frame came back as UTF-8 text that did not match the served bytes. With durable messages answered, all four returned the served bytes (the three non-UTF-8 ones as base64). Electron 44.9.1 gave the same. This is narrower than the spike's 23 responses and is not the e2e run.

Earlier blockers, each fix mutated (unit tests of the touched file).

Round-1 findingFixMutantResult
1 events unboundedhold() count bound :212count check removed3 tests fail
1byte bound :212byte check removed1 fails
1late target not set uphold dropped from attach path8 fail
2 post data fetched before checkreservation in fetchUnderBudgetreservation check removed3 fail
2unknown length reserved at the limitestimate returns 02 fail
2declared length over limit not fetchedcheck removed1 fails
3 owed rulingscollector state collect.ts:100returns in-flight1 fails
3count collect.ts:337constant 01 fails
3signed field :506line removed2 fail
4 notice before storinglog after ingest, recapture.ts:286log moved before the refusal check3 fail
5 strict schemaBoundTlsSchema.parseparse removed1 fails
6 reserve and cutMath.min :491 / :495each removed2 / 1 fail
6renderer wiring timeLeftMsremoved / margin 02 / 2 fail
8 bodies by sessioncollector check collect.ts:154-159forced true1 fails
8recorder key :169session ignoredsurvives (finding 5)

Round-1 items 7 (probe), 9 (test title), 10 (coverage) and 11 (limitations) are applied as the dispositions say. The probe's on pass now sends the three commands and its run here printed "No difference beyond the about:blank history entry." For the e2e request bodies, warcio check was not re-run this round.

Evidence gate. evidence-affecting is on the PR and on #1929. The delta touches blocking-tier paths (backgroundRenderer.ts, recapture.ts, transactionRecorder.ts, src/packages/transaction-record/**, db/captureRepo.ts). The Evidence impact section is substantive, apart from finding 3's understatement. Known-answer coverage of c1c50096: the recorder test pins the full inventory part including finishedWithoutResponse: 0 and a case signing 1. The e2e pins finishedWithoutResponse: 1 and inFlight: 1 in the stored entry, which then verifies. Backward verification: against the base there is no change to src/shared/schemas.ts, src/shared/verify/** or manifest.ts. The field and its schema tests come from #1978 through the base, and the schema-6 tests pass here (39 and 5). Schema 6 is unreleased, so no stored entry is reinterpreted.

CI at head (gh pr checks 1999). Green: lint, typecheck, build, test, e2e, docs-build, Test reliability (probe), adr-numbers, hunks, label, security jobs. merge-gate fails for the missing sign-off, as expected. agent/pre-pass is pending (this review). Diff coverage in the test job: 357 of 363 changed lines (98.35%) against the base. The six misses are lib/warcReader.ts 28-30, 36, 62, 116, unchanged from round 1. Test reliability: 4 of 40 sites survived. :135 is an equivalent mutant (a falsy return in answered). :202 is finding 6. collect.ts:311 predates this delta. warcReader.ts:30 also predates it. 42 touched tests pass with the source reverted, all older collector tests or negative recapture cases; my placement mutant shows the latter catch a misplaced notice.

Completion controls. Body ends with the maintainer's byline, .claude/skills/post-pr-body/scripts/check.sh exit 0. No Co-authored-by in the 31 commits ahead of the base. Auto-merge null.

Commands run (exit codes). Scratch worktree at head, Node 20 via mise, BIRDBRAIN_TEST_SYSTEM_SHARP=1.

  • scripts/setup-worktree.sh 0; env -u PKG_CONFIG_PATH pnpm build:test-sharp 0; pnpm build 0
  • pnpm test per file, each 0: transactionRecorder (50), recapture (40), backgroundRenderer (65), collector (62), writer (38), manifestSchema6 (39), manifestSchema6TooOld (5), captureLifecycle (121)
  • 18 mutants (the table, plus a second run of the collector-check mutant against the recorder tests), each reverted with git checkout
  • xvfb-run -a npx playwright test e2e/recapture-transaction-record.spec.ts: 6 passed; the known-answers test failed once in fixture setup (the app's first window did not open within 30 s, before any test code) and passed on rerun alone, 0
  • xvfb-run --auto-servernum node scripts/capture-probe/run.mjs 0
  • git merge-tree --write-tree with the base: 1 conflict; with main at a0d938b1: 3 doc conflicts
  • Standalone Electron scripts outside the repo, 44.9.1 and 45.0.0, each 0: upload event shapes (9 MB and 11 MB, bytes, string, Blob, FormData; with and without maxPostDataSize), durable bodies, service-worker page copies

Not checked. The full suite with coverage locally; I used CI's test job. The e2e spec on Electron 45, which needs the app rebuilt against it. warcio check this round. Windows and macOS. HTTP/2 or a remote server.

Added 1 commit

  • chore(merge): take main after #1984 and #1985 squash-mergedf56233b
Synced from GitHub

Review verdict: request changes

  1. When a page starts a second navigation that never replaces it, such as a download or an empty reply, the signed record takes its connection details from that navigation and can name another site's certificate.
  2. No unit test fails if a later request from the page's own frame is chosen as the page, so the field that carries those signed connection details is untested on that point.
  3. The description says a request that finished with no response counts as unfinished, but the code counts and signs it separately.
  4. The branch conflicts with main in 16 files, and main's plan asks this step to re-check text bodies on Electron 45 first, so the tree that lands needs a fresh review.
  5. One upload of about 11 MB makes the whole network record fail, and the documents name only too many requests as the cause.
Full report

Reviewed commit: 11fcd9c2d2647dfd36f28f894757d60158cc0226

#severityfile:linefinding
1blockingsrc/packages/transaction-record/lib/collect.ts:309-313; src/main/services/transactionRecorder.ts:432-448mainDocument is the main frame's last Document request by start time, whatever state it ended in, and boundTlsOf checks only the protocol and cached. A later navigation that never commits (a 204, a download, an aborted load, or one in flight at stop()) supplies the signed boundTls, or a false not-reported or not-https. The code comment "the last one is the page the artifacts show", features.mdx:52 and CONTEXT.md:106 are therefore false.
2blockingcollect.ts:311Changing && to || on this line leaves every test green: CI Test reliability job 122003768934, and the reviewer's rerun (collector 62/62, recorder 50/50). With that change, any later main-frame request, such as a script from a content delivery network, becomes the main document and its TLS details are signed.
3blockingPR body, Changes, "Package" bullet"a finished request with no response counts as in flight" contradicts collect.ts:99-101, :337, the signed field at transactionRecorder.ts:506, and the body's own Evidence impact section.
4blockingwhole PRgit merge-tree origin/main 11fcd9c2 lists 16 conflicting files: 6 source (db/captureRepo.ts, manifest.ts, lib/write.ts, constants.ts, schemas.ts, verify/manifestChain.ts), 7 test and 3 documents. Main's plan (lines 1257-1259) and charset spike (line 760) say step 9 re-checks Network.configureDurableMessages on Electron 45.0.0. The branch pins ^44.9.1, and the PR neither does the re-check nor defers it.
5advisorytransactionRecorder.ts:176-179, 209-212; features.mdx:38-39; validation record line 179Network.enable is sent without maxPostDataSize. Measured on Electron 44.9.1: one 11,000,000-byte upload produces an event of 25,667,654 JSON characters, past the 25,000,000 event-byte bound, so the record is failed.
6advisoryPR body, "Fix round" bullet"bodies keyed by session and request id" is true only for response bodies (:169, :313). Request bodies are keyed by request id alone (:185, :327).
7advisorytransactionRecorder.ts:120-127, 493-497When a phase runs out of time, withinMs only stops waiting. The writer (up to 50 MB of compression) and pending body fetches keep running on the main process after the record is marked failed.
8advisorytransactionRecorder.ts:202, :135Two more mutants survive: removing responseBodies.clear() (memory only, since collect throws first) and an equivalent mutant.
9advisoryPR body, "Merges" bulletThe bullet says 083dba1b was merged "ahead of #1996 taking it", but #1996 already carries it at f7a6b98f.

Scope: the reviewer reviewed the PR's own changes over its stack, git diff f7a6b98f 11fcd9c2, where f7a6b98f is in #1996's head f56233be, and the writer against #1979's head 93b5e0e5. CI at this sha has no failing or pending check.

Failure scenario for 1: the reviewer ran Electron 44.9.1 under xvfb-run. Page /a loads, then sets location.href to /nocontent (204) or /dl (sent as an attachment). The window stays on /a with title A. The second request reports a response and then loadingFailed with net::ERR_ABORTED, canceled: true. Fed through collectExchanges and boundTlsOf in a temporary test, since deleted, mainDocument was the cancelled request. With https URLs and per-host securityDetails, the signed result was boundTls.subjectName: other.example for a capture of shown.example. collector.test.ts:972-1014 covers only a last document that committed. One possible fix is to pick the committed document, or to exclude cancelled, failed and in-flight, and pin both with tests.

Evidence gate: the PR and #1929 both carry evidence-affecting. Blocking-tier paths touched are backgroundRenderer.ts, recapture.ts, captureLifecycle.ts, db/captureRepo.ts, src/packages/transaction-record/**, and the two new tier rows. The Evidence impact section is substantive, but finding 1 makes its Bound TLS description inaccurate. The known-answer tests cover the body path; none covers which response the Bound TLS Details come from. The PR's own changes leave schemas.ts, src/shared/verify/**, manifest.ts and the layout package untouched, and the verify tests pass (472 passed, 21 skipped).

Verify loop (Ubuntu runner, Node 20.20.2, scratch worktree at the sha): pnpm lint, pnpm typecheck, pnpm build, pnpm test:coverage (8149 passed, 24 skipped), pnpm coverage:diff (98.98%, 583 of 589 changed lines) and pnpm lint:boundaries all exit 0.

Completion controls: no Co-authored-by trailer in the 76 commits; the body passes check.sh --any-author; auto-merge is off.

Not checked: the e2e specs locally and on Electron 45, whether a response can finish without Network.dataReceived (which would skip the body reservation), warcio check, and Vale on the changed lines.

Added 9 commits

  • Merge branch 'main' into feat/1928-scroll-and-screenshot-record7de1262
  • chore(merge): take main after the Shared Cases top bar and dashboard63ea04c
  • fix(e2e): open the blank new-Case form in the Recapture helperc2d35f6
  • chore(merge): take #1996 and main's Electron 45 into step 9f75d9c9
  • fix(transaction-record): sign the TLS details of the page shown217de82
  • test(transaction-record): pin the session key of response bodies604e086
  • docs(transaction-record): name the upload size that fails the record5c24394
  • docs(plans): record the durable messages re-check on Electron 45cfb3652
  • style(capture-probe): wrap the header comment at 100 columnsa46cd9b
Synced from GitHub

Dispositions for the review verdict above, at the latest commit.

  1. Later navigation supplying the signed connection details: applied. The page is now the main frame's last navigation that replaced it, measured on the newer browser for seven outcomes, and the glossary, the features page and the code comment say so.
  2. No test failing when a later request of the page's frame is chosen: applied. A collector test fails on that operator change, and five new tests fail on the earlier code.
  3. The description's wording on a request that finished with no response: applied. It now says such a request has its own signed count.
  4. Conflicts with main and the re-check on the newer browser: applied. The branch took #1996 and, through it, main with the newer browser; the step's end-to-end spec passed all eight tests on that browser, and the plan, the charset spike and the validation record carry the result.
  5. One 11 MB upload failing the record: Not applied: the setup command still carries no upload size limit, because dropping the event's copy of an upload changes what the collector reads for multipart bodies and needs its own measurement; the documents now name that upload size, as measured on the newer browser.
  6. The description's wording on how bodies are keyed: applied. It now says response bodies are keyed by session and request, and request bodies by request alone.
  7. A phase that runs out of time does not stop its work: Not applied: the record is already marked failed and the late result is discarded, so no evidence changes; stopping the writer and the pending fetches needs a cancellation path through the writer package, which I name as a follow-up candidate.
  8. Two surviving mutants: Not applied: removing the clear of held bodies changes only memory, since collection throws on the overflow first, and emptying the catch block that answers false is equivalent, because every caller tests the answer for truth only.
  9. The description's merge bullet: applied. It now says #1996 also carries that merge.
Commits and measurements
  • 1 and 2: 217de821 (replacedThePage in src/packages/transaction-record/lib/collect.ts; tests in collector.test.ts, tests/main/services/transactionRecorder.test.ts and e2e/recapture-transaction-record.spec.ts; CONTEXT.md, features.mdx and the validation record's step 9 Deviations table). The e2e test failed on a build of the earlier collect.ts (entry.boundTls undefined) and passed on the fixed build.
  • 4: f75d9c92 merges origin/feat/1928-scroll-and-screenshot-record at c2d35f66, which holds origin/main at 2d5f1594 with electron ^45.0.0; merging origin/main afterwards was already up to date. Re-check: cfb36527, Electron 45.0.0, Chromium 156.0.8078.48, e2e/recapture-transaction-record.spec.ts 8 passed under xvfb-run.
  • 5: 5c24394b. Measured on Electron 45.0.0 with the recorder's Network.enable limits: an 11,000,000-byte upload made a 25,667,678-character event, a 6,000,000-byte one 14,001,007.
  • Reviewed commit 11fcd9c2; new head a46cd9b6.
  • 3, 6 and 9: the PR body at a46cd9b6.
  • 7: withinMs in src/main/services/transactionRecorder.ts races the work against a timer and never cancels it.
  • 8: release() clears responseBodies, and collect() throws on overflow before reading it; answered()'s callers use its result only in if conditions.
Synced from GitHub

Dispositions for the earlier review verdict above, at the latest commit. Items 1, 2, 3 and 8 repeat items 3, 4, 5 and 9 of the later verdict and take the same answers there.

  1. The description's wording on a request that finished with no response: applied.
  2. The re-check on the newer browser: applied. The step's end-to-end spec passed all eight tests on it after the branch took main.
  3. Large uploads failing the record: Not applied: the setup command still carries no upload size limit, because that changes what the collector reads for multipart bodies; the documents now name the upload size that fails the record.
  4. The conflict with the stacked branch: applied. The validation record keeps both sides, the base's scroll-lock section first.
  5. The session key of response bodies not pinned: applied. A recorder test now fails when the key drops the session.
  6. The surviving mutant that clears held bodies: Not applied: it changes only memory, since collection throws on the overflow first.
  7. The service worker's copy holding no body only through browser behaviour: Not applied: the ruled outcome is pinned by the end-to-end spec, which passed again on the newer browser, and reading the service-worker flag in the collector would be a rule the ruling did not ask for.
  8. The description's merge bullet: applied.
  9. The over-long comment line in the probe script: applied.
Commits
  • Reviewed commit 11fcd9c2; new head a46cd9b6.
  • 1 and 8: the PR body at a46cd9b6.
  • 2: cfb36527 (plan, charset spike and validation record).
  • 3: 5c24394b.
  • 4: f75d9c92.
  • 5: 604e086d (keys each response body by its session, so a body that returns later replaces none in tests/main/services/transactionRecorder.test.ts; it fails with bodyKey returning the request id alone).
  • 9: a46cd9b6 (scripts/capture-probe/run.mjs).
Synced from GitHub

Review verdict: request changes

  1. If the captured page moves the hidden window to a blank page, by going back in history to the blank page the app now loads first or by opening a blank address, the signed record still names the original site's secure connection details beside a blank capture.
  2. The documents say those details always come from the page the capture shows, and describe the extra blank history entry as only changing a count the page can read; both are untrue in that case, and the second needs the maintainer's ruling.
  3. The earlier blocking points are fixed: a cancelled later navigation no longer supplies the connection details, and tests now fail if that choice breaks.
  4. Every required check passes at this commit and my own full run matched the description's figures; the one failing check is the maintainer sign-off this kind of change always needs.
  5. When a final recording step runs out of time its work still runs on after the record is marked failed; this was declined last round with a reason and stays open for the maintainer.
Full report

Reviewed commit: a46cd9b63068e6c43c033cf11298a8e8d2efc2a2

Head re-read at the end: unchanged; auto-merge null. Previous agent review: 11fcd9c2. The delta has executable changes (217de821 in collect.ts, and the merge f75d9c92), so this is a full round. Scope read: git diff origin/main...a46cd9b6 (37 files, which includes the unmerged #1996 at c2d35f66), with attention on git diff c2d35f66 a46cd9b6 (this PR's own 33 files) and git show --remerge-diff f75d9c92.

#severityfile:linefinding
1blockingsrc/packages/transaction-record/lib/collect.ts:235-240, :316-326; src/main/services/transactionRecorder.ts:432-448; src/main/services/backgroundRenderer.ts:648-655; CONTEXT.md:105-106; website/content/docs/features.mdx:52-57mainDocument is chosen from Network events only, so a final main-frame navigation that sends no request leaves it on the earlier page. The page shown and the signed boundTls then disagree, while the glossary and features page say the details come from "the page the capture shows" and "the last navigation that replaced it".
2advisory (needs a ruling)validation record lines 277-281; features.mdx:50-51The blank first page is recorded as costing one history.length entry. It also gives the page an entry to go back to, so history.back() blanks the capture, which is then stored under about:blank. The 26 October ruling covered the history entry, not this.
3advisorytransactionRecorder.ts:120-127, :493-497withinMs stops waiting but never cancels. After a timeout the writer (up to 50 MB of compression) and pending body fetches keep running on the main process. This was declined last round with a reason, and the follow-up candidate is named but not filed.
4advisory (not confirmed)collect.ts:239-240A download or 204 navigation whose response arrived before stop() but whose loadingFailed arrived after is in-flight with a response, so it counts as having replaced the page. The validation table shows the cancel following the response, so the window is narrow. I did not measure it.
5advisoryTest reliability job 123768730978The 4 surviving mutants are not evidence-affecting. transactionRecorder.ts:368 overflow && !method is equivalent, since record() filters by HELD_METHODS and hold() refuses after overflow (I reran it: green). :205 affects memory only. warcReader.ts:30 is the test-only reader's FNAME branch, which the writer never sets. backgroundRenderer.ts:362 is #1996's 24-byte PNG boundary. The 45 revert survivors are existing collector, lifecycle and recapture tests that were edited for fixture shape.

Failure scenario for 1 and 2, measured with a standalone Electron 45.0.0 script under xvfb-run (in /tmp, deleted afterwards). The script mirrors the render: it loads about:blank, sends Network.enable, then loads http://127.0.0.1:<port>/target, which calls history.back() 300 ms after load. After 3 s wc.getURL() was about:blank, the title about:blank and history length 2. The only Document events were the target's requestWillBeSent and responseReceived. A target that sets location.href = 'about:blank' gave the same result (no Network event, window on about:blank). An A to B to history.back() sequence does send a request (served from cache), so it is not affected.

From there, by code trace (the app was not run end to end): replacedThePage picks the target, and boundTlsOf signs its securityDetails. recapture.ts:206 does not refuse about:blank, because localAddress.ts:88 returns false for a non-http protocol. recapture.ts:232-235 stores the Capture with url: 'about:blank' and the target's Bound TLS Details beside a blank screenshot and MHTML.

The history.back() path exists only because this PR loads about:blank first. Before it, the call had no entry to go back to. One possible fix is to compare the chosen document with the URL the window committed (did-navigate, or Page.frameNavigated's loaderId) and record boundTlsAbsent on a mismatch, with a collector or recorder test for each case.

Verified fixed from the last round:

  • Cancelled, never-answered and superseded navigations are excluded. I ran mutants on collect.ts: dropping state !== 'cancelled' fails 2 tests, dropping || failure !== undefined fails 1, and && to || on the frame test fails 5.
  • Body keys by session are pinned (604e086d).
  • The 11 MB upload is now named in the validation record and the features page.
  • The Electron 45 re-check is recorded. The e2e spec has 8 tests (spec lines 408-780), and the KAT asserts recordedTargets: 5 (line 455).
  • The merge f75d9c92 resolutions keep this branch's writer (marker removed) and both sides of the validation record. Nothing was dropped.

Evidence gate:

  • PR labels are evidence-affecting, agent-authored, review:stale and size:XXL. #1929 carries evidence-affecting.
  • Blocking-tier paths touched: backgroundRenderer.ts, recapture.ts, captureLifecycle.ts, db/captureRepo.ts, reportHtml.ts, src/packages/transaction-record/**, and the two new rows (transactionRecorder.ts, testCertificateAllowance.ts).
  • Advisory tier, src/shared/types.ts: the diff adds two log codes. Not evidentiary.
  • Advisory tier, src/shared/constants.ts: the diff adds the Transaction Record budget constants. These reach the signed inventory.transactionRecord.budget, which the label already covers.
  • The Evidence impact section is substantive. Its Bound TLS sentence is inaccurate for finding 1.
  • KATs cover body bytes and digests, and the shown page after an empty-answer navigation (e2e line 717). None covers a non-network final navigation.
  • Backward verification: git diff --name-only c2d35f66 a46cd9b6 names no verifier, schema, manifest or layout file, and the full suite (frozen digests included) passes.
  • merge-gate fails only on "evidence-affecting with no sign-off at head", which is expected.

Verify loop: Linux runner, Node 20.20.2, scratch worktree at the sha (closed with teardown.sh close).

  • pnpm lint, pnpm typecheck and pnpm build: exit 0.
  • BIRDBRAIN_REQUIRE_OPENSSL=1 BIRDBRAIN_REQUIRE_JQ=1 pnpm test:coverage: exit 0, 8477 passed and 25 skipped, matching the PR body.
  • pnpm coverage:diff: exit 0, 98.25% (336 of 342 lines). The uncovered lines are in the test-only warcReader.ts (28-30, 36, 62, 116).
  • CI: every required check is green at the sha. The probe job (Test reliability) succeeded.

Completion controls:

  • No Co-authored-by in git log origin/main..a46cd9b6.
  • The body ends with the maintainer's Created with byline. Both check.sh --any-author and check.sh exit 0.
  • auto_merge is null.

Not checked:

  • Vale on the changed lines, because Vale is not installed on this runner. Preflight and CI docs-build report a pass.
  • The e2e specs locally. CI e2e is green.
  • warcio check was not run.
  • Finding 4's race.
  • Shell semantics: none needed. The tool shell here is bash, and the setup and teardown scripts ran under bash.

Written by claude-opus-5-5 via Claude Code on behalf of Matt.

Maintainer rulings on the last review round

Matt gave these on 2026-10-29, answering the two points the latest review left for him.

  1. Blank page at the end of a capture: when the page the hidden window ends on is not the request the secure-connection details came from, the signed record says those details are absent. The blank page the app loads first stays. Each case gets a test.
  2. Work that runs on after a timeout: accepted as it is for this release. When a final recording step runs out of time, the record is marked failed and the step's work finishes in the background; the capture is still stored correctly, and no change is needed in this PR.

Added 2 commits

  • chore(merge): take main at a6e392f2 into the transaction record branch41650c0
  • fix(transaction-record): sign Bound TLS only for the committed page4e18f02
Synced from GitHub

Dispositions for the review verdict of 30 October and the maintainer's rulings on it, at the latest commit.

  1. A blank last page carrying the earlier page's connection details: applied. The signed record now holds those details only when the page the window ends on is the one they came from; going back to the blank first page, or opening a blank address, records them absent, and the blank first page stays.
  2. The documents saying the details always come from the page the capture shows: applied. The glossary, the features page, the validation record and the description now state the new rule, and the extra history entry is recorded as giving the page a blank page to go back to.
  3. Work that runs on after a final step times out: not changed, as the maintainer accepted it for this release; the record is marked failed and the capture is still stored correctly.
  4. A download whose cancel arrives after recording stops: applied through item 1. Such a navigation never becomes the page the window shows, so the record now says the details are absent instead of signing its own, and a test pins that.
  5. The four surviving test mutations: Not applied: none changes the signed record, since two only change what is held in memory after an overflow the collection already refuses, one is in a reader only tests use, and one is in the stacked screenshot change.
Comment ids, commits and measurements
  • Answers issue comment 6512333121 (verdict at a46cd9b6, items 1 to 5 above) and 6515710817 (maintainer rulings: ruling 1 is item 1, ruling 2 is item 3).
  • New head: 4e18f025. 41650c09 merges origin/main at a6e392f2 with no conflicts.
  • 1, 2 and 4: 4e18f025. boundTlsOf in src/main/services/transactionRecorder.ts compares the main document's URL, without the fragment, with the URL of the window's last did-navigate, which backgroundRenderer.ts passes as committedUrl; a mismatch gives boundTlsAbsent: 'not-reported', an existing value, so the schema is unchanged.
  • Measured on Electron 45.0.0 under xvfb-run, in a standalone script outside the repository: history.back() to the blank first page and location.href = 'about:blank' each sent no Network event and reported did-navigate with about:blank; a page loaded as /normal#frag reported did-navigate with the fragment while the debugger's URL had none; history.pushState reported only did-navigate-in-page; a 204 and an attachment reported no did-navigate and ended loadingFailed canceled true.
  • Tests: in tests/main/services/transactionRecorder.test.ts, the two blank-page cases, the open download and a boundTlsOf case fail with the comparison removed, and the kept-details case fails when the fragment is kept; the renderer test states the details absent when the page ends on the blank page it went back to fails with the comparison removed; the two e2e tests in e2e/recapture-transaction-record.spec.ts (/then-back, /then-blank) failed on a build without the comparison, with boundTls signed beside url: about:blank, and pass on the fixed build.
  • 5, rechecked at a46cd9b6: transactionRecorder.ts:368 drops overflow from the listener's guard, after which hold() still refuses every held event and collect() throws on the overflow; :205 is held = 0 in release(), read only by fetch accounting after the overflow; warcReader.ts:30 is in the reader whose entry point says only tests import it, which grep confirms (the package tests and the e2e spec); backgroundRenderer.ts:362 blames to 6d0cc9c1, which is in #1996 at c2d35f66 and not in main.
Synced from GitHub

Review verdict: request changes

  1. When the page moves away from the page it loaded, the signed record now says its secure-connection details are absent, the earlier blank-page problem is fixed, and the new tests fail when the fix is removed.
  2. The capture report then explains the absence as the browser reporting no details for the page, which is untrue in this new case; one sentence of report text needs to name both causes.
  3. Both of the maintainer's rulings are applied as written: the details are absent with a test for each case, and the work after a timeout is unchanged.
  4. Every automated check passes at this commit, the browser tests included, and the branch merges cleanly with the latest main.
  5. The only failing check is the maintainer sign-off this kind of change always needs.
Full report

Reviewed commit: 4e18f02521cb44ef57489126d5500bfb42149cc1 (round 3; previous review at a46cd9b6, verdict 6512333121).

Head re-read at the end: 4e18f02521cb44ef57489126d5500bfb42149cc1, unchanged. Auto-merge null.

Scope read: git diff a46cd9b6..4e18f025 less the main merge. The merge 41650c09 brings in exactly the 9 files git diff 2d5f1594 a6e392f2 names (command palette and sharing label), none of which the branch touches, and each is byte-identical to a6e392f2 at 41650c09. The fix commit 4e18f025 changes 9 files: transactionRecorder.ts, backgroundRenderer.ts, collect.ts (comment only), two unit test files, the e2e spec, CONTEXT.md, features.mdx and the validation record.

#severityfile:linefinding
1blockingsrc/main/services/reportHtml.ts:1826; src/main/services/transactionRecorder.ts:455-457The new mismatch path returns boundTlsAbsent: 'not-reported', which the evidence report renders as "the browser reported no security details on the response for the main document". In the mismatch case the browser did report details on that response; they are withheld because the window ended on another page. The string predates this PR (from #1978) and was true until this round.
2advisorytests/main/services/backgroundRenderer.test.ts:233-236; transactionRecorder.test.ts:904-1000No test pins that a same-document navigation (pushState, did-navigate-in-page) keeps the details. The renderer mock never emits did-navigate-in-page, so a change that also updated committedUrl from it would drop real details with every test green.
3advisorysrc/main/services/backgroundRenderer.ts:715-732Collection stops before the screenshot, but committedUrl is read after the screenshot, the MHTML save and the text read. A page that navigates in that interval loses details its screenshot shows. This direction only ever drops details, never signs the wrong ones, so it is not blocking.

Failure scenario for 1: the e2e case /then-back (spec lines 753-772). The https target loads over the test certificate, so the browser reports securityDetails on its response, then the page calls history.back(). The entry is stored under about:blank with boundTlsAbsent: 'not-reported', and the report for that Capture (reportHtml.ts:1849) prints "The manifest entry records no Bound TLS Details: the browser reported no security details on the response for the main document." Before this round the same page signed those details (last round's finding 1), so the browser did report them. The same text appears for the download whose cancel arrives after collection stops (transactionRecorder.test.ts, "states the details absent, not the later answer..."), where the page shown had its own reported details. CONTEXT.md:106 lists "when the browser reported none" and "when the window ends on a page no recorded request produced" as separate reasons, but both map to one value, and the plan (docs/plans/2026-10-10-capture-engine-first-slice.md:450) defines not-reported as the first only.

Corrected wording for 1, which keeps the signed schema and the released verifier's accepted values unchanged: 'not-reported': 'the browser reported no security details on the response for the main document, or the window ended on a page that response did not load', with tests/main/services/reportCaptureEngine.test.ts:347 updated to match. A new absence value would separate the two causes in the signed entry, but z.enum(BOUND_TLS_ABSENCES) at src/shared/schemas.ts:674 is strict, so an earlier verifier would reject it; that choice is the maintainer's.

Summary (the squash commit body): no sentence is false at this head. The sentence "The capture's signed record gains the archive's hash, counts of what the archive lacks, and the browser's summary of the secure connection of the page the capture shows, or the reason it has none" stays correct once finding 1's report text is fixed; no wording change is needed. The Evidence impact section's Bound TLS sentence ("only when that document's URL, without the fragment, is the URL the window last committed; otherwise boundTlsAbsent is not-reported") matches transactionRecorder.ts:450-464. The claim of five Bound TLS e2e tests matches spec lines 640-780.

Last round's blocking finding, verified fixed by mutation (Node 20.20.2, BIRDBRAIN_TEST_SYSTEM_SHARP=1):

  • At head: transactionRecorder.test.ts 57 passed, backgroundRenderer.test.ts 78 passed, both exit 0.
  • Comparison removed (|| url.href !== committed.href deleted): 4 recorder tests fail (both blank-page cases, the open download, the boundTlsOf mismatch case) and the renderer test "states the details absent when the page ends on the blank page it went back to" fails.
  • Fragment kept (parsed.hash = '' removed): "signs the details of a page the window stays on, whatever fragment it committed with" fails.
  • Renderer passes the target URL instead of the did-navigate URL: the renderer blank-page test fails.
  • The e2e tests /then-back and /then-blank were not run locally; CI e2e covers them.

Checks the brief asked for:

  • Fragment stripping: documentUrl (transactionRecorder.ts:433-441) parses with WHATWG URL and clears hash on both sides, so #frag and a bare # both compare equal to the CDP URL, which carries no fragment.
  • Same-document navigation: did-navigate (backgroundRenderer.ts:641) is the only writer of committedUrl. Electron does not emit it for in-page navigations (the implementer's Electron 45.0.0 measurement agrees), so pushState keeps the details. Finding 2 is the missing pin.
  • Redirect: the collector's main document is the last hop of the chain (collect.ts:316-326), whose response URL is the final URL, the same URL did-navigate reports. A fragment carried across the redirect is stripped. By code trace, not run.
  • Normalisation: both strings are Chromium canonical URLs and both pass through WHATWG URL before comparing. A Node 20 check gave equal results for https://Example.COM:443 against https://example.com/, bücher.example against xn--bcher-kva.example, and x# against x. A trailing slash on a non-root path compares unequal, which is correct, since the server treats it as a different resource and a redirect between them makes the final URL the one compared.
  • Reuse of not-reported: finding 1.

Maintainer rulings (comment 6515710817):

  • Ruling 1 is applied as written. The mismatch records the details absent, about:blank still loads first (backgroundRenderer.ts:658), and each case has a recorder test and an e2e test, plus a renderer test for going back.
  • Ruling 2 is applied as written. git diff a46cd9b6 4e18f025 -- src/main/services/transactionRecorder.ts touches only documentUrl, boundTlsOf and the committedUrl option, and withinMs is unchanged.

Bot findings: CodeRabbit's only comment (6430027103) says the draft was not reviewed. There are no reviews and no inline comments (--paginate over pulls/1999/reviews and pulls/1999/comments returned 0 each).

Evidence gate:

  • PR labels: evidence-affecting, agent-authored, review:changes, size:XXL. #1929 carries evidence-affecting.
  • This round touches the blocking-tier backgroundRenderer.ts, transactionRecorder.ts and src/packages/transaction-record/**, all covered by the label.
  • The Evidence impact section is updated for this round and is substantive.
  • Known answer: the e2e spec pins both blank-page cases against a generated certificate.
  • Backward verification: git diff --name-only c2d35f66 4e18f025 names no verifier, schema or manifest file, and the absence enum is unchanged, so older entries read as before.

Verification:

  • CI at 4e18f025: test (7m36s), lint, typecheck, build, docs-build, changes, hunks, adr-numbers, the secret scan and the dependency audit pass. e2e (9m38s) and probe (Test reliability, 10m51s) pass. merge-gate fails only on "evidence-affecting with no sign-off at head", which is expected.
  • The probe's 4 surviving mutants are not on this round's lines. transactionRecorder.ts:219 (return false to true after the event bound is hit, from e72fe128) leaves the record failed either way, because release() has set overflow and cleared the events; the other three are in the test-only warcReader.ts. The 45 revert survivors are the collector, lifecycle, recapture and report tests listed last round, edited for fixture shape.
  • Locally I ran only the two touched unit test files and the mutations above. The full coverage suite, the build and preflight were left to CI at this sha.
  • Trial merge of 4e18f025 with origin/main at 32cf7983 (the 1.0.1-beta.24 release bump): clean, and only package.json changes.

Completion controls:

  • No Co-authored-by in git log origin/main..4e18f025.
  • The body ends with the Created with byline, and post-pr-body/scripts/check.sh exits 0.
  • auto_merge is null.

Not checked:

  • The e2e spec locally.
  • Back-forward cache restores in the hidden window.
  • A target URL with embedded credentials, where the two URL sources might differ.
  • Vale on this round's doc changes, because CI docs-build and the PR's preflight cover it.

Added 1 commit

  • fix(report): describe a withheld Bound TLS as well as an unreported one3987f06
Synced from GitHub

Reply to the review of 2026-10-30

  1. The report now describes the new absence correctly: it says the browser reported no security details, or the window ended on a page the main document's response did not load. Matt chose this wording over a new signed value, so the signed record is unchanged and released verifiers still read it.
  2. Same-document navigation is left as it is: the code only takes the committed page from a full navigation, as the review traced, and no test is added this round.
  3. Late navigations after collection stops are left as they are: the review found they can only drop details, never sign wrong ones.
Details
  • Finding 1, applied in 3987f069: BOUND_TLS_ABSENCE_TEXT['not-reported'] and its test in tests/main/services/reportCaptureEngine.test.ts (41 passed). pnpm preflight at 3987f069: exit 0, 8490 passed, 25 skipped, diff coverage 98.30% of 352 lines.
  • Findings 2 and 3: advisory, not applied, for the reasons above.
Synced from GitHub

Review verdict: approve for human review

  1. A frame or worker whose setup answers after the body fetches finish is counted as unrecorded and its text bodies are omitted, an error that overstates what the record lacks.
  2. The writer's time limit marks the record as failed but lets serialization keep running in the background, bounded only by the 50 MB and 25 MB budgets.
  3. Two type declarations in the transaction record package's types file are each written twice, a merge artefact that will conflict if only one copy is edited.
  4. The capture probe header still names Electron 44.9.1 and Chromium 152, while the probe now reports Electron 45.0.0 and Chromium 156.
  5. The event size limit is described as bytes of JSON but measured as UTF-16 string length, which differs only for JSON that is not ASCII.
Full report

Reviewed commit: 3987f069bbe8de48459450842cb53e8783ab9915

Reviewed commit: 3987f069bbe8de48459450842cb53e8783ab9915 (PR #1999, feat/1929-record-transaction-record). I re-read the head at the end and it is still 3987f069, so it did not move.

What I reviewed against. The PR is stacked on #1996, which is still open (its head is c2d35f66). Diffing against origin/main therefore pulls in #1996's own changes too (18 files). I reviewed this PR's own change as c2d35f66..3987f069, which is the #1996 head plus the main merge at a6e392f2. I only skimmed #1996's content; its own review covers it. Current origin/main (db87600c) merges cleanly with the head (git merge-tree exit 0).

Findings

#SeverityLocationFinding
1advisorysrc/main/services/transactionRecorder.ts:240-261, :397When a frame or worker attaches, its setup sends three commands and waits for their answers. Those waits are not in the pending set that collect() waits for. So a child whose setup answers after the body fetches finish is counted in unrecordedTargets, and its text bodies are left out as body-omitted. The error runs the cautious way (it overstates what the record lacks). I worked this out from the code and did not reproduce it.
2advisorysrc/main/services/transactionRecorder.ts:512-516 (withinMs at :120-127)The writer's time limit only races the writer. When the limit runs out the record becomes failed, but the serialization keeps running in the background and its result is thrown away. The work is still finite because the 50 MB body budget and the 25 MB event budget cap it. Under the "bounded work" rule this is a time limit that decides the outcome without stopping the work.
3advisorysrc/packages/transaction-record/lib/types.ts:270-288 and :290-308WriterInput and WrittenTransactionRecord are each declared twice. This is a merge artefact. It compiles today because identical interfaces merge, but editing only one copy later will raise a type conflict.
4advisorysrc/main/services/transactionRecorder.ts:219The Test reliability check found a surviving mutant here: changing return false to true in hold()'s overflow branch left every related test green. It is close to equivalent, because collect() still throws the overflow at :396/:398. The only extra effect is that the one event that triggers the overflow can still send a body fetch or set up a child.
5advisoryscripts/capture-probe/run.mjs:15,18The header's expected output names Electron 44.9.1 and Chromium 152. I ran the probe on the branch (xvfb-run --auto-servernum node scripts/capture-probe/run.mjs, exit 0): it reports Electron 45.0.0 and Chromium 156, and every other line of its output matches the header.
6advisorysrc/shared/constants.ts:21, validation record lines 83 and 311The event bound is called "bytes of their JSON", but the code measures JSON.stringify(params).length, which counts UTF-16 characters, not bytes (transactionRecorder.ts:211). constants.ts itself says "length". This only matters for JSON that is not ASCII.

Correctness traces (no defect found)

  • Bound TLS rule. boundTlsOf (transactionRecorder.ts:448-464) signs details only when the main document's URL, without its fragment, equals the URL from the window's last did-navigate (backgroundRenderer.ts:640-644). Every mismatch I traced gives not-reported, never another page's details: back-forward cache, prerender, a cancelled navigation still open at stop, and going back to about:blank. I removed the comparison and four recorder tests failed, so the PR's claim holds.
  • Main document choice. collect.ts:313-329 ignores cancelled navigations and navigations with neither a response nor a failure.
  • Body keys and text bodies. Bodies are keyed by session in both the recorder (:169, :400-403) and the collector (collect.ts:148-163), and they agree. A text body is kept only from a session whose durable-messages command answered.
  • Overflow. On overflow everything held is released (:199-207), every later event returns early, later targets are resumed, and collect() throws, so the record is failed.
  • Ingest. It writes {id}.warc.gz inside the store callback, and the existing deleteArtifacts on the failure path covers that file. Only recapture.ts:243 passes captureId, and it is a fresh randomUUID(). The other ingest callers build their parameters explicitly.
  • Duplicate. It copies and hash-checks the record (captureLifecycle.ts:1638-1644), so the CONTEXT.md claim "copies its Transaction Record" is true. Export includes the record when its hash is signed (export.ts:1591-1596).
  • Test certificate switch. testCertificateAllowance returns null unless app.isPackaged === false and the switch is set. It is installed on the per-render partition only (backgroundRenderer.ts:533).
  • Documentation claims. I checked the claims in CONTEXT.md, features.mdx, the validation record and reportHtml.ts against the code: the 10 MB and 11 MB limits, collection stopping before the screenshot, the reasons Bound TLS is absent, the TLS fields, and the hash list. I found no overclaim.

Evidence gate

  • Labels: the PR has evidence-affecting, agent-authored, review:requested and size:XXL. Issue #1929 has evidence-affecting.
  • Blocking-tier paths touched: backgroundRenderer.ts, recapture.ts, captureLifecycle.ts, db/captureRepo.ts, src/packages/transaction-record/**, and the two rows this PR adds (transactionRecorder.ts, testCertificateAllowance.ts). The PR carries the label, so the backstop passes.
  • Evidence impact section: substantive. It names what acquisition and the signed entry gain, states what verification proves and does not prove, and lists the limits.
  • Known-answer tests: the writer KAT is updated to the served windows-1252 bytes and digest, and a new byte-order-mark test is added. The charset known answers that come from the browser live in the e2e spec, and that spec passed in CI.
  • Backward verification: between c2d35f66 and the head, no file under schemas, verify or manifest changes. The full suite, frozen digests included, passes.
  • Auto-merge is null.

Verification (run on 3987f069, Node v20.20.2, Ubuntu 24.04; commands run in the bash tool shell)

  • pnpm lint: exit 0, with 0 errors and 49 warnings, none in changed files.
  • pnpm typecheck: exit 0.
  • pnpm build: exit 0. pnpm build:extension was not needed because extension/ is unchanged.
  • BIRDBRAIN_REQUIRE_OPENSSL=1 BIRDBRAIN_REQUIRE_JQ=1 pnpm test:coverage (under xvfb-run): exit 0, with 8490 passed and 25 skipped (8515), thresholds met.
  • pnpm coverage:diff: exit 0, with 346/352 changed lines covered (98.30%). The 6 uncovered lines are in the test-only warcReader.ts.
  • Capture probe (D15): exit 0, "No difference beyond the about:blank history entry."
  • Live checks (gh pr checks 1999): all green except two.
    • merge-gate is failing, and its log reads "evidence-affecting with no sign-off at head". That is the expected sign-off gate, not a defect.
    • agent/pre-pass is pending.
  • Test reliability check: 47 of 233 touched tests still pass with the source reverted. All of them are pre-existing tests edited for the new count, or tests checking that something does not happen, which pass trivially on the old code. 4 mutants survived: finding 4, plus 3 in the test-only warcReader.ts.

Completion controls

  • Commit trailers: git log origin/main..3987f069 --format=%B | grep -i co-authored found nothing (grep exit 1).
  • Attribution: the body ends with the Created with ... in Claude Code (T3 Code) byline. The linter accepts it (check.sh exit 0 both with and without --any-author).
  • Auto-merge: null.

Not checked

  • warcio check on records from this head's e2e run. The PR says it was last run at 11fcd9c2. The writer has not changed since then; the collector has changed only its main-document choice.
  • I did not run the e2e suite locally. CI's e2e passed at this commit.
  • I did not review #1996's own content in depth.
  • I wrote no comment file and did not run the post-comment check.sh, because nothing is posted this run.

Friction notes

  • re-derived, agent file "Review passes" (diff command): for a stacked PR, git diff origin/main...<head> also includes the parent PR's changes, and nothing says to diff from the parent's head instead. Cost: I had to look up #1996's state and rebuild the delta as c2d35f66..head. Fix: add one line saying a stacked PR is reviewed against its parent's head, with main as the fallback.
  • confusing, dispatcher context "CI at this sha is green: every check passed": gh pr checks showed merge-gate as failing. Cost: I read its log to confirm it was the expected sign-off gate. Fix: in the scheduled-review context, list merge-gate as expected red on an evidence-affecting PR that has no sign-off yet.
Synced from GitHub

Review verdict: approve for human review

  1. The report sentence for missing connection details now names both causes, and its test fails when the old sentence is put back.
  2. A correction to my last review: a page that moves to another page before the save and comes back after it gets the first page's connection details signed beside a saved copy of the other page; the stacked PDF change fixes this, so merge it before any release that ships this change.
  3. Once the PDF change lands, the report sentence, one line of the validation record and one code comment still name the end of the render instead of the save; the full report gives the corrected words, which belong in the PDF change.
  4. The description says the scroll-to-load change merges first; it is still open and its commits are in this branch, so merge that one first.
  5. Every required check passes at this commit and the branch merges cleanly with the latest main; the only failing check is the maintainer sign-off this kind of change always needs.
Full report

Reviewed commit: 3987f069bbe8de48459450842cb53e8783ab9915 (round 4; previous review at 4e18f025, verdict 6516179381).

Head re-read at the end: 3987f069bbe8de48459450842cb53e8783ab9915, unchanged. auto_merge is null.

Delta: git diff 4e18f025 3987f069 changes 2 files, 3 lines added and 2 removed. BOUND_TLS_ABSENCE_TEXT['not-reported'] at src/main/services/reportHtml.ts:1826-1827 and the matching row at tests/main/services/reportCaptureEngine.test.ts:345-348. The string feeds rendered report text only (reportHtml.ts:1850) and no branch reads it, but I ran the touched test anyway because the brief asked for the mutation check.

#severityfile:linefinding
1resolvedsrc/main/services/reportHtml.ts:1826-1827Last round's blocking finding is fixed with the wording the review proposed.
2advisorysrc/main/services/backgroundRenderer.ts:715-732, :755; src/main/services/transactionRecorder.ts:450-464This corrects last round's advisory 3, which said a late navigation "only ever drops details, never signs the wrong ones". That is wrong for a round trip. committedUrl is passed to finishTransactionRecord after the screenshot, the save and the text read. A page A that navigates to a page B before savePage, where B returns to A after the save and before that read, ends with committedUrl equal to A. The collector stopped before either navigation, so the main document is still A's response, and boundTlsOf signs A's details. The MHTML holds B and finalUrl (:755, read later still) names A. The reply 6516984759 item 3 relied on my wrong claim. PR #2000 reads committedUrl at the save (d807ff8e, its SavedPage), which closes this. Found by code trace, not run.
3advisoryreportHtml.ts:1827; validation record line 298-299; transactionRecorder.ts:444-447After #2000 lands, the moment that decides not-reported is the save, so three texts name the wrong moment. Exact wording follows the table.
4advisoryPR body, Summary, first sentenceThe sentence "it is stacked on #1996, which merges first" holds only if the maintainer merges #1996 first. #1996 is open with review:passed, its head c2d35f66 is an ancestor of this head, and this PR's base is main, so squash-merging this PR first would put #1996's change into this commit under this message. No wording change is needed if #1996 merges first.
5advisorydocs/plans/2026-10-10-capture-engine-first-slice.md:448-451The plan still defines not-reported only as "the response carried no securityDetails". The line predates this PR (2566d4be), and plans are allowed to go stale (CLAUDE.md, documentation conventions). No change asked.

Finding 3: each sentence and the corrected wording, to apply in #2000, not here.

  • reportHtml.ts:1827 (#2000 has not taken 3987f069 yet; its branch still carries the old string). Current: "the browser reported no security details on the response for the main document, or the window ended on a page that response did not load". Corrected: "the browser reported no security details on the response for the main document, or the page the capture saved was not the page that response loaded". With #2000, a page that leaves before the save and returns after it records not-reported (#2000's Evidence impact says so). If the return restores the same document, the window ended on the page that response loaded, so the current sentence is false. The matching test row moves with it.
  • Validation record, Known limitations, step 9, lines 298-299 on this branch (lines 438-439 on #2000). Current: "a Capture that ends there is stored under about:blank with no Bound TLS Details". Corrected: "a Capture whose page is there when it is saved is stored under about:blank with no Bound TLS Details". With #2000, a page that goes back after the save keeps the saved page's address and details.
  • transactionRecorder.ts:444-447 comment, which #2000 does not touch. Current: "committedUrl is the URL of the window's last cross-document navigation ... its details are signed only when that page is the one the window ends on". Corrected: "committedUrl is the URL of the window's last cross-document navigation at the save ... its details are signed only when that page is the one the window held when the page was saved".

Statements that stay true once #2000 lands, so no change is needed:

  • CONTEXT.md:106 ("when the window ends on a page no recorded request produced"): #2000 already rewrites it to "when the page the Capture saved is one no recorded request produced".
  • website/content/docs/features.mdx:55-57 ("When the page then moves to a page that no request loads"): #2000 already rewrites it to "When the page has moved, by the time it is saved, to a page that no request loads".
  • The validation record's third-review note (line 228, "the URL of the window's last cross-document navigation, as Electron's did-navigate reports it") records round 3 as history. #2000 adds its own note at its line 346 for the save-time read.
  • The PR body's Evidence impact ("only when that document's URL, without the fragment, is the URL the window last committed") and the Summary's "records that summary as absent when the page then moves to a page no request loads" are true for this commit. The squash body records this commit, and #2000's Evidence impact states the save-time change.

Summary (the squash commit body): no sentence is false at this head. The first sentence's "stacked on #1996, which merges first" depends on merge order (finding 4). The Bound TLS sentences need no change.

Wording fix verified by mutation (Node 20.20.2, BIRDBRAIN_TEST_SYSTEM_SHARP=1, bash):

  • At head, pnpm test tests/main/services/reportCaptureEngine.test.ts exits 0 with 41 passed.
  • With the string reverted to the old text, it exits 1, and "states the Bound TLS absence not-reported" fails (1 failed, 40 passed). The test asserts the full sentence with toContain, so dropping either clause fails it.

Old wording elsewhere: git grep -n -i "no security details\|not-reported\|boundTlsAbsent\|security details" over the source, CONTEXT.md, website/, the validation record, every tracked *.sh and src/shared/verify/ finds no other surface that states the not-reported meaning in the old words. The only user-facing rendering is reportHtml.ts:1850. The verifier (src/shared/verify/manifestChain.ts:454-469) passes the value through without text, and no tracked verify*.sh names Bound TLS. The one remaining narrow definition is the plan (finding 5).

Review surface (--paginate over pulls/1999/reviews, pulls/1999/comments, issues/1999/comments): 0 reviews, 0 inline comments, 13 issue comments. Only one is new since 6516179381:

  • 6516984759, the reply. Item 1 is applied and verified above. Item 2 (no same-document navigation test) is advisory and accepted as left. Item 3 relies on last round's claim, which finding 2 corrects. The cited preflight result (8490 passed, diff coverage 98.30%) was not re-run here. CI's test job at this sha agrees.
  • CodeRabbit's 6430027103 is unchanged since 2026-10-26.

Evidence gate:

  • PR labels: evidence-affecting, agent-authored, review:requested, size:XXL. #1929 carries evidence-affecting.
  • This round touches the report renderer and its test only. The Evidence impact section names the report wording (2026-10-30 ruling).
  • The signed value and z.enum(BOUND_TLS_ABSENCES) are unchanged, so earlier entries read as before.

Verification:

  • CI at 3987f069: test (7m43s), e2e (9m20s), lint, typecheck, build, docs-build, changes, hunks, adr-numbers, review, label, seed, the secret scan, dependency audit, font licence check, registry publish guard and probe (10m49s) pass.
  • merge-gate fails with "evidence-affecting with no sign-off at head", which is expected. agent/pre-pass is pending this verdict.
  • Probe: 47 of 233 touched tests still pass with the source reverted, and 4 of 40 mutants survived, against 45 and 4 last round. The two new revert survivors were not itemised. This round adds no recorder or renderer lines, and the report test does fail on revert (above).
  • Locally I ran only the touched test file and its mutation. The full suite with coverage, the build and preflight were left to CI at this sha.
  • Trial merge of 3987f069 with origin/main at db87600c: clean, changing only package.json and src/renderer/routes/__root.tsx.

Completion controls:

  • git log origin/main..3987f069 --format=%B contains no Co-authored-by.
  • The body ends with the Created with byline, and post-pr-body/scripts/check.sh exits 0.
  • auto_merge is null.

Not checked:

  • The e2e spec locally.
  • Finding 2 by a run; it is a code trace.
  • Whether a back-forward cache restore after the save happens in the hidden window, which decides whether the #2000-era report sentence is false or only imprecise.