feat(capture): record the Capture a link Capture came from (ADR-0040 writer)#1997

merged50+
main
feat/1995-link-capture-writer
opened4d agobyMMatt Donovan
View on GitHubTwo-way sync enabledPR last imported 7h ago

Closes #1995

Summary

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.

Changes

Files changed, grouped by purpose
  • Main merge, second: 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.
  • Both schema-6 sets on one Capture: 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.
  • Main merge, first: 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.
  • Database: migration 44 (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.
  • Writer: 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.
  • Case Archive: 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.
  • Shared Case receive: receivedEvidence.ts copies the three fields from the signed entry onto the received row. Test in receivedEvidence.test.ts.
  • Renderer: 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.
  • Report: 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).
  • Docs: ADR-0040's status paragraph says the verifier reads the fields from #1981 and Capture link writes them from #1995; the glossary's link Capture line drops _planned:_.
  • E2E: 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.
  • Overlap with the capture engine stack, now on 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.
  • Findings left out of scope: (1) a Duplicate of a link Capture does not carry the three fields, and ADR-0040 does not say whether it should; (2) follow-up candidate: the report's "Supersedes", "Duplicate of" and "Text digest" rows still read the editable row rather than the signed entry; (3) the plan's B4 and B5 still say archive version 8 to 9 and schema 5, which #1977 and this branch supersede; (4) 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.

Evidence-affecting

Yes

What could change for evidence, and how that is checked

Evidence impact

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.

  • Evidentiary result or interpretation that could change: a Capture made with Capture link now carries 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.
  • What verification proves and does not prove after this change: a schema-6 verifier from #1981 parses the entry, chains it and checks the three fields as a complete set; 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.
  • Backward verification preserved: entries without the fields are byte-identical to before, so existing packages verify unchanged. A schema-5 verifier reads a link entry as too old rather than as a broken chain; #1981's 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.
  • Known-answer test added or extended, or why none applies: none added here. #1981 freezes the schema-6 link entry's canonical body and digest in manifestSchema6LinkProvenance.test.ts. This branch adds no reader rule; it pins the body the writer produces in linkProvenanceIngest.test.ts, which that schema parses.

Verification

All pre-merge checks passed

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 match
  • pnpm lint:adr - pass (exit 0)
  • vale-prose --base - pass (exit 0) - 3 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) - 8618 passed, 25 skipped (8643), thresholds met
  • pnpm coverage:diff - pass (exit 0) - 100.00% of 100 changed lines covered (floor 90%); 1 changed source file not instrumented

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

Checks

1 failing · 20 successful · 11 skipped
Some checks failed

Failing1

8242016

Added 15 commits

  • feat(export): hand the report the capture engine fields054e044
  • feat(capture): sign the Capture a link Capture came from1e5d783
  • chore(merge): take step 4 into the report branch3f01014
  • fix(export): ship the two files only when the signed entry anchors them71cce8b
  • feat(sync): carry a link Capture's source onto a received row82c7017
  • feat(db): mirror a link Capture's source on the captures row92c08bd
  • fix(report): quote capture engine fields only over a verified chain976e4a5
  • feat(viewer): send the link source and show where a Capture came froma34dbbf
  • chore(merge): take the link provenance verifier into the writer branchb4809c0
  • test(export): find capture engine fields for an import-renamed rowb4bd9b7
  • docs(verify): state who carries request bodies and qualify the recordb7e5e2a
  • feat(archive): carry link Capture provenance at archive version 14b920ce7
  • docs(plans): settle linkHref as the address before canonicalizationc531f67
  • feat(report): print a link Capture's source beside the Duplicate rowd2ac571
  • chore(merge): take step 4's fix round into the report branchdfd8677

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

