fix(archive): reload must not drift the x tag — carry the preimage, prove it#187
Conversation
…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>
Post-review update — a critical regression was caught and fixed before mergeI 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
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 It was a regression this PR introduced — pre-#187 the guard had no The lesson, which is the actual fixThis was the same mistake the design review had already caught one level down. The refuters proved So Two more, both fixed
Verification
Two findings were refuted and are not real: that #185 gates client-side over an unchanged |
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>
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 distinctxtags from one unedited article.assembleArticleBodystates the invariant this breaks out loud: "Conversion runs ONCE per body, ever."Why it mattered more than it looked: the
dtag 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 —isMarkdownCanonicalneedsarticle.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
reconstructArticleFromEventnow carries_publishedDraft— the valuecontentheld when the event was built (assembleArticleBody's input, whose output is thextag's preimage).shared/archive-draft.jsproves 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:
dirtySourcewriters.dirtySource='reader'has four writers. Setting the verdict only inloadArchivedArticleleavesonTag/onEntityTagto flip it back — so tagging one entity, the tool's primary workflow, re-armed the bug in full (measured3→9→21→45→93).onReaderFieldInputflips it unconditionally, so fixing a typo in the title drifted the body. Hencestate.draftProven, guarding the threehtmlToMarkdown(htmlDraft)sites —dirtySourcerecords where a draft came from,draftProvenrecords whether it can be trusted, and text-neutral edits move one without the other.markdown— that key armsisMarkdownCanonical, which would declare a relay-reconstructed PDF canonical without proof. A PDF whose text contains a## Descriptionheading loses that section to reconstruct's regex and would have been republished 48 of 120 bytes short.assembleArticleBodyre-appends Description/Transcript for video and emitted them twice. The remainder is what composes.adoptArticle(case view's "Extract claims", case-graph nodes), never throughloadArchivedArticle.Rejected alternatives (both tested, not assumed)
markdownToHtmla turndown inverse — a ~1-line unescape, but fixes only 1 of 5 shapes (no tables, no nested lists) and is retroactively unsafe: platform handlers setcontent = markdownToHtml(bodyMarkdown), so it silently moves every already-published platform capture'sxtag.5 \* 3and5 * 3would share one content address. Fatal for a content address.Wire format: unchanged
No kind, tag, or field moves; the hash algorithm is untouched;
_publishedDraftis read-side andassembleArticleBodynever reads it. What changes is thextag 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 intobuildArticleEvent.tests/archive-reload-hash.test.mjscloses that loop.Its fixtures are escape-prone on purpose (
5 * 3,that_is_it, a line starting14.) — plain prose round-trips byte-stably and passes against the bug, includingaudit-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 test— 1756 pass, 0 fail · build clean · lint 0 errorsxtag (was 5). A real body edit still forks — that is a different article.Worth checking after this lands
Any archive row with
priorVersionsentries you don't recognize: those are this bug's fingerprints — spurious "stealth edits" recorded against articles you never touched.🤖 Generated with Claude Code