Caution
Review failed
The pull request is closed.
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.
Closes #1995
When the operator captures a link from a stored page, the new capture's signed record now names the capture that held the link, that capture's content fingerprint and the link's address, and the app checks the source capture against its signed record before it signs anything. The database, the case archive, shared-case sync, the viewer and the report carry the relationship, and the viewer links back with "From a link in" followed by the source's title. A link capture renders in the background, so its signed record also carries the page inventory, scroll, transaction record and PDF facts every background capture now records, and the tests check both sets on one capture. Only a verifier that knows the newer record format reads these fields, and release 1.0.1-beta.24, published on October 30, 2026, is the first release with one, which the link-capture decision record requires before any build writes them. An earlier interactive session opened this draft and applied its labels by hand.
c99efe4d takes main through #2002 (#2016, #2015, #2014, #2006, #2017, #1996, #1999, #2000 and #2002 since 653d6d71). Four files conflicted, each adjacency only: captureLifecycle.ts (the IngestParams fields, the entry context and the insertCapture call), captureRepo.ts (the insertCapture columns), recapture.ts (two import blocks and the ingest call) and recapture.test.ts (imports). Each resolution keeps main's capture engine lines and this branch's link lines side by side. Every line git diff shows the merge dropping from either parent on those files is one the other side had changed, or one re-added with a trailing comma or inside a combined import. main took no schema or archive number (still 43 and 13), so migration 44 and archive 14 stand. main added pdfjs-dist, so the merged tree needs pnpm install.82420164 adds a recapture.test.ts case where a Capture link job, rendered with every capture engine part, signs the link fields beside inventory, scrollToLoad, transactionRecordHash, boundTls and pdfHash; the entry passes the strict schema, the row mirrors both sets and verifyCapture returns verified. A reportCaptureEngine.test.ts case prints the link rows and the capture engine fields from one entry carrying both.653d6d71 takes main through #2011 (#1984, #1985, #1998 and #2007 to #2011 since the last merge base). Four files conflicted, each adjacency only: in export.ts, reportHtml.ts, export.test.ts and reportCaptureEngine.test.ts every hunk's main side was this branch's side minus the link additions, so each resolution keeps both, and each resolved file differs from main only by the link lines. main took no schema number: it is at LATEST_SCHEMA_VERSION 43 and CASE_ARCHIVE_SCHEMA_VERSION 13, so migration 44 and archive 14 stand. An earlier merge of #1985's branch (c7a89225) changed one test now on main, the not-in-Case notice's Capture link, to expect linkedFrom in the payload, because that button calls the same captureLink action as the menu.pnpm db:migration:new link-capture-provenance) adds linked_from_capture_id, linked_from_content_hash and link_href; CAPTURE_COLUMNS, insertCapture and rowToCapture in captureRepo.ts; Capture and LinkProvenance in src/shared/types.ts, whose field comment says a received row holds the entry's source id. Test: linkProvenanceColumnsMigration.test.ts; the drift test passes unchanged.RecaptureJob and RecaptureEnqueuePayload gain linkedFrom (source id and address, never the hash). recapture:enqueue hands a payload carrying it to a new enqueueLink, which refuses unless urls is exactly [linkHref] and the source exists in the job's Case. Before the render, runJob re-checks the address, existence and Case, reads the source's capture entry from the author's chain (it must verify and describe the row, via authorChainOf and entryDescribesRow), refuses a contentHash that is not lower-case SHA-256 hex, then requires verifyCapture to return verified with the same hash. The entry's linkedFromCaptureId is the id the source's own entry signs, as a deletion entry names a Capture (#1815; ADR-0040's Decision records the ruling); the row keeps the source's row id, so the viewer still opens it after an archive import renamed it. IngestParams.linkSource passes through withCaptureEntry, which writes the three fields only when set; fieldMinReaderSchemaVersion from #1981 then stamps 6. Every event runJob emits for a link job carries CaptureEvent.linkedFrom. Tests: recapture.test.ts (the B5 cases plus a legacy source, an entry that names another row, a source whose screenshot alone fails, an import that renames the source, a source received from another Shared Case member, whose entry is read from that member's chain, and the job's events, including an exclusion skip after a redirect that names the final address), linkProvenanceIngest.test.ts (both entry bodies pinned field for field and parsed with the strict schema), ipcHandlers.test.ts.linkHref canonicalization: the field holds the address before canonicalizeUrl, exactly as the viewer read it (the Page tab menu, a Links tab row, or the not-in-Case notice's Capture link) and as the job renders it, and both checks compare exact strings. Documented in the slice 2 plan, B4 step 3; a trailing-slash spelling is refused in recapture.test.ts.importCaptureRows remaps linked_from_capture_id through ctx.mapId; CASE_ARCHIVE_SCHEMA_VERSION goes from 13 to 14 under the constant's own rule. The pins in caseArchive.test.ts, caseArchiveRoundTrip.test.ts and investigationRecordsMigration.test.ts moved, the newer-version refusal cases pass, and the round trip shows the id remapped, not copied.receivedEvidence.ts copies the three fields from the signed entry onto the received row. Test in receivedEvidence.test.ts.useRecaptureMutations forwards linkedFrom; useLinkMenuTarget sends the viewed Capture and the link address; CaptureViewer.tsx shows "← From a link in <title>" (or the URL when there is no title) while the Case holds the source. The queued notice says the source is checked before the link renders. A refusal when the job runs raises a "Link not captured" notice, matched to the job by source === 'recapture' and the event's linkedFrom (source id and link address), not by the event's address. A refusal that reaches the viewer before the queue's reply suppresses the queued notice, and a failed request removes the listener. Tests: MhtmlViewer.test.tsx pins the enqueue payload, the refusal notice, a refusal that beats the reply, the listener cleanup on a failed request, the redirect skip, and silence for a same-address Recapture, a same-address link from another page and another route's event; LinksTab.test.tsx the Links tab payload; CaptureViewer.test.tsx the header.reportHtml.ts adds "From a link in", "Link source SHA-256" and "Link address" beside "Supersedes" and "Duplicate of". They come from the signed entry through resolveLinkSources and resolveExportLinkSources (export.ts), over the same chain verdicts resolveExportCaptureEngine uses; over a chain that did not verify the report says the source is not reported. Tests: reportCaptureEngine.test.ts (including an accepted Shared Case member's link source, which fails if the remote verdict is false), and export.test.ts, where a Database Admin edit of link_href and linked_from_content_hash does not reach report.html, and a link entry edited in manifest.jsonl prints only the not-reported note (fails if the local verdict is true)._planned:_.e2e/mhtml-link-interactivity.spec.ts gains a test that right-clicks a link the Case does not hold, chooses Capture link and reads the new Capture's "From a link in" header. https://example.test/main resolves nowhere, so the spec answers it with protocol.handle in sessions created after the viewer opened.main: this branch adds readLinkSource, enqueueLink, the linkedFrom tag on a link job's events and one linkSource pass-through in each ingest call, and does not touch backgroundRenderer.ts.resolveExportLinkSources verifies the local chain a second time beside resolveExportCaptureEngine, which a shared verdict would save; (5) follow-up candidate, read from appStore.ts and not run: the store pairs a terminal event with its received entry by address, so a Recapture's exclusion skip after a redirect, which names the final address, leaves the job's received entry in Capture Health; (6) two Capture link clicks on the same link from the same page give two listeners that both end on the first job's outcome. (7) follow-up candidate, read from receivedEvidence.ts and not run: a row received through Shared Case sync mirrors the three link fields but none of the capture engine fields, on main as on this branch; the signed entry carries both sets, and the report reads the entry.Yes
The gate fired on both triggers: issue #1995 carried the evidence-affecting label when I read it in this round, and the diff touches blocking-tier paths (recapture.ts, captureLifecycle.ts, manifest.ts, caseArchive.ts, reportHtml.ts, export.ts, sharing/receivedEvidence.ts). The same read showed the label on this pull request, applied in the earlier session that opened it; this round changes no label, and keeping it is the coordinator's to confirm.
linkedFromCaptureId, linkedFromContentHash and linkHref in its signed capture entry, stamped schema 6. The entry claims that the Operator chose Capture link on a link in that source and that the link named that address (ADR-0040, "What the record claims"). The hash comes from the source's verified entry, never from the editable row, and linkedFromCaptureId is the id that entry signs. report.html prints the three fields only from the signed entry over a chain that verified: export.test.ts edits the link entry in manifest.jsonl and finds only the not-reported note, and reportCaptureEngine.test.ts prints an accepted member's source as verified. Every other entry keeps its canonical body: linkProvenanceIngest.test.ts pins an ordinary entry with none of the fields at its existing stamp, and recapture.test.ts shows a Recapture of a link Capture carries none. Since the merge of the capture engine stack, a link Capture's entry also carries that render's capture engine fields; recapture.test.ts parses such an entry with the strict schema and verifyCapture returns verified on it.recapture.test.ts asserts verifyCapture returns verified on a link Capture, including one linked from a source another Shared Case member authored, whose entry the writer reads from that member's chain. Verification does not show that the source still holds the link, or that linkedFromCaptureId resolves to a Capture in the package. The source's stored bytes, matched by linkedFromContentHash, are the evidence that it held the link.manifestSchema6TooOld.test.ts covers that. Case Archive 14 is refused by a build that knows only 13, so an older Birdbrain cannot drop the columns silently. The linkedFrom tag is on activity events only, which the main process sends to the app window and to nothing that is signed or exported.manifestSchema6LinkProvenance.test.ts. This branch adds no reader rule; it pins the body the writer produces in linkProvenanceIngest.test.ts, which that schema parses.pnpm preflight at 824201645e2d20973a89dc7d8aaeae8966688055 on Node v20.20.2; diff scored against origin/main (merge base 18f295049). 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 matchpnpm lint:adr - pass (exit 0)vale-prose --base - pass (exit 0) - 3 changed Markdown files checkedpnpm typecheck - pass (exit 0)pnpm build - pass (exit 0)pnpm build:extension - skipped - no extension/ changespnpm test:coverage - pass (exit 0) - 8618 passed, 25 skipped (8643), thresholds metpnpm coverage:diff - pass (exit 0) - 100.00% of 100 changed lines covered (floor 90%); 1 changed source file not instrumentedCreated with Claude Opus 5.5 in Claude Code (T3 Code)
Added 15 commits
Caution
The pull request is closed.
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.
Written by claude-opus-5-5 via Claude Code on behalf of Matt.
Review verdict: request changes
Reviewed commit: c531f67f8f25b407b70ccfd9cc4488d677508092 (head unchanged at end of review). Own diff: b4809c04..c531f67f (b4809c04 merges #1981 into the base; it differs from a fresh git merge-tree of the two only by the tests/verifier/binary.test.ts import resolution, which keeps both sides' imports correctly).
| # | severity | file:line | finding |
|---|---|---|---|
| 1 | blocking | src/main/services/recapture.ts:165; tests/main/services/recapture.test.ts "skips a source whose stored page no longer verifies" | The status !== 'verified' half of the source check is unpinned. With || mutated to && all 46 branch tests in recapture.test.ts pass; a scratch test that alters only the source's screenshot (verifyCapture returns tampered, the MHTML still matches the entry) fails only under the mutant. |
| 2 | advisory | src/main/services/reportHtml.ts:1602-1604 | "From a link in", "Link source SHA-256" and "Link address" read the editable captures row (Capture[], reportHtml.ts:323), as "Supersedes" and "Duplicate of" (:1600-1601) and "Text digest" (:1622) do; the capture engine rows read the verified entry (resolveCaptureEngineFacts, :159). |
| 3 | advisory | src/renderer/components/captures/useLinkMenuTarget.ts:56; src/renderer/components/status/CaptureHealth.tsx:94 | A run-time refusal follows a "Link capture queued" toast; the reason appears only in the Capture Health list. Disclosed as PR finding (5). |
| 4 | advisory | src/main/services/recapture.ts:169 | linkedFromCaptureId signs the source's row id. After an id-collision archive import that differs from the captureId in the source's own entry; the deletion path signs the entry's id (signedCaptureId, #1815), the Duplicate path the row id (captureLifecycle.ts:1653). Disclosed as PR finding (4); needs a ruling. |
| 5 | advisory | src/main/services/recapture.ts:205 vs :230; comment at :399 | The source is checked before the render (up to 120 s, :73), so a source deleted or altered during the render is still named. What is signed stays true, because the hash comes from the signed entry; the comment "closest to the signing" overstates slightly. |
| 6 | advisory | src/renderer/components/captures/LinksTab.tsx:138 | No test pins the Links tab's captureId; only MhtmlViewer.test.tsx:173 pins the payload. A wrong id is refused by main ("Link source not found"), so a mistake here fails closed. |
| 7 | advisory (equivalent) | src/main/services/recapture.ts:146, :406 | Probe survivors. At :146 a broken own chain is still refused by verifyCapture at :165. At :406 a non-string linkHref is still refused by the urls check at :410, with a different message. |
Failure scenario for 1: a later change drops status !== 'verified' as redundant next to the hash comparison. A link job then signs a source whose screenshot or text file was altered or deleted, or whose row hash was edited (chain-broken), and no test fails. ADR-0040 requires a source that "verifies against the chain". The signed hash would still be true of the page bytes, so the defect is the ADR rule going unenforced, not a false fingerprint. Fix: one test with a sidecar-altered source.
Failure scenario for 2: an admin edits link_href or linked_from_content_hash in Settings → Database, then exports a package. The report prints the edited value under "Link address" or "Link source SHA-256". The signed entry in manifest.jsonl still holds the original, and the report tells readers to compare against the entries. The mechanism matches the existing rows. It weighs more here because a 64-hex SHA-256 line reads as a checked digest, and the Operator statement (:1562) says the digests reproduced are those of the verification run, which never recomputes the source's hash. Reading the three fields from the verified entry, as #1985 does, closes it.
Checklist answers: (1) The hash comes only from the parsed, chain-verified entry (:144-158). Hex is checked at :159 and bytes plus status at :165. Every refusal returns before the render (:205-216), and the only route to signing passes linkProvenance at :301. (2) The address is compared as an exact string at enqueue (:410) and at run time (:132). The render loads job.url (backgroundRenderer.ts:540). A redirect changes only url/finalUrl. #1990's private-name rule refuses the job at enqueue or at the hop and cannot change the address. (3) The archive is at version 14 with the gate at caseArchive.ts:769, the remap is at captureRepo.ts:598-599, migration 44 skips present columns, and the drift test passes. (4) receivedEvidence.ts:275-277 copies the three fields from the signed entry. A scratch test also confirmed a member can link from a teammate's received source and the result verifies. (5) See finding 2. (6) The protocol.handle page server lives only in the e2e spec (:573-574). The packaged files list is out/**, resources/**, package.json and node_modules/**, the fixture is per test, and viewer partitions exist from startup (index.ts:223), so only the hidden render's recapture-<uuid> session (backgroundRenderer.ts:404) gets it. It handles only https, and the sentinel is http on 127.0.0.1, so the zero-request assertions still count.
Verification (local, Node 20.20.2, scratch worktree at head): 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 exited 0 with 8032 passed and 25 skipped. A first run without the system sharp failed setup on libOpenEXR, as expected. pnpm coverage:diff exited 0 at 252/252 lines (100%). xvfb-run -a pnpm test:e2e e2e/mhtml-link-interactivity.spec.ts exited 0 with 6 passed. CI at head: test, e2e, lint, typecheck, build and probe pass. merge-gate fails with "evidence-affecting with no sign-off at head", as designed.
Controls: evidence-affecting is on both the PR and #1995. Auto-merge is null. No Co-authored-by trailer appears on the 7 own commits. The attribution is the maintainer byline, which the post-pr-body skill accepts. Evidence impact is substantive and justifies the missing KAT by #1981's frozen digest plus a writer body test parsed by the strict schema. Advisory-tier hits (ipcHandlers.ts, shared/ipc.ts, shared/types.ts) carry the link payload and types, and the blocking-tier review already covers them.
Added 7 commits
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 ruling of 2026-10-26 on the source id.
319417d7 (finding 1), 62c7ade9 (finding 2), 3290c2ee (finding 3), ea051e53 (finding 4), 9715f9a3 (finding 5), 5db2bc69 (finding 6)
Added 14 commits
Review verdict: request changes
Reviewed commit: 0675f94a8bdbedbfd5620ad52e85c80861a5ef14 (head re-read at end: unchanged). Delta from c531f67f: six own commits 319417d7..5db2bc69, then two merges. b809fcd6 (base at 19267c2c) and 0675f94a (46c61427) each equal a fresh git merge-tree of their parents; 0675f94a changes no file.
| # | severity | file:line | finding |
|---|---|---|---|
| 1 | blocking | PR body Summary; ADR-0040 on main, "The fields need schema 6" | The newest tag is v1.0.1-beta.23, which does not contain #1981 (55c73c87). ADR-0040 (via ADR-0023) says the release that knows the fields ships before any build writes them, and the body says "must not merge before that release ships". On this sha, approving means merging. |
| 2 | blocking | src/main/services/export.ts:143-155 | resolveExportLinkSources local verdict unpinned. Mutating resolveLinkSources(manifest.entries, localVerified) to true leaves export.test.ts and reportCaptureEngine.test.ts green (160/160), and so does mutating the remote true to false. The base pinned the same gate for engine fields in e1dcee4c ("quotes none of the fields in report.html when the local chain does not verify") after the same revert passed. |
| 3 | blocking | docs/adr/0040-…md:8; ADR-0040 on main lines 8-11; CONTEXT.md on main:345 | At head the ADR says "Nothing in it is implemented." beside the PR's own Decision edit (:40-43). A trial merge with main auto-merges to "No build writes the fields yet. … the writer is planned in …", and the glossary to "planned: it names the Capture …". Both are false once this lands. |
| 4 | advisory | src/renderer/components/captures/useLinkMenuTarget.ts:56-62 | The listener filters on url only, not source === 'recapture'. Another job for the same address (extension auto capture, a Recapture, a second click) ends it or raises a false "Link not captured: …". The exclusion skip at recapture.ts:266 names rendered.finalUrl, so after a redirect into an excluded URL the listener never ends and fires on a later unrelated event. |
| 5 | advisory | docs/adr/0040-…md:43; recapture.ts:169 | "The maintainer ruled this on 2026-10-26" has no record on #1995 or #1997. The maintainer should confirm it. The code matches the ruling as stated. |
| 6 | advisory | PR body, Changes | Stale text: "#1987 and #1990 edit …" (#1987 merged as 07ffcea6), "one linkProvenance pass-through in each ingest call" (now linkSource), and "also holds the link provenance verifier change, so it merges after both" (#1981 merged as 55c73c87). |
| 7 | advisory | src/main/services/export.ts:147 | A second full-chain verification per export runs on the main process. The PR discloses it as finding (4). |
Failure scenario for 2: a later change drops the verdict and passes true, as the engine path once did. A package whose manifest.jsonl was edited (say linkHref or linkedFromContentHash in the link entry, which breaks the chain) then prints the edited address and a 64-hex "Link source SHA-256" instead of "not reported: the manifest chain did not verify", and no test fails. Fix: copy e1dcee4c's shape for a link Capture.
Failure scenario for 4: the operator chooses Capture link on an address that a Recapture is already rendering. That job's failed event raises "Link not captured: ", and the link job then stores the page. Or that job's stored event ends the listener, and the link job's refusal is visible only in Capture Health.
Corrected wording for 3: ADR status paragraph "…accepted it on 2026-10-26, amended to schema 6 (see "The fields need schema 6"). The verifier reads them from #1981 and Capture link writes them from #1995." Glossary: drop "planned: " so the line reads "…it is a new sighting; it names the Capture whose stored page held the link (ADR-0040)".
Earlier findings, each fix mutated (perl substitution, then the named files rerun, then restored):
status !== 'verified' || changed to &&: "skips a source whose screenshot no longer verifies, though its page does" fails.chainVerified check dropped: 1 to 3 tests fail in reportCaptureEngine.test.ts and export.test.ts.rowId line dropped: the import-rename and linkProvenanceIngest tests fail.Agreement with #1981 as merged: the capture-entry schema region and fieldMinReaderSchemaVersion are byte-identical to main. Field names, the all-or-none rule, the stamp-6 refine, sha256HexField (^[0-9a-f]{64}$, matching recapture.ts:120 and checked at :160) and linkHref min(1) all agree. The verifier passes linkedFromCaptureId through without resolving it, so the entry-id ruling cannot break verification.
Verification: gh pr checks 1997 at head shows test, e2e, lint, typecheck, build and probe passing. merge-gate fails for missing sign-off, as expected. agent/pre-pass is pending, which is this run. Locally (Node 20 via mise, system sharp, scratch tree), the 17 touched test files ran: 16 passed and 1 skipped, with 711 tests passed and 22 skipped. The skipped file is tests/verifier/binary.test.ts, which needs a built verifier binary, and CI ran it. Lint, build and full coverage were not re-run locally (scope set to touched tests). CI's test job, which includes diff coverage, is green. The probe flagged 6 tests that pass on revert, all #1981 refusal or absence guards and none a finding, and 0 of 40 probed mutants survived (29 were not run for time).
Trial merges: with origin/feat/1927-report-capture-engine-fields (c7219ba3) the merge is clean. The base gained only tests since 19267c2c. On the merged tree, export.test.ts and reportCaptureEngine.test.ts pass 164/164 and pnpm typecheck exits 0. With origin/main (14bf7977), 14 files conflict, and the base alone conflicts with main in 8 of them. That is expected from #1980/#1981 squash-merging separately. The core.ts conflict is LATEST_SCHEMA_VERSION 44 against 43, so no migration number collides.
Evidence gate: evidence-affecting is on the PR and on #1995. Blocking-tier paths touched in the delta: recapture.ts, captureLifecycle.ts, reportHtml.ts, export.ts. The Evidence impact section is substantive and current. The KAT is justified by #1981's frozen body plus the writer body test. Backward verification holds: entries without the fields are unchanged. Controls: auto-merge null; no Co-authored-by on any commit since c531f67f; the body passes post-pr-body check.sh and ends with the maintainer byline. Bot surface: no reviews or inline comments; CodeRabbit skipped the draft.
Added 7 commits
Dispositions for the review verdict above (comment 6451496026), at the latest commit.
resolveLinkSources(manifest.entries, localVerified) to true fails export.test.ts "quotes none of a link Capture's source in report.html when the local chain does not verify"; mutation resolveLinkSources(remote, true) to false fails reportCaptureEngine.test.ts "prints an accepted member's link source from that member's entry". Each run: 1 failed, 165 passed.CaptureEvent.linkedFrom is set on every event runJob emits for a link job; useLinkMenuTarget requires source === 'recapture' and a matching linkedFrom.captureId and linkedFrom.linkHref. Mutations each fail one test: dropping the source check, matching by url again, dropping the captureId check (MhtmlViewer.test.tsx), and emitting events untagged or tagging every job (recapture.test.ts). Left out of scope and listed in the description: the store pairs a Recapture's terminal event with its received entry by address, so the exclusion skip that names the final address leaves a received entry in Capture Health (read from appStore.ts, not run). Two clicks on the same link from the same page still share the first job's outcome.Review verdict: request changes
Reviewed commit: c7a892257a3ed8800b52d78b70dc7f4092cdae15 (head re-read at the end: unchanged; auto-merge null). Delta from 0675f94a: fe0325e4 (merge), 75cf6bd2, ab4c3029, 34f29a2c, c7a89225 (merge).
| # | severity | file:line | finding |
|---|---|---|---|
| 1 | blocking | ADR-0040 "The fields need schema 6"; PR body Summary | Still open. The newest tag is v1.0.1-beta.23 and git merge-base --is-ancestor 55c73c87 v1.0.1-beta.23 fails, so no tag carries the schema-6 verifier. ADR-0040 says "The release that knows the fields ships before any build writes them", and the body says the change "must not merge before that release ships". Nothing in the branch can fix this; tagging is the maintainer's step. |
| 2 | advisory | PR body, Changes, "Stack" bullet | "merged in at 76f8b4b6, which holds main through #1984" is not true: #1984 is open. 76f8b4b6 holds main through #1982 (a0d938b1) plus #1984's head 93b5fe8f. Corrected: "merged in at 76f8b4b6, which holds main through #1982 and #1984's branch (93b5fe8f), and so the schema-6 verifier (#1981, 55c73c87), #1987 and #1990." |
| 3 | advisory | docs/adr/0040-…md:41 | Carried from the last round: "The maintainer ruled this on 2026-10-26" still has no record on #1995 or #1997. The maintainer should confirm it. The code at recapture.ts:169-170 matches the ruling as stated. |
| 4 | advisory | src/renderer/components/captures/useLinkMenuTarget.ts:98 | The one uncovered changed line: onError: () => stop(). Removing stop() would leave a listener behind after a failed enqueue and no test would fail. The failure itself still reaches the operator through the MutationCache handler (queryClient.ts:33-36). |
| 5 | advisory | docs/plans/2026-10-04-…-slice-2-…md, B4 step 3 | The added line names two readers of linkHref: the Page tab's params.linkURL and a Links tab row's href. After the merge, a third reader exists: the not-in-Case notice's Capture link (MhtmlViewer.tsx:188, :251), which sends the click's navigation URL (guestLink.ts:104 passes it through raw). The ADR-0040 wording ("as the viewer read it from the stored page") still holds. Plans may go stale, so this is a nit. |
| 6 | advisory | src/main/services/export.ts:147 | Carried, and already disclosed as the body's out-of-scope (4): a second full-chain verification per export. |
Finding 2 of the last round (chain gate untested): resolved. These mutations ran in my scratch tree with perl, each followed by export.test.ts and reportCaptureEngine.test.ts, then a restore:
true: 1 failed, the "quotes none … when the local chain does not verify" testtrue to false: 1 failed, the "accepted member's link source" test.slice(manifest.entries.length) dropped: 1 failed, the tampered-chain testchainVerified render guard dropped (reportHtml.ts): 2 failedFinding 3 of the last round (ADR and glossary): resolved. ADR-0040:8-9 reads "The verifier reads the fields from #1981 and Capture link writes them from #1995." CONTEXT.md:345 has no _planned:_. Both claims hold: 55c73c87 is on main, and the writer is on this branch.
Finding 4 of the last round (notice matched by address): resolved. recapture.ts:192-196 wraps every runJob emit; the only bare deps.emitEvent left is the enqueue-time exclusion at :388, which also returns as a rejection that the hook handles. I ran five mutations: untagged events, the redirect-skip emit untagged, the linkHref check dropped, the source check dropped and the captureId check dropped. Each fails exactly one test, in recapture.test.ts or MhtmlViewer.test.tsx.
Evidence gate on 34f29a2c. It changes the shape of events only. CaptureEvent.linkedFrom (types.ts:1398-1400) goes from recapture.ts to sendEvent(... CAPTURE_ACTIVITY ...) at index.ts:657-661, and then into appStore.captureEvents, which is in memory and not persisted (appStore.ts:51, 293-317). It never reaches the manifest, the signed entry, the database or an export. No signed field changed.
Merge c7a89225 against git merge-tree --write-tree 34f29a2c 76f8b4b6 (01c83964). The same 8 files conflict. Each resolution:
followCaptureLink.{ toastWayback: true }.linkedFrom and the Back trail (rendered at :204-229).captureId), and fixes the auto-merged ), into )\n},.linkedFrom to the not-in-Case Capture link.git diff 76f8b4b6 c7a89225 removes no line from the e2e spec, CaptureViewer, LinksTab or MhtmlViewer. Lines starting with it(/test(, counted per file as merge base / ours / theirs / merged, give merged = ours + theirs - base every time:
| file | base | ours | theirs | merged |
|---|---|---|---|---|
| e2e spec | 5 | 6 | 6 | 7 |
| MhtmlViewer | 14 | 15 | 23 | 24 |
| LinksTab | 21 | 22 | 32 | 33 |
| CaptureViewer | 28 | 30 | 30 | 32 |
recapture, export and reportCaptureEngine are unchanged by the merge.
Verification. gh pr checks 1997 at the end:
The CI test job ran on ubuntu-24.04: 8305 passed and 25 skipped, and diff coverage was 172 of 173 lines (99.42%). The probe found 3 tests that still pass with the change reverted. All 3 are absence guards, so none is a finding. Of the mutants, 0 of 40 survived, 28 were caught and 12 were not run for time.
Local runs used Node 20 via mise, system sharp and a scratch tree:
pnpm build: exit 0e2e/mhtml-link-interactivity.spec.ts under xvfb-run -a: 7 of 7 passed, including the Back-trail and Capture link testsNot run locally: the full suite with coverage and pnpm lint. CI's test and lint jobs are authoritative here and are green.
Trial merge with origin/main f787d88d (#1998). git merge-tree is clean. On the merged tree, pnpm typecheck exited 0. Eleven test files passed with 605 tests: LinksTab, MhtmlViewer, CaptureViewer, ipcHandlers, preload bridge, recapture, waybackLinkCheck, waybackBatchModel, waybackLinkBatch, export and reportCaptureEngine. #1984 (93b5fe8f) and #1985 (76f8b4b6) are still open, and both heads are ancestors of this head.
Evidence gate.
evidence-affecting is on the PR and on #1995.Controls.
Co-authored-by trailer on any of the 65 commits in origin/main..HEAD.--paginate: 5 issue comments, 0 reviews and 0 inline comments. CodeRabbit skipped the PR as a draft, so it left no findings to judge.Is everything except the tag resolved? Yes. Findings 2-4 from the last round are fixed and mutation-checked. What remains is advisory, and the description line in finding 2 is a one-line edit.
Review verdict: request changes
Reviewed commit: c7a892257a3ed8800b52d78b70dc7f4092cdae15
Reviewed commit and head check
c7a892257a3ed8800b52d78b70dc7f4092cdae15 on feat/1995-link-capture-writer. I re-read the head at the end of the review: it is still c7a89225, auto_merge is null and mergeable_state is dirty.a0d938b1) therefore also shows #1984 and #1985 content, and so does pnpm coverage:diff. To isolate what this PR actually adds, I simulated the merge with origin/main (aa87bd36) and reviewed that result: 33 files, +1460/-46.Findings
| # | Severity | file:line | Finding |
|---|---|---|---|
| 1 | gate | src/main/services/export.ts, src/main/services/reportHtml.ts, tests/main/services/export.test.ts, tests/main/services/reportCaptureEngine.test.ts | The branch conflicts with main. GitHub reports mergeable:false, mergeable_state:dirty. The PR head does not contain #1985's final head 7fba3c39. |
| 2 | blocking (merge timing) | ADR-0040's last paragraph of "The fields need schema 6"; ADR-0023 | No tagged release can read schema 6 yet. The newest tag is v1.0.1-beta.23, and its src/shared/schemas.ts contains no linkedFromCaptureId. The ADR requires the reader release to ship before any build writes the fields. Only the draft state and the maintainer's judgement hold the merge back; merge-gate checks sign-off, not release order. |
| 3 | advisory | src/renderer/components/captures/useLinkMenuTarget.ts:89-93 | Toasts can arrive in the wrong order. If a refusal event arrives before the IPC reply (the case the comment at :71-74 says is handled), warned is set and the listener stops. But the accepted > 0 branch never checks warned, so "Link capture queued ... Its source is checked before the link renders" appears after the "Link not captured" warning. The most likely trigger is a legacy source with no manifest entry: readLinkSource refuses it before any await. I did not reproduce this, and no test covers a refusal that arrives before the reply. |
| 4 | advisory | src/renderer/components/captures/useLinkMenuTarget.ts:98 | onError: () => stop() is the only uncovered changed line (172 of 173 covered). Nothing tests listener cleanup when the IPC call rejects. |
| 5 | advisory | src/main/services/recapture.ts:139-143 | The teammate-source branch has no writer test. When the source was authored by another Shared Case member (authorInstallationId non-null), readLinkSource goes through authorChainOf. No recapture.test.ts case uses such a source. The logic matches verifyCapture (captureLifecycle.ts:533), so this is a test gap, not a suspected bug. |
| 6 | advisory | PR body, Summary and "Stack" bullet | The body is out of date. It says the branch is stacked on #1985 and merges after it. #1985 merged on 2026-10-27 at 20:14Z, and the branch now conflicts with main (finding 1). |
| 7 | advisory | src/shared/types.ts:352 | The field comment does not hold for received rows. It says the row's linkedFromCaptureId is the source's row id. On received rows it is copied from the entry (receivedEvidence.ts:275), so it is the entry's id. That equals the receiver's row id for a received source, but not when the source is the receiver's own Capture that an import renamed. In that edge case the viewer header is hidden; nothing evidentiary changes. |
| 8 | advisory | src/main/services/export.ts:143 | resolveExportLinkSources verifies the local chain a second time alongside resolveExportCaptureEngine. The PR's own findings list notes this as item (4). The extra work is O(n), done once per export. |
The conflicts in finding 1
All four conflicts are adjacency conflicts: main's side is the PR side minus the link additions. Keeping the HEAD side of each conflict produced files byte-identical to the PR head, so the conflicts carry no semantic difference. On that resolved tree, pnpm typecheck exits 0, and these six test files pass, 505 tests: LinksTab, ipcHandlers, MhtmlViewer, recapture, reportCaptureEngine, export. The merge commit is still a new head, and it needs a delta review and fresh CI.
What I checked and found sound
Writer: recapture.ts readLinkSource (:129-172) and enqueueLink (:407)
job.url is never normalized: enqueue and validateUrl leave it alone, and the job renders it as given (renderPage(job.url) at :236). So the exact-string linkHref check compares like with like.entryDescribesRow, as Duplicate does.verifyCapture must return verified with computedHash === contentHash, which also covers a source whose screenshot alone was tampered.linkedFrom, and the renderer handles them through onSuccess.runJob emits for a link job carries the tag.Ingest: captureLifecycle.ts
withCaptureEntry (manifest.ts) writes the fields only when they are set.Report
add() values are escaped at render (reportHtml.ts:1829).resolveExportCaptureEngine.Archive and migration
importCaptureRows remaps the id as it does for duplicate_of.CASE_ARCHIVE_SCHEMA_VERSION goes to 14.LATEST_SCHEMA_VERSION goes to 44.Doc and comment claims, each checked against the code
linkedFromCaptureId and #1815: matches signedCaptureId (captureLifecycle.ts:796).canonicalizeUrl drops the fragment and trailing slash: true (urlCanonicalize.ts:12-13,35).linkedFrom tag reaches only the app window: true (index.ts:657-660).caseArchive.ts comment that an older build drops the columns without error: true; import is driven by CAPTURE_COLUMNS._planned:_: true once merged.Scope
Verify loop (pinned sha, Node v20.20.2, tool shell bash)
| Check | Result |
|---|---|
pnpm lint | exit 0 (0 errors, 47 warnings) |
pnpm typecheck | exit 0 |
pnpm build | exit 0 |
BIRDBRAIN_REQUIRE_OPENSSL=1 BIRDBRAIN_REQUIRE_JQ=1 xvfb-run -a pnpm test:coverage | exit 0: 486 files passed, 2 skipped; 8229 tests passed, 25 skipped. Matches the PR's figures. |
pnpm coverage:diff | PASS: 99.42% (172 of 173 lines). Matches the PR. |
xvfb-run -a pnpm test:e2e e2e/mhtml-link-interactivity.spec.ts | exit 0, 7 of 7 passed, including the new "Capture link files a Capture that names the stored page" |
Vale (.claude/hooks/vale-prose.sh --base origin/main) | Not run: Vale is not installed on this runner. CI lint is green, and the PR's preflight reports it passing. |
pnpm build:extension | Not needed: extension/ is unchanged. |
CI at the head sha: every required check is green. merge-gate failed twice. The log says this is the expected evidence sign-off block: "evidence-affecting with no sign-off at head".
Test reliability probe at this head: 0 of 40 probed mutants survived, 28 were caught, and 12 did not run within the time budget. Three tests still pass with the source changes reverted:
Evidence gate
evidence-affecting, and so does issue #1995.recapture.ts, captureLifecycle.ts, manifest.ts, db/**, caseArchive.ts, export.ts, reportHtml.ts, sharing/receivedEvidence.ts.src/main/ipcHandlers.ts, the recapture enqueue handler: one line routing linkedFrom payloads to enqueueLink. It does reach the writer, but the enforcement is in blocking-tier recapture.ts, and the PR is already labelled.src/shared/ipc.ts, RecaptureEnqueuePayload and LinkedFrom: type only.src/shared/types.ts, Capture fields, LinkProvenance and CaptureEvent.linkedFrom: types and comment only (see finding 7).manifestSchema6LinkProvenance.test.ts freezes the canonical body and digest. On this branch, linkProvenanceIngest.test.ts pins both bodies field by field against the strict schema, and recapture.test.ts asserts that verifyCapture returns verified on a link Capture. I accept the justification.Completion controls
check.sh --any-author on the body exits 0, and there is no attribution line mid-body.null.Co-authored-by trailers: none. git log origin/main..HEAD --format=%B | grep -i co-authored-by exited 1 over all 65 commits on the branch.What I could not check
Friction notes
git diff origin/main...origin/<branch>). After the PR's stack base was squash-merged into main, the prescribed three-dot diff also showed #1984 and #1985 content, and the merge base used by coverage:diff was stale. Cost: I simulated the merge (git merge-tree, then a resolved scratch merge) to get the PR's net diff, and typechecked and tested that tree separately. Fix: add a line saying that when mergeable_state is dirty or the stack base has squash-merged, the reviewer should review against a simulated merge with origin/main.Added 5 commits
Dispositions for the review verdict above (comment 6459247348), at the latest commit.
origin/main at 2d5f1594. Conflicts in export.ts, reportHtml.ts, export.test.ts and reportCaptureEngine.test.ts; in every hunk the main side was the branch side minus the link lines. Each resolved file equals the pre-merge branch head c7a89225, and git diff origin/main on each removes only a line it replaces with a superset (an import list, the engine: line). main is at LATEST_SCHEMA_VERSION 43 and CASE_ARCHIVE_SCHEMA_VERSION 13, so migration 44 and archive 14 are unchanged.settled, set on the job's terminal event, gates both the queued notice and the reject-path warning. New test in MhtmlViewer.test.tsx: dropping if (settled) return fails it on the queued notice; changing onError: () => stop() to onError: () => {} fails it on the leftover listener. Each mutation: 1 failed, 24 passed.recapture.test.ts describe with two peers and a sync pull; forcing authorInstallationId: null in readLinkSource fails it with a skipped event (1 failed, 49 passed).receivedEvidence.ts setting linkedFromCaptureId: entry.linkedFromCaptureId.Dispositions for the open items of the review verdict above (comment 6452349263), at the latest commit.
Review verdict: approve for human review
Reviewed commit: 4ea0274dd206458f270aaf484585e1552155f82e (head re-read at the end: unchanged; auto_merge null; mergeable_state blocked by merge-gate only)
Previous reviewed commit: c7a892257a3ed8800b52d78b70dc7f4092cdae15. Delta: 653d6d71 (merge of main at 2d5f1594), 315fc942, 4b70cc7e, a674fcb7, 4ea0274d.
| # | severity | file:line | finding |
|---|---|---|---|
| 1 | merge condition (not a code blocker, by coordinator instruction) | docs/adr/0040-…md:67-69; PR body Summary sentence 3 | No tag carries the schema-6 verifier: the newest tag is v1.0.1-beta.23, and git merge-base --is-ancestor 55c73c87 v1.0.1-beta.23 fails. 55c73c87 is on main and on chore/release-1.0.1-beta.24 (437135ba, PR #2006, open, unmerged). The body states this accurately. |
| 2 | advisory | src/renderer/components/captures/useLinkMenuTarget.ts:90 | Deleting if (accepted === 0) stop() leaves all 25 MhtmlViewer tests green. If the queue refuses at enqueue time (for example, the case exclusion list), the listener stays registered. A later refusal of the same link from the same page then warns twice. The test at MhtmlViewer.test.tsx:192 has no listener bookkeeping, so the gap predates this round, but this round rewrote the line. The body claims cleanup only for a failed request, which is tested (mutation M2). |
| 3 | advisory | src/renderer/components/captures/useLinkMenuTarget.ts:82-83 | Moving settled = true below the stored return also leaves every test green. A stored event that beats the reply would then raise "Link capture queued" after the Capture already exists. This is unlikely because a render takes seconds. |
| 4 | advisory (carried) | docs/adr/0040-…md:41 | "The maintainer ruled this on 2026-10-26" still has no record on #1995 or #1997. The code matches the ruling as stated (recapture.ts:169-170). |
| 5 | advisory (carried, disclosed as out-of-scope item 4) | src/main/services/export.ts:147 | resolveExportLinkSources verifies the local chain a second time, beside :129. |
Earlier blockers, each mutated by me (perl edit, single test file, git checkout restore; script run under bash -c):
if (settled) return dropped: MhtmlViewer 1 failed / 24 passed (the new test at :308).onError: () => stop() changed to onError: () => {}: 1 failed / 24 passed (same test, on the listener count).settled = true dropped: 1 failed / 24 passed.authorInstallationId: null forced in readLinkSource (recapture.ts:141): recapture 1 failed / 49 passed (the teammate test at :839).if (accepted === 0) stop() dropped (finding 2), and M6, settled set after the stored check (finding 3).Logic of 315fc942. recapture.ts:353-363: drain() starts runJob synchronously. runJob emits received, which the listener ignores, then awaits readLinkSource. That function returns { refused } for a source with no manifest entry before its first await (:138). The tagged skip is therefore emitted one microtask after enqueue returns, and it can beat the IPC reply. The commit message says "can", which is accurate. Enqueue-time exclusions (:388) carry no linkedFrom, so settled cannot be true when accepted === 0, and the listener never double-warns.
Comment and doc claims in the delta.
outcome.url, which guestLink.ts:78 and :104 pass through unchanged.Merge 653d6d71. I rebuilt it with git merge-tree --write-tree c7a89225 2d5f1594. The same four files conflict, and no other file differs from the automatic result. Each of the four resolved files is byte-identical to c7a89225. Main touched these files only through #1984 and #1985 (git log a0d938b1..2d5f1594). git diff 2d5f1594 653d6d71 on them deletes only lines that it re-adds as supersets: the reportHtml import list in export.ts, engine: gaining , linkSource: in reportHtml.ts, and the import in reportCaptureEngine.test.ts. export.test.ts is additions only. No main-branch content was dropped. Main is at schema 43 and archive 13; the branch moves them to 44 and 14.
PR body, Summary sentence by sentence.
verifyCapture run before ingest.thebristolsound added agent-authored and evidence-affecting at 22:48:31Z on 2026-10-26. The post-pr-body skill permits a handoff sentence.I found no false claim in the Summary. Nit: the clause "and the session coordinating this review round posts the updated description" describes this round only, and it will read stale in the squash commit. Optional rewording: "An earlier interactive session opened this draft and applied its labels by hand."
git log a0d938b1..2d5f1594, the schema numbers match, the MhtmlViewer test list matches, the teammate-source claim matches (M5), and the test counts match CI.Verification.
gh pr checks 1997:xvfb-run: the 13 test files the PR touches against main passed, 761 tests, exit 0.Evidence gate.
evidence-affecting is on the PR and on #1995.Capture fields with no evidence behaviour.Controls.
git log origin/main..HEAD --format=%B | grep -ic co-authored-by prints 0 over 70 commits.--paginate: 0 reviews, 0 inline comments and 9 issue comments. The only bot comment is CodeRabbit's draft skip, which has no findings.Review verdict: approve for human review
Reviewed commit: 4ea0274dd206458f270aaf484585e1552155f82e
Head re-read at the end of the review: unchanged. Auto-merge: null.
| # | severity | file:line | finding |
|---|---|---|---|
| 1 | advisory | docs/adr/0040-a-link-capture-records-the-capture-it-came-from.md:68 | Merge sequencing: ADR-0040 requires the release that knows the fields to ship before any build writes them. gh api .../releases shows v1.0.1-beta.23 (2026-10-12) as the latest, and git ls-remote --tags has no beta.24. The PR body states the maintainer will merge after beta.24; nothing mechanical enforces it. |
| 2 | advisory | src/main/services/recapture.ts:147 | Mutant !chain.valid -> false leaves all 50 tests in recapture.test.ts green (run locally, reverted). Equivalent mutant: verifyCapture at :163 re-reads the same author chain (captureLifecycle.ts:532-536, same authorChainOf arguments) and returns chain-broken, so the job is still refused, only with a different reason string. Not a gap in protection; the guard is redundant with the verify call. |
| 3 | advisory | src/main/services/export.ts:147 | resolveExportLinkSources verifies the local chain a second time beside resolveExportCaptureEngine (:129). Bounded (one extra linear pass per export), already listed by the PR as out-of-scope finding 4. |
| 4 | advisory | src/main/services/recapture.ts:129-171 | The writer signs a renderer-supplied linkHref without checking it appears among the source's extracted links. ADR-0040 "What the record claims" scopes the claim to the Operator's action and names the source bytes as the evidence, so this matches the decision; noted only so the human reviewer sees the trust boundary is the app window. |
| 5 | advisory | src/main/services/recapture.ts:388 | An enqueue-time exclusion block emits its skipped event through deps.emitEvent, untagged by linkedFrom. Harmless: the same block is returned as a rejection and useLinkMenuTarget.ts (onSuccess, accepted === 0) raises the notice and stops the listener. The types.ts comment scopes the tag to events "emitted while it runs", which is accurate. |
Correctness trace. IPC recapture:enqueue (ipcHandlers.ts:1129) routes any payload with linkedFrom !== undefined to enqueueLink, which type-checks both fields, refuses supersedesCaptureId, requires urls exactly [linkHref] (exact string), and checks source existence and Case, then funnels into enqueue (URL scheme, local-address and exclusion checks). runJob emits received, then readLinkSource before the render: re-checks address/existence/Case, requires a manifest index, reads the author's chain, strict-parses the entry, requires type === 'capture' and entryDescribesRow, refuses non-lower-hex contentHash, then requires verifyCapture(..., {record:false}) to be verified with computedHash === contentHash. Any throw lands in the existing catch and emits a tagged failed. The ingest spreads signed into the entry context (manifest.ts writes each field only when defined; fieldMinReaderSchemaVersion stamps 6) and writes the row with rowId overriding linkedFromCaptureId. Archive import remaps the id via ctx.mapId; receive copies the three fields from the signed entry (received row id is the entry id). Report rows come only from strict-parsed signed entries, printed only when the chain verified, through esc() (reportHtml.ts:1829). Every main-process file that handles duplicate_of_capture_id or supersedes_capture_id also handles the new column (grep comparison).
Docs claims checked. ADR status line: #1981 is on main (55c73c87) and src/shared/schemas.ts on main has the fields. Plan B4 linkHref paragraph: src/shared/urlCanonicalize.ts does drop fragment and trailing slash; the not-in-Case notice passes the did-start-navigation URL unchanged through linkClickOutcome (guestLink.ts:104). Migration comment's "as v43 does" matches migrations.ts v43. Evidence-impact claim that the linkedFrom event tag reaches only the app window: index.ts:654-660 sends it to mainWindow only. Too-old claim: tests/shared/verify/manifestSchema6TooOld.test.ts has a link-provenance describe block.
Evidence gate. Touched blocking-tier paths: recapture.ts, captureLifecycle.ts, manifest.ts, caseArchive.ts, export.ts, reportHtml.ts, sharing/receivedEvidence.ts. Advisory-tier: src/shared/ipc.ts (new LinkedFrom payload type, part of the evidence path, already covered by the blocking label) and src/shared/types.ts (row/event fields). PR labels: evidence-affecting, agent-authored, review:stale, size:L; issue #1995: evidence-affecting. Evidence impact section is substantive. No new KAT: justified by #1981's frozen schema-6 link body (manifestSchema6LinkProvenance.test.ts); the writer's body is pinned field for field and strict-parsed in linkProvenanceIngest.test.ts. Backward verification: entries without the fields keep their body (pinned at schema 3 in the same test); full suite green. The regression-guard test the reliability probe flags as passing with the source reverted is exactly that unchanged-body guard, so it is expected.
Verification (Node v20.20.2, local, at the pinned sha). pnpm lint exit 0; pnpm typecheck exit 0; pnpm build exit 0; BIRDBRAIN_REQUIRE_OPENSSL=1 BIRDBRAIN_REQUIRE_JQ=1 pnpm test:coverage exit 0, 8375 passed, 25 skipped (8400), matching the PR body; pnpm coverage:diff 100/100 changed lines (types.ts not instrumented). No extension changes, so no extension build. CI: every check passes except merge-gate, which fails with "evidence-affecting with no sign-off at head" (ADR-0041), the expected state before maintainer approval. Test reliability probe: 0 survived, 27 caught, 13 not run for time.
Acceptance criteria (#1995 B5). All seven covered: recapture.test.ts (write, other Case, deleted, failing verify, urls empty/two/different/canonical spelling, ordinary job, non-hex hash), linkProvenanceIngest.test.ts, linkProvenanceColumnsMigration.test.ts plus drift test, caseArchiveRoundTrip.test.ts remap, receivedEvidence.test.ts, MhtmlViewer.test.tsx and reportCaptureEngine.test.ts, and the e2e spec (CI e2e pass). The event tagging and refusal notice go beyond the listed scope but serve the requirement that a refusal at run time is visible; not flagged.
Completion controls. Commit trailers: git log origin/main..HEAD --format=%B | grep -i co-authored found none. Auto-merge: null. Attribution: body ends with a Created with ... (T3 Code) byline, which post-pr-body/scripts/check.sh accepts in both default and --any-author modes (exit 0). Draft state not assessed per ADR-0025.
Not checked. I did not run the e2e spec locally (CI e2e passed at this sha). I did not run pnpm test:reliability locally; I read the CI probe's summary and ran one targeted mutant.
Merge request refused: the pull request is a draft; mark it ready first
Remove the cause, then apply the merge label again.
Added 2 commits
Dispositions for the open items of the two approve verdicts above (comments 6490642910 and 6508749761), at the latest commit.
Review verdict: approve for human review
Reviewed commit: 824201645e2d20973a89dc7d8aaeae8966688055 (delta round from 4ea0274d; base main at 18f29504)
| # | severity | file:line | finding |
|---|---|---|---|
| 1 | none | merge c99efe4d | git show --remerge-diff c99efe4d: in all four conflicted files the resolution removes only conflict markers and keeps both sides' lines; no line from either parent is dropped. |
| 2 | advisory | PR body, Changes, "Main merge, second" | The bullet names five PRs since 653d6d71; git log --first-parent 653d6d71^2..18f29504 lists nine (#2016, #2015, #2014, #2006, #2017, #1996, #1999, #2000, #2002), and git merge-base --is-ancestor 3777064e 653d6d71 is false, so the first merge did not bring them. |
| 3 | advisory, not filed | src/main/services/sharing/receivedEvidence.ts:272 | PR body finding (7) is true: the received row copies linkedFromCaptureId, linkedFromContentHash and linkHref (lines 275 to 277) and none of the six engine fields, as on main. It is neither user-visible nor evidence-affecting, so ADR-0028 keeps it in the findings list. |
| 4 | merge condition | merge-gate | merge-gate fails with "evidence-affecting with no sign-off at head"; github-actions[bot] removed approved at 2026-10-31T06:43:06Z when the head moved. |
Correction for 2, exact sentence: "Main merge, second: c99efe4d takes main through #2002 (#2017, #1996, #1999, #2000 and #2002 since 653d6d71)." Corrected wording: "Main merge, second: c99efe4d takes main through #2002 (#2016, #2015, #2014, #2006, #2017, #1996, #1999, #2000 and #2002 since 653d6d71)."
Trace for 3: the row engine columns have no reader that reaches an operator or a verdict. git grep over src/renderer and src/preload finds no read of inventory, scrollToLoad, boundTls, transactionRecordHash or pdfHash on a Capture. The report reads the signed entry (reportHtml.ts:160, resolveCaptureEngineFacts over ManifestEntrySchema). Packaging ships the Transaction Record and PDF only when the entry anchors them (export.ts:1615-1624). Sidecar verification takes its hashes from the chain entry (captureLifecycle.ts:715-733). Both verifiers read the entry: src/shared/verify/manifestChain.ts:454-473 and evidencePackage.ts:924-940, and the standalone script hashes the whole entry body (verifyProcedure.ts:85, jq -cS 'del(.entryHash, .signature)'). The only place the gap shows is the raw Settings, Database view of the row.
Both sets together, re-derived at head:
captureLifecycle.ts:461 spreads linkSource.signed beside inventory, scrollToLoad, transactionRecordHash, boundTls, boundTlsAbsent and pdfHash (lines 462 to 467); manifest.ts:1281-1290 writes each only when set.captureLifecycle.ts:523-530 passes both sets to insertCapture. captureRepo.ts:258-266 maps both. CAPTURE_INSERT_SQL (captureRepo.ts:214) names its columns from CAPTURE_COLUMNS (lines 194 to 200, engine then link) and binds values by column name (line 271), so the order of the row object cannot misplace a value. Migration 44 (migrations.ts:1908-1925) adds the three link columns after v43.CASE_ARCHIVE_SCHEMA_VERSION is 14 (caseArchive.ts:191) on main's 13; importCaptureRows remaps linked_from_capture_id (captureRepo.ts:621-623) and copies the engine columns through CAPTURE_COLUMNS. The round trip test asserts the engine columns on one Capture and the link columns on another.main is at LATEST_SCHEMA_VERSION 43 and archive 13; this branch takes 44 (core.ts:6) and 14. origin/main has not moved since 18f29504.readLinkSource (recapture.ts:195-238) runs at recapture.ts:277-291, before renderPage (line 305), so the source verifies against its signed record before any engine field exists; linkSource and the engine fields reach the same ingest call (lines 379 to 388).manifestChain.ts:454-473 reads both sets; ManifestEntrySchema (schemas.ts:672-684) declares both, and no refinement ties one set to the other.Mutations, each applied alone in a scratch tree and reverted, against the new test named in brackets (recapture: "signs the capture engine fields beside the link fields, and the row mirrors both"; report: "prints the link rows and the capture engine fields from one entry carrying both"). All 11 failed the test with an assertion error:
| mutant | test | result |
|---|---|---|
drop the link spread from the entry (captureLifecycle.ts:461) | recapture | fails |
drop inventory from the entry (line 462) | recapture | fails |
| drop the link spread from the row (line 523) | recapture | fails |
drop inventory from the row (line 525) | recapture | fails |
drop linkSource from the ingest call (recapture.ts:379) | recapture | fails |
drop link_href from insertCapture (captureRepo.ts:260) | recapture | fails |
drop bound_tls from insertCapture (line 265) | recapture | fails |
print link rows only without engine facts (reportHtml.ts:1646) | report | fails |
print engine blocks only without a link source (reportHtml.ts:1848) | report | fails |
schema rejects an entry carrying both sets (schemas.ts:750) | recapture | fails |
| same schema mutant | report | fails |
Evidence gate: blocking-tier paths touched (captureLifecycle.ts, recapture.ts and others); PR and issue #1995 both carry evidence-affecting. Evidence impact is substantive and its new sentences on the engine fields match the code. Auto-merge is null. No Co-authored-by trailer on any branch commit. The PR body ends with the byline as its last authored line.
Review surface since comment 6508749761 (all three endpoints read with --paginate): no reviews and no review comments exist on the PR; two issue comments are new, 6509335087 (merge refused while the PR was a draft) and 6540220041 (implementer dispositions). Finding (7) is in the PR body's findings list; the reply's item 7 is the chain-validity guard.
Verification at head: CI is green for lint, typecheck, build, test (7m53s), e2e (9m57s), probe and the rest; merge-gate fails as in finding 4; agent/pre-pass waits on this verdict. Test reliability: 1 of 46 touched tests passes with the source reverted ("carries none of them on an ordinary entry"), which tests an unchanged entry by design; 0 of 40 probed mutants survived. Locally on Node 20 with the system sharp addon: recapture.test.ts 64 passed, reportCaptureEngine.test.ts 47, linkProvenanceIngest.test.ts 2, caseArchiveRoundTrip.test.ts 7, sharing/receivedEvidence.test.ts 33. The full loop was left to CI at this commit. Trial merge with origin/main 18f29504 is clean (git merge-tree exit 0; the branch already contains it).
Not checked: the full local suite with coverage, lint, typecheck and build were not re-run (CI is authoritative at this commit).