Review verdict: request changes

  1. The tests do not show that a source capture failing its check for any reason other than altered page bytes (an altered or missing screenshot or text file, or an edited database record) is refused; I weakened that check by hand and every test on the branch still passed.
  2. The report's three new lines come from the editable database record, as the existing "Supersedes" and "Duplicate of" lines do, so a database edit can make the report name a source, fingerprint or address the signed record does not; the signed record ships beside it, so a reader can catch the mismatch.
  3. When the source fails its check, the operator first sees "Link capture queued", and the reason it was refused appears only in the capture health list.
  4. Everything else holds: nothing is signed after a failed check, the link address is the one the capture was sent to, older builds refuse the new archive, teammates receive the fields, and the test-only page server cannot ship.
  5. The style and type checks, the build, the full test suite with every changed line covered, and the link end-to-end test all pass on this commit; the merge check is waiting only for your sign-off.
Full report

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).

#severityfile:linefinding
1blockingsrc/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.
2advisorysrc/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).
3advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:56; src/renderer/components/status/CaptureHealth.tsx:94A run-time refusal follows a "Link capture queued" toast; the reason appears only in the Capture Health list. Disclosed as PR finding (5).
4advisorysrc/main/services/recapture.ts:169linkedFromCaptureId 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.
5advisorysrc/main/services/recapture.ts:205 vs :230; comment at :399The 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.
6advisorysrc/renderer/components/captures/LinksTab.tsx:138No 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.
7advisory (equivalent)src/main/services/recapture.ts:146, :406Probe 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

  • fix(db): check the two mirrored hashes on readb3a68d4
  • test(capture): refuse a link source whose screenshot fails its check319417d
  • docs(capture): say when the link source check runs relative to signing9715f9a
  • fix(capture): name a link source by the id its own entry signsea051e5
  • fix(report): read a link Capture's source from the verified entry62c7ade
  • fix(viewer): say a link source is checked later and warn when it fails3290c2e
  • test(viewer): pin the Capture the Links tab names on Capture link5db2bc6

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.

  1. Half the source check untested: applied; a source with only its screenshot altered now fails the weakened check.
  2. Report rows read the editable row: applied; the three link rows come from the signed entry and only when the chain verified, and a Database Admin edit no longer reaches the report. The older Supersedes, Duplicate of and text digest rows still read the row, and the description names them as a follow-up.
  3. The queued notice hid a later refusal: applied; the notice says the source is checked before the link renders, and a refusal raises its own notice.
  4. The source id after a renaming import: applied as ruled; the entry signs the id the source's own entry carries, the viewer still opens the source, and the decision record states it.
  5. The comment about when the source is checked: applied.
  6. The Links tab source id untested: applied.
  7. The two equivalent surviving changes: not applied: both are still refused further down, only with a different message.
Commits

319417d7 (finding 1), 62c7ade9 (finding 2), 3290c2ee (finding 3), ea051e53 (finding 4), 9715f9a3 (finding 5), 5db2bc69 (finding 6)

Added 14 commits

  • feat(verify): count requests that finished with no response30605f8
  • docs(capture-engine): define the finished-without-response count083dba1
  • chore(merge): take the finished-without-response count from #19789b9ec9e
  • chore(merge): take the finished-without-response count from #197846c6142
  • 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 #1985b809fcd
  • chore(merge): take the link provenance verifier's merge of #19780675f94
  • 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
Synced from GitHub

Review verdict: request changes

  1. No released Birdbrain build can read the new link fields yet, and both the feature's decision record and this pull request's own description say it must not merge until one ships.
  2. The report correctly leaves out the link details when the export's own record chain fails its check, but no test covers that, and with the check removed by hand every test still passed.
  3. Once this lands, the feature's decision record and the glossary will still say nothing writes the link fields, and merging the main branch keeps that wording without a conflict to prompt the fix.
  4. The new "Link not captured" notice matches jobs by web address alone, so another capture of the same address can raise it falsely or swallow the real one.
  5. Every earlier finding is fixed with a test that fails when the fix is undone, and every automated check passes except the merge check, which waits on your sign-off.
Full report

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.

