feat(capture): record the Capture a link Capture came from (ADR-0040 writer)#1995
Written by claude-opus-5-5 via Claude Code on behalf of Matt.
Part of the MHTML viewer slice 2 plan, Part B step 3, under ADR-0040 (accepted at schema 6 on 2026-10-26). The maintainer approved this step's plan under ADR-0016 on 2026-10-26.
A Capture made with Capture link records, in its signed entry, which Capture held the link, that Capture's Content Hash, and the link's address. The verifier learns the fields in #1976 (pull request #1981). This step writes them: the viewer sends the source and the address, the main process checks the source before signing, and the database, Case Archive, viewer and report carry the relationship. It merges only after the release that carries schema 6.
Agent brief: plan sections, gates, scope, acceptance criteria, verification and starting files
Plan. docs/plans/2026-10-04-mhtml-viewer-slice-2-click-through-and-provenance.md, B1, B2, B4 step 3, B5 and B6; docs/adr/0040-a-link-capture-records-the-capture-it-came-from.md, Decision and Consequences.
Waits for #1981 and the schema-6 release tag before merging. Build it stacked on the open schema-6 pull requests.
Scope. As B4 step 3: RecaptureJob, RecaptureEnqueuePayload and useRecaptureMutations gain linkedFrom (source id and link address, never the hash); recapture.ts accepts a link job only when urls holds exactly one URL equal to the link address, the source exists, is in the job's Case and its capture entry verifies against the chain, and takes linkedFromContentHash from that verified entry; a migration adds linked_from_capture_id, linked_from_content_hash and link_href; CAPTURE_COLUMNS; Case Archive import remaps linked_from_capture_id and the archive version rises; useLinkMenuTarget sends the source and address; CaptureViewer.tsx shows "From a link in <title>"; reportHtml.ts adds the rows. A Recapture of a link Capture does not inherit the fields.
Three requirements from the review of #1981:
- The writer signs without a schema check, so it refuses a source whose Content Hash is not lower-case SHA-256 hex rather than copying it into a signed entry the verifier would reject.
- The Shared Case receive path (
src/main/services/sharing/receivedEvidence.ts) builds capture rows without the three fields; it carries them, so a teammate's viewer shows "From a link in". - The step settles, and documents in the plan, whether
linkHrefis the address before or aftercanonicalizeUrl, and the single-URL check compares like with like.
Acceptance criteria (B5)
tests/main/services/recapture.test.tscovers a link job that writes the three fields; a source in another Case, deleted, or failing verification (rejected);urlsempty, holding two addresses, or differing from the link address (rejected); an ordinary job that writes none of them; and a source with a non-hex Content Hash (rejected).- A lifecycle test pins the entry body with and without the fields, and parses it with the strict schema.
- The migration test and the
CAPTURE_COLUMNSdrift test pass. tests/main/services/caseArchiveRoundTrip.test.tsexports and imports a link Capture and showslinked_from_capture_idremapped, not copied; the archive version pin moves and the newer-version refusal cases still pass.- A Shared Case receive test shows the three fields on the received row.
MhtmlViewer.test.tsxpins the enqueue payload from the menu; the report test pins the new rows.- The e2e spec captures a link the Case does not hold and reads the new Capture's "From a link in" header.
Verification
The tests above, pnpm preflight, and xvfb-run -a pnpm test:e2e e2e/mhtml-link-interactivity.spec.ts.
Starting files. src/main/services/recapture.ts, src/main/services/captureLifecycle.ts, src/main/services/db/migrations.ts, src/main/services/db/captureRepo.ts, src/main/services/caseArchive.ts, src/main/services/sharing/receivedEvidence.ts, src/main/services/reportHtml.ts, src/renderer/components/captures/CaptureViewer.tsx, src/renderer/components/captures/useLinkMenuTarget.ts.
Evidence-affecting: yes (acquisition, manifests, storage, reporting; blocking tier).