Repository navigation
The release that makes lethe-delete's PyPI page correct. git diff v0.7.2..v0.7.3 -- lethe/ is empty — the library's code is unchanged — so what
ships here is the long description and the docs around it. That is the point
rather than a caveat: the description is the artifact that was broken. Every
relative link on the project page 404'd, and because a long description is
frozen into the uploaded artifact and the page renders only the latest release,
no amount of fixing main could repair it. Only publishing a new version can,
which is this one.
Added
-
The root README's links will work on the next PyPI release, and cannot
regress after that. Not now: a long description is baked into the uploaded
artifact and a project page renders only the latest release, so
pypi.org/project/lethe-delete/keeps serving 0.7.2's broken copy until
0.7.3 goes out — verified by reading the liveinfo.description, which still
carries all seven. This entry first claimed the present tense, which is the
classdocs/reviewing.mdhad just been given ("the fix is not retroactive"),
broken in the change immediately after writing it. The sdist built from this
commit carries zero relative targets in itsPKG-INFO, so the fix is real —
it is just not live yet.All seven relative links are absolute: six
docs/*.mdoccurrences
(threat-model.mdtwice) and](LICENSE). That file islethe-delete's
long description, andreadme_rendereremits a relative target verbatim, so
each one resolved againstpypi.org/project/lethe-delete/and 404'd. The
display text is unchanged; only the targets moved, and the link count is the
same 20 before and after. Every rewritten target was confirmed to exist on
mainbefore the rewrite, so none of these trade a 404 for a different 404.How long they were broken is worth stating precisely, because the first
telling of it was wrong.lethe-deletehas eightv*tags but only
three versions on PyPI — 0.1.0, 0.2.0, 0.7.2 — and a project page renders
only the latest, so tags and GitHub Releases are both the wrong unit. The
live page serves 0.7.2, whose README carries all seven. Before that it served
0.2.0, whose README carried two of them (docs/m2m.mdandLICENSE, the
only two present at every tag). Read from PyPI per version: 0.1.0 and 0.2.0
each published two relative targets, 0.7.2 publishes six distinct ones across
seven occurrences. So the page has been broken since the very first upload on
2026-07-06, and has shown all seven since 0.7.2 was uploaded on
2026-09-11 — not the 2026-09-08 this entry first gave, which was the tag
date; the page changes when the artifact is uploaded, not when a tag is cut.
Per-link tag coverage ranges from 1/8 (docs/releasing.md, added just before
the last release) to 8/8 — never the uniform "seven" first claimed.The guard that replaces remembering is one assertion with no allowlist,
because fixing all seven leaves the inventory empty and an empty rule beats a
rule with exceptions. It reads the file from each package'sproject.readme
rather than a hardcoded path, so renaming the readme moves the check with it
instead of leaving it pointed at a file nobody publishes — the same lesson as
PaymentConfigvalidatingfrom_envand nothing else.It covers the whole link surface, not the part in use today: inline links,
images, reference-style definitions and raw HTMLhref/src. Images
matter because the scan that found the original seven matchedhref=only,
and an image issrc=— so it never looked at the one image in the file. It
happened to be an absolute CI badge, but the check had a hole where a
relative badge would have sat. Fenced and inline code are stripped first, so
a README may still show a relative link as an example without tripping it.Nine file-level mutations: each of the seven link forms reverted or added
back fails it (including innotary/README.md, and including a renamed
readme =), while a relative link inside a code fence, a bare#anchor, and
amailto:all correctly do not.Those mutations were not enough, which is why there is also a table of 19
link forms asserting which must be caught and which must not. Probing the
patterns that way found one that renders, is relative, and was not
flagged: an unquoted HTML attribute,<a href=docs/x.md>. Valid HTML5, kept
by the sanitizer — measured, not assumed:readme_rendererturns
<a href=docs/x.md>intohref="docs/x.md" rel="nofollow", so the relative
target really does reach the page — and missed because the pattern demanded
quotes. Reading the regex had not found it; a table did. A second test keeps
both halves of that table populated, so it cannot drift to all-catch or
all-ignore and still pass. -
The notary has its own changelog,
notary/CHANGELOG.md,
and the entries that belonged to it have moved there. This file's
[Unreleased]had collected eight of them — mainnet auth, the catalog
identity, the honest banner, the construction-time validation — and all eight
had already shipped, inlethe-notary0.2.0 and 0.2.1.That is not untidiness, it is a release that lies.
release.ymlextracts
## [X.Y.Z]from this file verbatim as the GitHub Release notes, so renaming
[Unreleased]for the nextv*tag would have published, aslethe-delete
X.Y.Z's notes, "the notary can settle on mainnet" — describing a different
package, shipped weeks earlier, in a release whose artifact contains none of
it. The extraction guard exists precisely so the release and the changelog
cannot tell different stories; it cannot tell that the story is about the
wrong product.Reaching that file from PyPI turned out to need two links rather than one,
and the difference is only visible by reading the published record instead of
the tree —pypi.org/pypi/lethe-notary/{0.2.0,0.2.1}/jsoncarries no
ChangelogURL, metadata is frozen at upload, and the sdist ships no
CHANGELOG.md. So aChangelogproject URL covers releases from the next
one on, and a line innotary/README.mdcovers the two already out, because
their frozenDocumentationURL points at that README onmainand resolves
live. Both links are asserted; deleting the README one is the only failure
here that publishing again cannot repair.Those links must be absolute, which is measured rather than assumed:
readme_renderer46.0 — the library PyPI renders long descriptions with —
passes](CHANGELOG.md)through ashref="CHANGELOG.md", which on
pypi.org/project/<name>/resolves to a 404. Checking that is what caught
this change adding a relative link to the root README, one file away from
the guard it had just written for the notary's. It also turned up seven
pre-existing relative links in that README, which islethe-delete's long
description. Fixed in the entry below, which also corrects two things this
entry originally got wrong about them: they are not alldocs/*.md(six are,
withthreat-model.mdlinked twice; the seventh is](LICENSE)), and "for
seven releases" was a count of GitHub Releases, which is not what renders.Worth recording how it got there:
release-notary.ymlwas written with a
comment saying this changelog "is versioned to lethe-delete — there is no
## [0.2.0]section that means the notary. Inventing one here would be the
exact drift that guard exists to prevent." The trap was named correctly, in
writing, and then walked into anyway, because the note explained why the
workflow should not invent a section and said nothing about where the
notary's entries were piling up meanwhile. A hazard you have described is not
a hazard you have handled.So the split is enforced rather than remembered. A
notary-v*tag whose
version has no section innotary/CHANGELOG.mddoes not publish; and because
that check only fires once the tag is pushed,notary/tests/test_packaging.py
makes the same assertion on every commit, along with one that the notary's
changelog is not a copy of this one. Eight ways for the split to come apart
quietly were tried against those tests — a missing section, a version bumped
without one, a heading with nothing under it, a section whose number is only
a prefix of the version, this file copied over that one, and three ways to
cut the notary's changelog off from its readers — and each fails the test
meant to catch it. -
docs/reviewing.md, written from the defects a single hard run actually
produced rather than from general principle. The finding worth the document:
almost none of them were in the payment logic — they were in the machinery
meant to catch problems. A stub facilitator that stripped the port and hid a
real bug for a commit; a CI guard that sent its own diagnostic to
/dev/null; anoqaon the dead import it described; a comment claiming to
match an SDK, written from memory, wrong twice; validation attached to
from_envand therefore to nobody who constructs the object directly.It also records a stopping rule, because three audit passes over one change
each found something. The signal is not that a pass found something, it is
what class it found: pass three repeating pass two's class meant pass two's
enumeration was incomplete, not that a fourth pass was owed. Repeat class →
make the class exhaustive, do not look harder.tests/test_review_invariants.pyenforces the parts that can be: an
inventory of every signal suppressor in the tree —noqa,importorskip,
skip marks — each with the reason it was allowed, failing on a new one, on a
growing count, and on a stale entry; plus a check that no CI step sends a
guard's output to/dev/nullunless it is a--helpsmoke test where the
exit code is the whole signal. Mutation-tested against six ways to slip one
past, including the inventory itself going vacuous. -
docs/fleet-reset.md— a design note on a second target shape: restoring
a fleet of agents to an attested baseline, rather than deleting one data
subject's records. Nothing is built. It exists because
arXiv:2608.10218 states the requirement
Lethe's spine would satisfy — contamination must be cleared from every agent
at once, or the ones still infected re-infect the ones just cleaned — and
because that question ("did we actually clean all of them, and can we show
it?") outlives the particular attack that raised it. The note is equally
clear about what not to do: the mitigation for that attack is free, so nobody
should sell protection from it, and a detector keyed on the paper's "viral
persona" would be semantic and should not be built.
Fixed
-
pytest notary/tests testsfailed to collect. Both directories go on
sys.pathand both had a module namedconftest, so
from conftest import make_certin the notary tests resolved to the root
one, which has no such name — five files failed to import. CI never saw it,
because it runs the two suites as separate steps for unrelated reasons
(different dependencies); the person who hit it was whoever ran a bare
pytestover both directories, which is the obvious first thing to try. The
helper moved to a uniquely-named module, which cannot collide, and CI now
collects both suites in one invocation so this cannot come back quietly. -
A failed PyPI publish no longer costs a version number. The Release
workflow takes aworkflow_dispatchwith a tag input that runs only the
publish job, checked out at that tag, leaving the existing GitHub Release
alone. Before this, the sole retry was a fresh tag, because
gh release createrefuses an existing release — so v0.7.2's failed upload
would have had to become v0.7.3. The hand-run path keeps its guards: the tag
must look like a release tag, the checkout must be sitting on that tag's
commit, and the built artifacts must carry that tag's version — three checks
replacing the tag-vs-version.pygate that lives in the skipped Release job.
The publish job is also gated on!cancelled()rather thanalways():
always()runs through a cancelled workflow, and cancelling is the only stop
button there is on a step that cannot be undone. -
The publish step prints what PyPI actually said. Its 422 body names the
claim that did not match; the first version swallowed it and guessed, so
v0.7.2 reported a guess. (The guess was wrong twice over: the message blamed
a missing publisher, and a later revision blamed a mismatched one — the page
showed no publishers configured at all, which a 422 also covers and the
hint now says.) The trap worth naming in advance — entering the workflow's
display nameReleaseinstead of the filenamerelease.yml— is in the
error and indocs/releasing.md.