#severityfile:linefinding
1blockingPR 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.
2blockingsrc/main/services/export.ts:143-155resolveExportLinkSources 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.
3blockingdocs/adr/0040-…md:8; ADR-0040 on main lines 8-11; CONTEXT.md on main:345At 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.
4advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:56-62The 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.
5advisorydocs/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.
6advisoryPR body, ChangesStale 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).
7advisorysrc/main/services/export.ts:147A 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):

  • (1) status !== 'verified' || changed to &&: "skips a source whose screenshot no longer verifies, though its page does" fails.
  • (2) Each report row switched back to the row value, or the chainVerified check dropped: 1 to 3 tests fail in reportCaptureEngine.test.ts and export.test.ts.
  • (3) The run-time refusal notice changed to info: the MhtmlViewer.test.tsx refusal test fails.
  • (4) The entry id switched to the row id: the import-rename test fails. The row rowId line dropped: the import-rename and linkProvenanceIngest tests fail.
  • (5) The comment at recapture.ts:399-401 is accurate.
  • (6) The LinksTab id changed: the new LinksTab test fails.

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

  • chore(merge): take main after #1978, #1980 and #1982 squash-merged93b5fe8
  • chore(merge): take the member chain engine field tests from #1985fe0325e
  • chore(merge): take main through #1984 after the stack below merged76f8b4b
  • test(export): pin the chain gate on a link Capture's source75cf6bd
  • docs(adr): say Capture link writes the link fieldsab4c302
  • fix(viewer): tie the link notice to its own job, not its address34f29a2
  • chore(merge): take main into the writer branch through #1985's basec7a8922
Synced from GitHub

Dispositions for the review verdict above (comment 6451496026), at the latest commit.

  1. No release with the schema-6 verifier yet: not applied: tagging that release is the maintainer's step, so nothing in the branch changes for it, and the description still says the change stays a draft until it ships.
  2. The report's chain check for link rows untested: applied; one test edits the link record in the export's own chain and finds only the "not reported" note, and one prints an accepted teammate's link source as verified. Each fails under the matching hand edit from the review.
  3. The decision record and glossary still say nothing writes the fields: applied; both now say Capture link writes them, and the merge that brought the main branch's lines in conflicted there and was resolved to that wording.
  4. The "Link not captured" notice matched by address alone: applied; each event of a link job now names the job's source and link, and the notice matches on that and on the queue, so another job for the same address is ignored and a skip that names the redirect's final address still reaches it.
  5. The 2026-10-26 ruling has no written record: not applied: the sentence stays as written for the maintainer to confirm.
  6. Stale text in the description: applied; the corrected description is ready for the coordinator to post.
  7. The second chain check per export: not applied: it was already listed in the description as a finding left out of scope.
Commits and checks
  • Finding 2: 75cf6bd2. Mutation 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.
  • Finding 3: ab4c3029; the base merge c7a89225 conflicted on ADR-0040's status paragraph and the glossary line and kept this branch's wording. The wording follows the verdict's, with "them" changed to "the fields" because the new sentence has no earlier noun for it.
  • Finding 4: 34f29a2c. 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.
  • Merges: fe0325e4 takes the base at c7219ba3 (clean). c7a89225 takes the base at 76f8b4b6, which holds main through #1984. It conflicted in eight files, all resolved, and changed one base test so it expects the source on the not-in-Case notice's Capture link.
Synced from GitHub

Review verdict: request changes

  1. No released Birdbrain build can read the new link fields yet, and both the feature's decision record and this pull request's description say it must not merge until one ships; that is the only thing standing between this and an approval.
  2. The three fixes from the last round hold: the report's chain check on link details now has tests that fail when I break it by hand, the decision record and glossary say the feature exists, and the "Link not captured" notice now listens only to its own job.
  3. The hand-resolved merge of the main branch kept every test from the click-through, Back trail and Wayback work and from this change, and a trial merge with today's main branch is clean and passes.
  4. One line of the description says the merged base carries the main branch through the evidence packages change, which has not merged yet.
  5. Every automated check passes except the merge check, which waits on your sign-off.
Full report

Reviewed commit: c7a892257a3ed8800b52d78b70dc7f4092cdae15 (head re-read at the end: unchanged; auto-merge null). Delta from 0675f94a: fe0325e4 (merge), 75cf6bd2, ab4c3029, 34f29a2c, c7a89225 (merge).

