Skip to content

fix(archive): "Load archive" served the wrong article — gate on identity#185

Merged
bryanmatthewsimonson merged 2 commits into
mainfrom
claude/archive-identity-check
Jul 18, 2026
Merged

fix(archive): "Load archive" served the wrong article — gate on identity#185
bryanmatthewsimonson merged 2 commits into
mainfrom
claude/archive-identity-check

Conversation

@bryanmatthewsimonson

Copy link
Copy Markdown
Owner

Fixes the bug you hit: "Once, I went ahead and clicked 'Load Archive' and it loaded a completely different article."

It reproduces, it needs no race, and the mechanism is entirely ours. tests/archive-identity.test.mjs pins it.

The mechanism

xray:archive:reconstruct asked relays for {kinds:[30023], '#r':[url], limit:20}, sorted by created_at desc, took events[0], and reconstructed it — no authors filter, no identity check.

But #r is not an identity index. buildArticleEvent co-emits an indexed r tag for responds-to targets and for the first 25 outbound links of every article:

if (linked < 25) pushR(link.url);

So every article you publish poisons the #r index for up to 25 URLs it merely links to. A cross-linked corpus — the whole point of the tool — maximizes the over-match.

The worst case needs no concurrency. If you never published a capture of X, a linking article is the only candidate → it wins 100% of the time, and altCount is 0, so the "N relay versions found" tell never fires. It reads as an ordinary archive offer.

And the swap is disguised. loadArchivedArticle pins url: state.article.url, so the foreign article's title and body render under your URL — and any claims or comments you make key to X. Foreign content gets annotated as if it were your article.

docs/NIP_DRAFT.md already mandated this disambiguation. The handler did none of it.

The fix

articleAnswersTo in url-identity.js — pure, tested, and the natural home (that module is already about mirror-vs-original identity). An article answers to exactly two addresses:

  • its identity URL — the first r, per the reconstructArticleFromEvent invariant
  • its capture_url mirror — so probing from an archive.is address still resolves

Everything else in the r set is a reference to someone else's article. The handler now reconstructs first, keeps only events that ARE the requested URL, and reports found: false when none survive.

The trap worth knowing about

Link r targets are normalized (content-extractor.js) while the primary r and capture-url are emitted raw, and the probe URL is raw. Normalizing the probe against the raw #r set — the obvious "fix" — would have made this fire MORE often. Normalization here is applied only to an article's own addresses, on both sides. The identity check and the normalization are one change and must not be split.

Verification

The repro test runs the real builder and asserts the poisoned r tag IS present (so the relay filter really would return the event) before asserting the gate rejects it — the bug is pinned, not just the fix. Both directions covered: the linking article is rejected for the victim URL and still findable as itself; a genuine capture still resolves.

  • No wire-format change.
  • npm test1737 pass, 0 fail
  • npm run build — clean · npm run lint0 errors

Companion PR (banner flood) follows separately. Until both land, Options → Advanced → Archive banner → richer silences the false banners with no code.

🤖 Generated with Claude Code

The reconstruct handler asked relays for {kinds:[30023], '#r':[url]} and
rendered the newest hit, with no authors filter and no identity check.
But `#r` is not an identity index: buildArticleEvent co-emits an indexed
`r` for responds-to targets and for the first 25 OUTBOUND LINKS of every
article, so an event matches merely by LINKING to the probed URL. Every
article you publish poisons the `#r` index for up to 25 URLs it links to,
and a cross-linked corpus maximizes it.

The worst case needs no race: with no published capture of X, a linking
article is the ONLY candidate — it wins 100% of the time, and altCount 0
means the "N relay versions found" tell never fires. loadArchivedArticle
pins url: state.article.url, so the foreign body renders under X's
address and claims made on it key to X. NIP_DRAFT.md already mandated the
disambiguation this handler never did.

articleAnswersTo (url-identity.js, pure + tested): an article answers to
its identity URL (the first `r`, per the reconstruct invariant) and its
capture_url mirror, and to nothing else. The handler reconstructs first,
keeps only events that ARE the requested URL, and reports found: false
when none survive. Probing from a mirror address still resolves.

Normalization is applied only to an article's own addresses, on both
sides. Normalizing the probe against the raw `#r` set instead would widen
the over-match — the identity check and the normalization are one change.

No wire-format change. 1737 tests green; lint 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bryanmatthewsimonson
bryanmatthewsimonson merged commit 39db021 into main Jul 18, 2026
1 check passed
@bryanmatthewsimonson
bryanmatthewsimonson deleted the claude/archive-identity-check branch July 18, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant