Skip to content

fix(archive): reload must not drift the x tag — carry the preimage, prove it#187

Merged
bryanmatthewsimonson merged 4 commits into
mainfrom
claude/archive-escape-doubling
Jul 18, 2026
Merged

fix(archive): reload must not drift the x tag — carry the preimage, prove it#187
bryanmatthewsimonson merged 4 commits into
mainfrom
claude/archive-escape-doubling

Conversation

@bryanmatthewsimonson

Copy link
Copy Markdown
Owner

Stacked on #186. Fixes the third archive bug: "if I do so much as delete a 'SKIP ADVERTISEMENT' in the reader, then it will hash differently." It turned out you didn't even need to edit — Load archive → republish drifted the hash on its own.

The bug

Publish turndowns Readability HTML → markdown. Reconstruct renders that markdown → HTML. Republish turndowns the rendering again. turndown isn't idempotent — it escapes already-escaped characters — so backslashes grow n → 2n+1 per cycle. Measured on the real modules: 1 → 3 → 7 → 15, four distinct x tags from one unedited article.

assembleArticleBody states the invariant this breaks out loud: "Conversion runs ONCE per body, ever."

Why it mattered more than it looked: the d tag is URL-derived and survives reconstruct, so a drifted republish replaces the original 30023 at the same NIP-33 coordinate. The bug was overwriting the very body your audits anchor to. And the 2026-07-08 PDF fix never covered it — isMarkdownCanonical needs article.markdown, which reconstruct never set, so that protection was dead code on the relay path, for exactly the PDFs it was written for.

The fix: carry the preimage, and prove it

reconstructArticleFromEvent now carries _publishedDraft — the value content held when the event was built (assembleArticleBody's input, whose output is the x tag's preimage). shared/archive-draft.js proves it: rebuild the body publish would ship, check it hashes to the carried _articleHash.

True → ship those bytes. False → today's behavior. No third outcome, so being wrong is safe. A lossy reconstruction fails its own proof.

Four things the adversarial pass caught — each with a runnable repro