#severityfile:linefinding
1blockingADR-0040 "The fields need schema 6"; PR body SummaryStill 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.
2advisoryPR 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."
3advisorydocs/adr/0040-…md:41Carried 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.
4advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:98The 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).
5advisorydocs/plans/2026-10-04-…-slice-2-…md, B4 step 3The 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.
6advisorysrc/main/services/export.ts:147Carried, 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:

  • local verdict to true: 1 failed, the "quotes none … when the local chain does not verify" test
  • remote true to false: 1 failed, the "accepted member's link source" test
  • .slice(manifest.entries.length) dropped: 1 failed, the tampered-chain test
  • chainVerified render guard dropped (reportHtml.ts): 2 failed

Finding 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:

  • CONTEXT.md and ADR-0040 take this branch's wording.
  • LinksTab.tsx keeps base's followCaptureLink.
  • MhtmlViewer.tsx takes base's { toastWayback: true }.
  • CaptureViewer.tsx keeps both linkedFrom and the Back trail (rendered at :204-229).
  • useLinkMenuTarget.ts takes base's signature, the same dependency list base had (it already held captureId), and fixes the auto-merged ), into )\n},.
  • MhtmlViewer.test.tsx keeps both imports and changes one expectation, adding linkedFrom to the not-in-Case Capture link.
  • The e2e spec appends this branch's test after base's click-through test.

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:

filebaseourstheirsmerged
e2e spec5667
MhtmlViewer14152324
LinksTab21223233
CaptureViewer28303032

recapture, export and reportCaptureEngine are unchanged by the merge.

Verification. gh pr checks 1997 at the end:

  • passing: lint, typecheck, build, test, e2e, probe, hunks, adr-numbers and the security checks
  • merge-gate: fails, reason "evidence-affecting with no sign-off at head"
  • agent/pre-pass: pending; that is this review

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:

  • the 16 test files touched against main: 15 passed and 1 skipped (verifier binary), 861 tests passed and 22 skipped
  • pnpm build: exit 0
  • e2e/mhtml-link-interactivity.spec.ts under xvfb-run -a: 7 of 7 passed, including the Back-trail and Capture link tests

