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

opened9d agobyMMatt Donovan

Note

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

Part of the capture engine first slice (step 9 of the first-slice plan, brief CE-7, wiring). Follows step 1 (#1904), step 3 (#1925), step 4 (#1926), step 5 (#1927), step 6 (#1928), step 7 (#1903), step 8 (#1906), CE-2 (#1726), and CE-3 (#1695).

This step turns the capture engine on for background captures. While the hidden browser window loads a page, the app listens to the browser's own report of each request and response, including those made inside frames from other sites and by scripts the page runs in the background, and stores them beside the capture as a standard web archive file, with the values of four sign-in headers replaced. It also records the browser's summary of the page's secure connection. The capture's signed record gains the file's hash and counts of what the file lacks. If recording fails, the app still stores the capture, and its record says that the file is missing and why. This step writes new facts into signed records, so it waits for a release whose verifier can read them.

Agent brief: plan sections, scope, acceptance criteria, verification and starting files

Plan. docs/plans/2026-10-10-capture-engine-first-slice.md (approved with #1803): Step 9, D4 (the inventory), D7 (Bound TLS Details and boundTlsAbsent), D8 (the writer, whose WARC-IP-Address depends on the Egress), D14 (Shared Cases), D15 (the probe runs in CI), D16 (the validation record), D17 (out-of-process frames and workers, merged with PR #1902), the spec's open questions 3 and 4, Risks, the Rulings, 2026-10-10 (open question A, ruling 30; C, ruling 34; E, ruling 29; the test readers, ruling 26; the probe rerun, ruling 32), and the Ruling, 2026-10-17. The renderer spike it builds on: docs/specs/2026-10-11-capture-engine-renderer-spike.md, its Verdicts table, and the step 9 items in "Notes for later steps" (where collection stops, the test certificate allowance, and history.length in D15's script).

Waits for a tagged release that carries step 1's verifier (ruling 30), and for steps 3 to 8 and CE-3 (#1695, PR #1896) to merge; CE-2 (#1726) merged in PR #1887. Steps 3 to 5 carry and describe the two files, step 6 writes the inventory this step extends, step 7 (#1903, PR #1911) is the collector, step 8 (#1906) is the writer, CE-2 supplies the loopback allowance the fixtures use, and CE-3 supplies the proxy on the partition and the SOCKS5 fixture. It also waits for the maintainer's ruling on the second open decision on #1823: how the slice handles the extra history entry that the about:blank load adds. The spike found no method that removes the entry before the target's document commits, and asks for that choice (item 2 of the renderer spike's "Rulings this asks for").

Scope. As the plan's Step 9 "Changes" and "Time" items, with D17 for the attach:

  • In renderPageInHiddenWindow: CE-3's proxy on the partition before the first navigation, then attach, load about:blank, send Network.enable with the budget under a bounded wait, set up D17's attach for frames, dedicated workers, and service workers, and load the target. The plan removes the about:blank entry only if step 0 question 7 found a way; otherwise the difference is a known limitation in the validation record and in features.mdx, and D15's script expects it. The ruling named under Waits for decides which.
  • D17's attach, in its order per target type. On the page's session, Target.setAutoAttach with autoAttach, waitForDebuggerOnStart, and flatten true, answered within the bound of the page's Network.enable. On an iframe or worker, Network.enable with the same limits and the same Target.setAutoAttach together, both answers awaited within the child setup bound, then Runtime.runIfWaitingForDebugger. The bound is a constant beside the page's Network.enable bound, and the plan proposes 2 seconds. On a service_worker, all three commands together without waiting. A target of any other type is resumed at once and counted in unrecordedTargets. Each target is resumed once, whatever its commands answered, and a target that attaches after collection stops is resumed at once and neither recorded nor counted. wc.debugger.sendCommand takes a session id as its third argument, and its message event reports one.
  • The collector reads wc.debugger message events as session id, method, and params triples, with the Target.attachedToTarget events and the set of targets whose setup answered (D17, "The collector"). Each body is fetched on the session that reported its loadingFinished, if D17's body cap leaves room. The engine counts a body against budget.totalBytes when it sends Network.getResponseBody, at four thirds of its reported dataLength rounded up to a multiple of four unless it can tell in advance that the body returns as text, sends no request that would take the total past the cap, and counts each body it does not fetch in bodiesOmitted. A target that detaches before its body arrives leaves the body omitted, and its unfinished requests count as in flight.
  • Collection stops at the point step 0 question 2 fixes and the spike records. The spec puts that point "at the moment the screenshot and MHTML are taken" (spec, Transaction Record). The spike saw no request and no event during the screenshot or wc.savePage in any run, so on its fixtures stopping before the screenshot and stopping after savePage record the same events (renderer spike, "Notes for later steps").
  • The writer's output, the inventory part, and the main document's securityDetails travel on RenderedPage. Ingest writes the file through step 3's store method, transactionRecordHash, boundTls or boundTlsAbsent (not-recorded, not-https, from-cache, or not-reported, D7), the inventory part, and the row. The writer's input says whether the Egress is Direct, because the writer sets WARC-IP-Address only while it is (D8; ADR-0032 rule 5).
  • If the page's Network.enable fails or times out, the inventory records the Transaction Record absent as failed, boundTlsAbsent is not-recorded, and the Capture stores (ruling 29). If the page's Target.setAutoAttach, or a frame's or a worker's session setup, fails, the record is kept and the target counts in unrecordedTargets (D17, "Ruling E for a child session").
  • Time. The render races one deadline and, when the deadline wins, rejects with "Recapture timed out" and stores nothing; FINAL_ARTIFACT_RESERVE_MS reserves 20 s for the screenshot, MHTML, and text only. Step 9 raises the reserve by the time of two new end phases, the body fetches still pending when collection stops and the writer, and gives each phase its own timeout inside it, so the 120 s Recapture timeout stays. When either phase times out or throws, the inventory records the Transaction Record absent as failed, boundTlsAbsent is not-recorded, and the Capture stores.
  • The size budget (open question 3). Start from maxTotalBufferSize 50,000,000 and maxResourceBufferSize 10,000,000 bytes, hold them in src/shared/constants.ts, pick the phase timeouts with them, and report the record sizes the fixture pages and the demo fixture pages (scripts/demo-fixture/pages/) produce, the largest included (Risks, item 7).
  • The TLS Cert Chain re-fetch runs as today, and its field stays separate from boundTls.
  • A test-only certificate allowance, from step 0 question 6, joins CE-2's loopback allowance, which a packaged build cannot turn on. The spike found that a setCertificateVerifyProc on the render's session that accepts one leaf was enough.
  • The probe script (D15, ruling 32). Commit it as a file (proposed path scripts/capture-probe/main.js), taken from "The probe" in docs/specs/2026-10-02-capture-engine-probe-spike.md, with the command that runs its off and on passes under xvfb-run without the 90 s fresh-window case, and the output it expects. It fails when navigator.webdriver, the window.chrome keys, the Error.stack getter count, the resource timing list, or history.length differ between the passes beyond what step 0 question 7 records. The maintainer opens the pull request that adds its CI job after this step merges, because an agent branch cannot change .github/workflows/ (ADR-0048).
  • The validation record (D16): extend the one step 6 starts, with a manual warcio check from the separate Python implementation (1.8.1 on PyPI) on this step's output (ruling 26).
  • CONTEXT.md rewrites the Transaction Record and Bound TLS Details clauses, and the Capture clause's mention of the Transaction Record, to say that background Captures carry them today, and keeps Planned on the launched half.
  • features.mdx states beside the capability that credential header values are replaced (ADR-0036); that a token in a URL, a request or response body, or any header not on the list stays in the record, so an export of a signed-in page can still hold a reusable credential (ADR-0036, Consequences); that a resource reused inside a page leaves no trace; the x-orig- renaming; that Bound TLS Details are the browser's summary and hold no certificate bytes (D7); and D17's two limits, a shared worker's requests and the targets below a target whose Target.setAutoAttach failed. It adds the Transaction Record to the Integrity bullet that lists what Birdbrain hashes and to the Export bullet that lists what an evidence package contains.
  • SC-2 (#1512) merged in PR #1873, so this step adds the two-peer MemoryTransport test that a receiver hash-checks the Transaction Record before showing it (D14, ruling 34).

Acceptance criteria (the plan's Step 9 "Done when")

  • Against a local fixture server reached through CE-2's loopback allowance, a new e2e spec shows the spec's known answers (spec, Validation): gzip and plain digests; a redirect, same-origin and cross-origin; and failed, cancelled, in-flight, and over-budget requests.
  • The same spec loads a cross-site frame with a dedicated worker of its own, a dedicated worker of the page, and a service worker, and every request of each is an exchange, with recordedTargets counting them; stubbed failures of a child's setup and of the page's auto-attach each move unrecordedTargets (D17).
  • The spec includes a cached response, which step 0 found a way to produce (renderer spike, question 5).
  • The consent filter's cancelled request is counted. The spike found that it arrives as Network.loadingFailed with canceled: false, blockedReason: "other", and errorText: "net::ERR_BLOCKED_BY_CLIENT" (question 3).
  • Inventory counts match the server's request log, apart from the in-page reuse the probe documented and a page exchange a service worker answered, whose response reports fromServiceWorker (D17).
  • A cookie the fixture sets and sends appears nowhere in the inflated record.
  • For a fixture served over TLS with a known certificate, through the certificate allowance, boundTls matches its subject, subject alternative names, issuer, and validity; an http: page writes not-https.
  • With CE-3's SOCKS5 fixture and a proxy Egress, neither the record nor the inventory holds the proxy's host or port.
  • e2e/recapture.spec.ts and e2e/recapture-consent.spec.ts still pass.
  • A unit test in which a pending body fetch never settles, and one in which the writer throws, each store the Capture with the Transaction Record absent as failed, inside the Recapture timeout.
  • A unit test shows that with the test switch absent, or with app.isPackaged true, the render installs no certificate allowance and a certificate signed by the fixture's test authority is refused, as CE-2's brief asks of its loopback allowance.
  • The D15 script's command runs both passes and reports no difference beyond the one step 0 question 7 records.
  • The two-peer MemoryTransport test passes for the record.

Verification

pnpm test tests/main/services/backgroundRenderer.test.ts tests/main/services/recapture.test.ts tests/main/services/captureLifecycle.test.ts, pnpm test:e2e e2e/recapture.spec.ts e2e/recapture-consent.spec.ts e2e/recapture-transaction-record.spec.ts (the last is the new spec's proposed name), the D15 script's command, the docs checks (.claude/hooks/vale-prose.sh --base origin/main, and pnpm dlx mint broken-links from website/content/), then BIRDBRAIN_REQUIRE_OPENSSL=1 pnpm test and pnpm preflight at head, with exit codes captured. The renderer test imports sharp: on Linux, run pnpm build:test-sharp first and set BIRDBRAIN_TEST_SYSTEM_SHARP=1 (see "sharp under Electron on Linux" in docs/agents/testing.md). Locally, the e2e suite needs the offline recipe while a running Birdbrain holds port 19845 (#1693, Verification).

Starting files. src/main/services/backgroundRenderer.ts (renderPageInHiddenWindow, the partition setup, FINAL_ARTIFACT_RESERVE_MS, and the comment above createNetworkIdleTracker from #180, which spike question 1 found does not hold for the Network domain), src/main/services/recapture.ts (RenderedPage, DEFAULT_TIMEOUT_MS, and runJob), src/main/services/captureLifecycle.ts (ingestMhtmlCapture and the TLS Cert Chain re-fetch), src/main/services/captureStore.ts (step 3's write method), src/main/services/localAddressPolicy.ts (E2E_LOOPBACK_SWITCH, CE-2's loopback allowance), src/packages/transaction-record/ from steps 7 and 8, src/shared/constants.ts, the renderer spike and the probe spike, scripts/demo-fixture/pages/, tests/helpers/syncPeers.ts and tests/main/services/sharing/syncPeers.test.ts (the two-peer harness), CONTEXT.md (Capture, Transaction Record, and Bound TLS Details), website/content/docs/features.mdx (Capture, Integrity, and Export), the validation record from step 6, and the test files under Verification.

Evidence-affecting: yes (backgroundRenderer.ts, recapture.ts, and captureLifecycle.ts, blocking tier). The PR's Evidence impact section says that it changes acquisition and the signed entry: a background Capture gains a Transaction Record, its hash, Bound TLS Details or the reason they are absent, and the Transaction Record part of the inventory.

Order. Merges only after the release that carries step 1's verifier (ruling 30). In backgroundRenderer.ts, recapture.ts, and captureLifecycle.ts it follows step 6, and in captureLifecycle.ts also #1815, CE-3, CI-4, SC-2b (#1811), and CE-10 (#598, PR #1917). Step 10 follows it in all three files. A revert leaves the Captures written in the meantime with their record, and steps 3 to 5 still carry it.

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

Brief amendment: the maintainer ruled on 2026-10-26 on the text-body spike (#1983, pull request #1988), and this step carries all three rulings.

  1. The collector sends the browser's durable-messages command on every recording session, counts a session as recorded only if the command answered, and keeps a text body only from such a session, so the record holds the bytes the server sent.
  2. A page's copy of a response its service worker answered is recorded as body omitted; the service worker's own session still records it.
  3. This step applies the per-resource size limit itself before keeping a body, and records an oversized body as omitted.

This step's pull request also removes the re-encoded text marker and its info-record sentence that the WARC writer (#1979) adds, because after rule 1 they would mislabel correct bytes.

Where the rulings and the measurements are

The spike page docs/specs/2026-10-26-transaction-record-charset-spike.md (section "Rulings, 2026-10-26") and the step 9 Changes in docs/plans/2026-10-10-capture-engine-first-slice.md, both in pull request #1988. The command is Network.configureDurableMessages; the marker is x-birdbrain-decoded-text.