I ran a 15-agent workflow (map → 3 designs → 3 judges → 4 refuters). All four refuters landed, and two of them would have shipped a fix that does nothing:

  1. Guard the re-derivation readers, not the dirtySource writers. dirtySource='reader' has four writers. Setting the verdict only in loadArchivedArticle leaves onTag/onEntityTag to flip it back — so tagging one entity, the tool's primary workflow, re-armed the bug in full (measured 3→9→21→45→93). onReaderFieldInput flips it unconditionally, so fixing a typo in the title drifted the body. Hence state.draftProven, guarding the three htmlToMarkdown(htmlDraft) sites — dirtySource records where a draft came from, draftProven records whether it can be trusted, and text-neutral edits move one without the other.
  2. The carrier must not be named markdown — that key arms isMarkdownCanonical, which would declare a relay-reconstructed PDF canonical without proof. A PDF whose text contains a ## Description heading loses that section to reconstruct's regex and would have been republished 48 of 120 bytes short.
  3. Snapshot after the section extraction. My first cut carried the full preimage; assembleArticleBody re-appends Description/Transcript for video and emitted them twice. The remainder is what composes.
  4. Both entry points — the portal opens archive rows writable via adoptArticle (case view's "Extract claims", case-graph nodes), never through loadArchivedArticle.

Rejected alternatives (both tested, not assumed)

  • Make markdownToHtml a turndown inverse — a ~1-line unescape, but fixes only 1 of 5 shapes (no tables, no nested lists) and is retroactively unsafe: platform handlers set content = markdownToHtml(bodyMarkdown), so it silently moves every already-published platform capture's x tag.
  • De-escape inside the hash — opens a collision: 5 \* 3 and 5 * 3 would share one content address. Fatal for a content address.

Wire format: unchanged

No kind, tag, or field moves; the hash algorithm is untouched; _publishedDraft is read-side and assembleArticleBody never reads it. What changes is the x tag a reload→republish produces: from "a new drifting hash every cycle" to "the hash already on the wire." It mints no new anchor and re-anchors nothing.

Why 1739 tests stayed green while this was live

Nothing fed reconstructArticleFromEvent's output back into buildArticleEvent. tests/archive-reload-hash.test.mjs closes that loop.

Its fixtures are escape-prone on purpose (5 * 3, that_is_it, a line starting 14.) — plain prose round-trips byte-stably and passes against the bug, including audit-capture-hash.test.mjs's own fixture. There's a test asserting the fixture is escape-prone, so the file can't silently rot into proving nothing.

  • npm test1756 pass, 0 fail · build clean · lint 0 errors
  • Every refuter kill-sequence replayed and defeated: tag an entity, edit the title, open the markdown tab, all three combined, and 4× load→tag→republish → 1 distinct x tag (was 5). A real body edit still forks — that is a different article.

Worth checking after this lands

Any archive row with priorVersions entries you don't recognize: those are this bug's fingerprints — spurious "stealth edits" recorded against articles you never touched.

🤖 Generated with Claude Code

bryanmatthewsimonson and others added 2 commits July 16, 2026 23:45
…rove it

Publish -> "Load archive" -> republish minted a NEW x tag for an article
nobody edited, exponentially: publish turndowns Readability HTML to
markdown, reconstruct renders it back to HTML, republish turndowns the
rendering again. turndown is not idempotent, so backslashes grow n->2n+1
per cycle (measured 1->3->7->15, four distinct x tags). assembleArticleBody
states the invariant it breaks: "Conversion runs ONCE per body, ever."

The d tag is URL-derived and survives reconstruct, so a drifted republish
REPLACES the original 30023 at the same NIP-33 coordinate — the bug was
overwriting the body the audits anchor to. The 2026-07-08 PDF fix never
covered it: isMarkdownCanonical needs article.markdown and reconstruct
never set it, so that protection was dead code on the relay path.

reconstructArticleFromEvent now carries `_publishedDraft` (the value
`content` held at build time — assembleArticleBody's input, whose output
is the x tag's preimage). shared/archive-draft.js proves it: rebuild the
body publish would ship, check it hashes to the carried _articleHash.
True -> ship those bytes; false -> today's behavior. No third outcome, so
being wrong is safe. No shape/contentType/provenance short-circuit —
every cheaper discriminator has a counter-example, each pinned in tests.

Four things the adversarial pass caught, each with a repro:

- Guard the re-derivation READERS, not the dirtySource WRITERS.
  dirtySource='reader' has four writers; setting the verdict only in
  loadArchivedArticle left onTag/onEntityTag to flip it back, so tagging
  ONE entity re-armed the bug in full (3->9->21->45->93), and
  onReaderFieldInput flips unconditionally, so a title typo drifted the
  body. Hence state.draftProven, guarding the three
  htmlToMarkdown(htmlDraft) sites: dirtySource records where a draft came
  from, draftProven whether it can be trusted.
- The carrier must NOT be named `markdown` — that key arms
  isMarkdownCanonical, which would declare a relay-reconstructed
  pdf/transcript canonical without proof. A PDF whose text contains a
  `## Description` heading loses that section to reconstruct's regex and
  would have been republished 48 of 120 bytes short.
- Snapshot AFTER the section extraction: assembleArticleBody re-appends
  Description/Transcript for video, so carrying the full preimage emitted
  them twice. The remainder is what composes.
- Both entry points: the portal opens archive rows writable through
  adoptArticle, never loadArchivedArticle.

Rejected, both tested: making markdownToHtml a turndown inverse is
retroactively unsafe (it moves every published platform capture's x tag);
de-escaping in the hash opens a collision ("5 \* 3" vs "5 * 3").

No wire-format change. _publishedDraft is read-side and assembleArticleBody
never reads it. The x tag a reload->republish produces changes from "a new
drifting hash every cycle" to "the hash already on the wire" — no new
anchor, nothing re-anchored.

1739 tests stayed green while this was live because nothing fed reconstruct
output back into buildArticleEvent. tests/archive-reload-hash.test.mjs
closes that loop; its fixtures are escape-prone on purpose (plain prose
round-trips byte-stably and passes against the bug).

1756 tests green; build clean; lint 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…shipped

A review of the IMPLEMENTED diff (not the design) found three defects in
the escape-doubling fix, one critical. All are fixed here.

CRITICAL — applyMediaResult published an EMPTY body over the real article.
It parks markdownDraft='' ("stale — regenerated on tab entry") and sets
dirtySource='reader' to force that regeneration, which the new draftProven
guard suppressed. Publish shipped content: '' — x = sha256('') = e3b0c442…,
d unchanged, so the empty kind-30023 REPLACED the article at its own NIP-33
coordinate and orphaned every audit anchored to it. Reachable from the Load
archive banner and from the portal's writable open. The reader displayed a
correct body and hash line throughout (both read htmlDraft), so there was
no pre-publish signal.

This was the same mistake the design review had already caught one level
down: the refuters proved dirtySource='reader' has four writers and that
guarding one was useless, and draftProven was then introduced with the
identical shape — nine places assign markdownDraft, and the flag was
cleared at two. Enumerating writers does not work here.

So draftIsProven() is now self-checking: the flag AND markdownDraft still
equalling provenDraft, the exact bytes proved. Any writer that rewrites or
blanks the draft un-proves it with no cooperation — which is why the
Substack backfill (:3371, also unguarded, also unnoticed) is safe. The flag
stays necessary for the inverse: a real body edit moves htmlDraft while
markdownDraft sits still, invisible to the byte check. Both conditions are
load-bearing; the tests fail if either is removed.

MAJOR — the draft seed was not gated on `proven`, so a stale
_publishedDraft shadowed a newer `markdown` and silently reverted the body.
Unproven now falls back to exactly the pre-existing seed.

MAJOR — hashableArticle re-derived the hash from the rendering even when
the draft was proven, so the hash line, the audit anchor and the archive
row disagreed with the x tag publish emits: the drifted value, displayed
as the article's identity.

Tests: the empty-body case, the byte check against an uncooperative writer,
and the stale-shadow case. Both new tests verified load-bearing — each
fails when its own defense is removed.

1759 tests green; build clean; lint 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bryanmatthewsimonson

Copy link
Copy Markdown
Owner Author

Post-review update — a critical regression was caught and fixed before merge

I ran an adversarial review of this diff as implemented (5 dimension reviewers, then two independent skeptics per finding, both told to refute and to default to "not real" unless their own execution demonstrated it). 8 of 10 candidate findings survived; deduplicated, three were real. Pushed in 64b4984.

CRITICAL — this branch published an EMPTY body over the real article

applyMediaResult (attach a transcript) deliberately parks markdownDraft = '' with the comment "stale — regenerated on tab entry" and sets dirtySource='reader' to force that regeneration — which the new draftProven guard suppressed. Publish then shipped content: '':

x tag : e3b0c44298fc1c14…b7852b855   <- sha256("")
d tag : unchanged

So the empty kind-30023 replaced the article at its own NIP-33 coordinate, orphaning every audit anchored to the original hash and dropping the transcript the user had just attached. Reachable from the Load-archive banner and from the portal's writable open ("Extract claims", case-graph nodes). There was no pre-publish signal: the reader painted a correct body and a correct hash line the whole time, because both read htmlDraft.

It was a regression this PR introduced — pre-#187 the guard had no draftProven term, so the blank draft was always regenerated and the correct body shipped.

The lesson, which is the actual fix

This was the same mistake the design review had already caught one level down. The refuters proved dirtySource='reader' has four writers and that guarding one was useless — and then I introduced draftProven with the identical shape: nine places assign markdownDraft, and I cleared the flag at two. Enumerating writers doesn't work here, and the next writer won't know the flag exists.

So draftIsProven() is now self-checking: the flag AND markdownDraft still equalling provenDraft, the exact bytes the proof covered. Any writer that rewrites or blanks the draft un-proves it with no cooperation — which is why the Substack backfill (:3371, also unguarded, also unnoticed until now) is safe. The flag stays necessary for the inverse case: a real body edit moves htmlDraft while markdownDraft sits still, which the byte check can't see. Both conditions are load-bearing — the tests fail if either is removed.

Two more, both fixed

  • A stale _publishedDraft shadowed a newer markdown. The seed wasn't gated on proven, so a leftover carried draft could silently revert the body. Unproven now falls back to exactly the pre-existing seed.
  • hashableArticle re-derived the hash from the rendering even when proven — so the hash line, the audit anchor, and the archive row all disagreed with the x tag publish emits: the drifted value, displayed as the article's identity.

Verification

  • npm test1759 pass, 0 fail · build clean · lint 0 errors
  • Both new tests verified load-bearing: each fails when its own defense is removed, and both fail against the original bug.
  • The original attack replay still passes: tag an entity / edit the title / open the markdown tab → same x; a real body edit still forks.

Two findings were refuted and are not real: that #185 gates client-side over an unchanged limit:20 filter (the true candidate can't be pushed out in practice), and that _publishedDraft bloats every row (it's byte-identical to markdown on the publish path).

Phase 7 checked that "Load archive" swaps the body. It never checked that
it swapped to the RIGHT body, or that the hash survived. All four bugs in
the 2026-07-17 archive stack shipped undetected past a green suite.

Adds the hand-run walk for what the unit tests can only simulate: hash
stability across reload->republish (and across four cycles, since drift is
exponential), the text-neutral cases (entity tag, title typo, markdown
tab), the transcript attach that nearly published an empty kind-30023 over
a real article, the real-edit case that MUST still fork, the #r
over-match that serves a linking article under your URL, and the false
banner.

Records the prereq that makes the walk meaningful — the fixture must
contain turndown-escapable characters, because plain prose round-trips
cleanly and passes against the bug.

Marks the section agent-unverifiable in the coverage table, honestly: the
reader paints a correct body and a correct hash line in every one of these
failure modes. The event body is the only witness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bryanmatthewsimonson
bryanmatthewsimonson merged commit 891fc2d into main Jul 18, 2026
@bryanmatthewsimonson
bryanmatthewsimonson deleted the claude/archive-escape-doubling 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