Not 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.
  • Blocking-tier paths are touched: recapture.ts, captureLifecycle.ts, manifest.ts, caseArchive.ts, export.ts, reportHtml.ts, receivedEvidence.ts and db/**.
  • Advisory-tier: ipcHandlers.ts, ipc.ts and types.ts, which add typed payloads and an event field, none of them evidentiary.
  • The Evidence impact section is substantive and current. Its "activity events only" sentence is confirmed above.
  • No KAT was added. That is justified: #1981's frozen schema-6 body, plus this branch's writer-body pin in linkProvenanceIngest.test.ts.
  • Backward verification: entries without the fields are unchanged (withCaptureEntry omits them).

Controls.

  • Auto-merge: null.
  • No Co-authored-by trailer on any of the 65 commits in origin/main..HEAD.
  • The body passes post-pr-body check.sh, and it ends with the maintainer byline.
  • Bot surface, read with --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.

Synced from GitHub

Review verdict: request changes

  1. The branch conflicts with main because the change it was stacked on (#1985) has merged, so it needs a merge commit, a delta review and fresh CI before it can land.
  2. No tagged release reads schema 6 yet, so the link-capture decision record forbids merging this writer until the release carrying the reader ships.
  3. A refusal that reaches the viewer before the queue's reply can show "Link not captured" followed by "Link capture queued" (advisory).
  4. No writer test covers a link source authored by another Shared Case member, although the code matches the existing capture verifier (advisory).
  5. The PR body still says the branch is stacked on #1985 and merges after it, which is no longer true (advisory).
Full report

Reviewed commit: c7a892257a3ed8800b52d78b70dc7f4092cdae15

Reviewed commit and head check

  • Commit: reviewed 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.
  • Diff basis: main has since squash-merged #1984, #1985 and #1998. The three-dot diff from the old merge base (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

#Severityfile:lineFinding
1gatesrc/main/services/export.ts, src/main/services/reportHtml.ts, tests/main/services/export.test.ts, tests/main/services/reportCaptureEngine.test.tsThe branch conflicts with main. GitHub reports mergeable:false, mergeable_state:dirty. The PR head does not contain #1985's final head 7fba3c39.
2blocking (merge timing)ADR-0040's last paragraph of "The fields need schema 6"; ADR-0023No 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.
3advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:89-93Toasts 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.
4advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:98onError: () => stop() is the only uncovered changed line (172 of 173 covered). Nothing tests listener cleanup when the IPC call rejects.
5advisorysrc/main/services/recapture.ts:139-143The 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.
6advisoryPR body, Summary and "Stack" bulletThe 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).
7advisorysrc/shared/types.ts:352The 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.
8advisorysrc/main/services/export.ts:143resolveExportLinkSources 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)

  • The enqueue-time checks re-run when the job runs.
  • 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.
  • The hash comes from the parsed signed entry, never from the row.
  • The entry is bound to the row with entryDescribesRow, as Duplicate does.
  • A hash that is not lower-case hex is refused.
  • verifyCapture must return verified with computedHash === contentHash, which also covers a source whose screenshot alone was tampered.
  • Exclusion skips at enqueue time carry no linkedFrom, and the renderer handles them through onSuccess.
  • Every event runJob emits for a link job carries the tag.

Ingest: captureLifecycle.ts

  • The entry signs the source's entry id.
  • The row keeps the row id.
  • withCaptureEntry (manifest.ts) writes the fields only when they are set.

Report

  • The link address is page-controlled, and add() values are escaped at render (reportHtml.ts:1829).
  • Link rows print only when the chain verified; otherwise the report prints the not-reported note.
  • The resolver mirrors resolveExportCaptureEngine.

Archive and migration

  • importCaptureRows remaps the id as it does for duplicate_of.
  • CASE_ARCHIVE_SCHEMA_VERSION goes to 14.
  • Migration 44 is idempotent, as v43 is.
  • LATEST_SCHEMA_VERSION goes to 44.

Doc and comment claims, each checked against the code

  • ADR-0040, linkedFromCaptureId and #1815: matches signedCaptureId (captureLifecycle.ts:796).
  • Plan B4, canonicalizeUrl drops the fragment and trailing slash: true (urlCanonicalize.ts:12-13,35).
  • The body says the linkedFrom tag reaches only the app window: true (index.ts:657-660).
  • The migration 44 comment's reference to v43: true.
  • The caseArchive.ts comment that an older build drops the columns without error: true; import is driven by CAPTURE_COLUMNS.
  • The CONTEXT.md glossary line dropping _planned:_: true once merged.

Scope

  • The changes map onto #1995's scope, its three review requirements and B5.
  • The refusal notice and the event tag support the viewer sending the source, and are not drive-by additions.

Verify loop (pinned sha, Node v20.20.2, tool shell bash)

CheckResult
pnpm lintexit 0 (0 errors, 47 warnings)
pnpm typecheckexit 0
pnpm buildexit 0
BIRDBRAIN_REQUIRE_OPENSSL=1 BIRDBRAIN_REQUIRE_JQ=1 xvfb-run -a pnpm test:coverageexit 0: 486 files passed, 2 skipped; 8229 tests passed, 25 skipped. Matches the PR's figures.
pnpm coverage:diffPASS: 99.42% (172 of 173 lines). Matches the PR.
xvfb-run -a pnpm test:e2e e2e/mhtml-link-interactivity.spec.tsexit 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:extensionNot 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:

  • Two belong to #1984, now on main.
  • One is this PR's "carries none of them on an ordinary entry". That is a negative guard that is meant to pass without the change, so it is not a finding.

Evidence gate

  • Labels: the PR carries evidence-affecting, and so does issue #1995.
  • Blocking-tier paths touched: recapture.ts, captureLifecycle.ts, manifest.ts, db/**, caseArchive.ts, export.ts, reportHtml.ts, sharing/receivedEvidence.ts.
  • Advisory-tier hits:
    • 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).
  • Evidence impact section: substantive. It says what the entry claims, what verification does and does not prove, and how backward verification is kept.
  • Known-answer test: none added. The section justifies this: #1981's 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.
  • Backward verification: an ordinary entry keeps the schema-3 stamp and has none of the new fields (pinned). The full suite, including the verifier and package tests, passes.
  • Auto-merge: not enabled.

Completion controls

  • Attribution: optional on this PR. check.sh --any-author on the body exits 0, and there is no attribution line mid-body.
  • Auto-merge: 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.
  • Draft state: not reported, per ADR-0025.

What I could not check

  • Vale, because it is not installed here.
  • Whether the refusal in finding 3 can actually arrive before the IPC reply. That depends on the timing between Electron IPC and microtasks, and I did not reproduce it.

Friction notes

  1. re-derived, anchor: "Review passes" step 1 (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

  • chore(merge): take main through #2011 into the writer branch653d6d7
  • fix(viewer): raise no queued notice after a link refusal that beat it315fc94
  • test(recapture): link from a teammate's received Capture4b70cc7
  • docs(types): say what a received row's link source id holdsa674fcb
  • docs(plans): name the not-in-Case notice as a third link address reader4ea0274
Synced from GitHub

Dispositions for the review verdict above (comment 6459247348), at the latest commit.

  1. The branch conflicts with main: applied; the main branch is merged in through the latest sharing change, the four conflicts were adjacency only and each keeps both sides, and the main branch took no schema number this change uses.
  2. No tagged release reads schema 6 yet: not applied: tagging that release is the maintainer's step, and the description now says the maintainer plans to merge after release 1.0.1-beta.24.
  3. Notices in the wrong order: applied; a refusal that reaches the viewer before the queue's reply now suppresses the queued notice, and a new test that reproduces that order failed before the fix.
  4. The uncovered cleanup on a failed request: applied; the same test fails if a failed request leaves its listener behind.
  5. No writer test for a teammate's source: applied; a member links from a Capture received from the owner, the new entry names the owner's entry and its fingerprint, and the result verifies.
  6. Stale description: applied; the stacking text is gone and the description names the merge with the main branch instead.
  7. The field comment on received rows: applied; it now says a received row holds the source id its entry signs.
  8. The second chain check per export: not applied: the description already lists it as a finding left out of scope.
Commits and checks
  • Finding 1: 653d6d71 merges 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.
  • Findings 3 and 4: 315fc942. One flag, 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.
  • Finding 5: 4b70cc7e. New 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).
  • Finding 7: a674fcb7, comment only; the copy is receivedEvidence.ts setting linkedFromCaptureId: entry.linkedFromCaptureId.
Synced from GitHub

Dispositions for the open items of the review verdict above (comment 6452349263), at the latest commit.

  1. No release with the schema-6 verifier yet: not applied: tagging that release is the maintainer's step, and the description now says the maintainer plans to merge after release 1.0.1-beta.24.
  2. The description's base-merge line: applied; that line is gone, because the base change has merged and the description now names the merge with the main branch.
  3. The 2026-10-26 ruling has no written record: not applied: the sentence stays as written for the maintainer to confirm.
  4. The uncovered cleanup on a failed request: applied; a new test fails if a failed request leaves its listener behind.
  5. The plan names two readers of the link address: applied; it now names the not-in-Case notice's Capture link as the third.
  6. The second chain check per export: not applied: the description already lists it as a finding left out of scope.
Commits
  • Item 4: 315fc942
  • Item 5: 4ea0274d
Synced from GitHub

Review verdict: approve for human review

  1. The one remaining merge condition is release 1.0.1-beta.24: no tagged release can read the new link fields yet, the description says so accurately, and the release branch already carries the reader.
  2. The four fixes from the last round hold: each fails its own test when I break it by hand, and the hand-resolved conflicts with the main branch kept every main-branch line.
  3. Every automated check passes at this commit, except the merge check, which waits on your sign-off.
  4. If the queue refuses a link outright, nothing tests that the viewer then stops listening for it. The worst case is a repeated warning, so this is advisory.
  5. The decision record still says you ruled on the source id on October 26 without a written record of that ruling; please confirm it.
Full report

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.

#severityfile:linefinding
1merge condition (not a code blocker, by coordinator instruction)docs/adr/0040-…md:67-69; PR body Summary sentence 3No 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.
2advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:90Deleting 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).
3advisorysrc/renderer/components/captures/useLinkMenuTarget.ts:82-83Moving 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.
4advisory (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).
5advisory (carried, disclosed as out-of-scope item 4)src/main/services/export.ts:147resolveExportLinkSources 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):

  • M1, if (settled) return dropped: MhtmlViewer 1 failed / 24 passed (the new test at :308).
  • M2, onError: () => stop() changed to onError: () => {}: 1 failed / 24 passed (same test, on the listener count).
  • M3, settled = true dropped: 1 failed / 24 passed.
  • M5, authorInstallationId: null forced in readLinkSource (recapture.ts:141): recapture 1 failed / 49 passed (the teammate test at :839).
  • Two mutants survived: M4, 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.

  • types.ts:352-355 says a received row copies the entry id. True: receivedEvidence.ts:275.
  • The plan, B4 step 3, adds a third reader: the not-in-Case Capture link. True: MhtmlViewer.tsx:177, :188 and :251 take outcome.url, which guestLink.ts:78 and :104 pass through unchanged.
  • 315fc942's comment at useLinkMenuTarget.ts:72-73 matches the code.

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.

  • Sentence 1 holds: the signed entry, the hash check and verifyCapture run before ingest.
  • Sentence 2 holds. The viewer falls back to the URL when there is no title (body, Changes).
  • Sentence 3 holds; see finding 1.
  • Sentence 4, about the interactive session that opened the PR and applied its labels by hand, matches the label events: 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."

  • Changes and Evidence impact claims checked this round: the merge bullet's PR list matches 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.

  • CI at 4ea0274d, from gh pr checks 1997:
    • Pass: lint, typecheck, build, test, e2e, probe, hunks, adr-numbers, changes, label, seed, withdraw, and the security checks.
    • merge-gate fails with "evidence-affecting with no sign-off at head", which is expected.
    • agent/pre-pass is pending; that is this review.
  • CI test job: 492 files passed and 2 skipped; 8375 tests passed and 25 skipped. Diff coverage is 100/100 changed lines. Both match the body.
  • Probe:
    • One test still passes with the source changes reverted: "carries none of them on an ordinary entry". It is an absence guard, so it is not a finding.
    • Mutants: 0 of 40 survived, 27 were caught and 13 were not run for time. M4 and M6 were not in its sample.
  • Local run, Node v20.20.2 via mise, with system sharp and xvfb-run: the 13 test files the PR touches against main passed, 761 tests, exit 0.
  • Not run locally, per instruction: the full suite with coverage, lint, typecheck, build and e2e. I left the full loop to CI at this sha.
  • Trial merge: origin/main (2d5f1594, re-fetched at the end) is an ancestor of the head, so the merge is a no-op and clean.

Evidence gate.

  • Labels: evidence-affecting is on the PR and on #1995.
  • The delta touches no blocking-tier path. useLinkMenuTarget.ts and the tests are not on the list. types.ts is advisory-tier, and its change is a comment on Capture fields with no evidence behaviour.
  • The merge brings no new evidence code: export.ts and reportHtml.ts resolve byte-identical to c7a89225, which was reviewed last round.
  • The Evidence impact section is substantive and now names the teammate-source verification. Its no-KAT justification, which points to #1981's frozen body and this branch's pinned body, still holds.

Controls.

  • Auto-merge: null.
  • git log origin/main..HEAD --format=%B | grep -ic co-authored-by prints 0 over 70 commits.
  • The body passes post-pr-body check.sh (exit 0) and ends with the maintainer byline.
  • Bot surface, read with --paginate: 0 reviews, 0 inline comments and 9 issue comments. The only bot comment is CodeRabbit's draft skip, which has no findings.
Synced from GitHub

Review verdict: approve for human review

  1. No defects found: the new link record is checked against the source's signed record and stored bytes before anything is signed, and every check, test and coverage figure the description states reproduces.
  2. Do not merge until the release carrying the newer verifier has shipped; the latest published release is still the previous one, and the design record forbids writing these fields before that.
  3. This change affects evidence, so it still needs the maintainer's own sign-off at this commit before the merge gate will pass.
Full report

Reviewed commit: 4ea0274dd206458f270aaf484585e1552155f82e

Head re-read at the end of the review: unchanged. Auto-merge: null.

#severityfile:linefinding
1advisorydocs/adr/0040-a-link-capture-records-the-capture-it-came-from.md:68Merge 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.
2advisorysrc/main/services/recapture.ts:147Mutant !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.
3advisorysrc/main/services/export.ts:147resolveExportLinkSources 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.
4advisorysrc/main/services/recapture.ts:129-171The 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.
5advisorysrc/main/services/recapture.ts:388An 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.

Synced from GitHub

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

  • chore(merge): take main through #2002 into the writer branchc99efe4
  • test(recapture): pin both schema-6 sets on one link Capture8242016
Synced from GitHub

Dispositions for the open items of the two approve verdicts above (comments 6490642910 and 6508749761), at the latest commit.

  1. Merge after release 1.0.1-beta.24 (6490642910 item 1, 6508749761 item 2 and table row 1): applied in the description; that release was published on October 30, 2026 and its tag contains the change that taught the verifier the link fields.
  2. Maintainer sign-off at the latest commit (6508749761 item 3): not applied: this round moved the head, so the sign-off is the maintainer's step at the new commit.
  3. A refusal at queue time leaves the listener in place untested (6490642910 item 4 and table row 2): not applied: this round only merges the main branch, and the reviewer rated it advisory with a repeated warning as the worst case.
  4. Marking the request settled after the stored check passes every test (6490642910 table row 3): not applied: the reviewer rated it advisory, and a render takes seconds, so a stored event is unlikely to arrive before the reply.
  5. The 2026-10-26 ruling has no written record (6490642910 item 5): not applied: the sentence stays as written for the maintainer to confirm.
  6. The second chain check per export (6490642910 table row 5, 6508749761 table row 3): not applied: the description already lists it as a finding left out of scope.
  7. The chain-validity guard in the link source check is redundant with the verify call (6508749761 table row 2): not applied: the verify call refuses the same source, so removing the guard changes only the refusal text.
  8. The link address is not checked against the source's links, and a queue-time exclusion skip carries no link tag (6508749761 table rows 4 and 5): not applied: both match the decision record and the reviewer found no defect in either.
Commits this round
  • c99efe4d: merge of the main branch through #2002
  • 82420164: tests that one link Capture carries the link fields and the capture engine fields together
Synced from GitHub

Review verdict: approve for human review

  1. The merge with the main branch keeps both sides: every place that signs, stores, archives, shares, reports or verifies a link capture carries the link record and the newer page capture facts together, and the two new tests fail when either set is removed.
  2. The description lists five of the nine main branch changes the second merge brought in, and leaves out three small fixes and the beta.24 release bump.
  3. Copies received from another shared case member keep the page capture facts in their signed record but not in their database row; nothing reads that row copy, so it needs no separate issue.
  4. The one failing check is the maintainer sign-off, which the push cleared and which has to be given again on this commit.
Full report

Reviewed commit: 824201645e2d20973a89dc7d8aaeae8966688055 (delta round from 4ea0274d; base main at 18f29504)

#severityfile:linefinding
1nonemerge c99efe4dgit 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.
2advisoryPR 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.
3advisory, not filedsrc/main/services/sharing/receivedEvidence.ts:272PR 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.
4merge conditionmerge-gatemerge-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:

  • Signed entry: 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.
  • Row: 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: 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.
  • Schema numbers: 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.
  • Source check order: 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).
  • Verifiers: 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:

mutanttestresult
drop the link spread from the entry (captureLifecycle.ts:461)recapturefails
drop inventory from the entry (line 462)recapturefails
drop the link spread from the row (line 523)recapturefails
drop inventory from the row (line 525)recapturefails
drop linkSource from the ingest call (recapture.ts:379)recapturefails
drop link_href from insertCapture (captureRepo.ts:260)recapturefails
drop bound_tls from insertCapture (line 265)recapturefails
print link rows only without engine facts (reportHtml.ts:1646)reportfails
print engine blocks only without a link source (reportHtml.ts:1848)reportfails
schema rejects an entry carrying both sets (schemas.ts:750)recapturefails
same schema mutantreportfails

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).