Repository navigation
Releases: bluetieroperations-create/lethe
Release list
v0.7.3
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-test...
v0.7.2
The first release that reaches PyPI in over two months. pip install lethe-delete has served 0.2.0 since July while the repo moved to 0.7.x; this
tag is the one that closes that gap, and everything below exists to make sure
it stays closed. The lethe package's code is unchanged from 0.7.0 —
git diff v0.7.0..v0.7.2 -- lethe/ is empty — so the value here is
distribution, not new behaviour.
Fixed
pip install lethe-deleteserved 0.2.0 while the repo was at 0.7.1.
Nothing published to PyPI after July; the Release workflow only ever created
a GitHub Release. The documented MCP install was broken the whole time as a
result: 0.2.0's[mcp]extra declares an unpinnedmcp>=1.9, so
pip install "lethe-delete[mcp]"resolveslethe-delete 0.2.0with
mcp 2.2.0— verified — andlethe/mcp.pyat 0.2.0 does
from mcp.server.fastmcp import FastMCP, a module 2.x renamed. Users got the
exact breakage 0.6.0 fixed. It also madepip install ./notaryfail outright
with no matching distribution, since the notary requires
lethe-delete>=0.7.- The documented install commands are pinned, so they fail loudly instead
of quietly serving a version from before certificate schema v3, the audit
chain'sprev_hashUNIQUE index, and the anchor credential-leak fix:
pip install "lethe-delete>=0.7"andpip install "lethe-delete[mcp]>=0.7".
The mcp 2.x migration landed in the CHANGELOG's 0.6.0 section, but 0.6.0
was never tagged and has no release, so 0.7 is the lowest floor a user can
actually install.notary/README.mdnow installslethe-deletefrom the
repo root before the notary, which is the sequence that actually works.
Added
- The Release workflow publishes to PyPI, from the same tag that creates
the GitHub Release. Trusted Publishing over OIDC — no API token in the repo,
in Actions secrets, or anywhere to leak — and no third-party action, for the
same reason the release step usesghdirectly. The token is minted and
used inside one process, so it never crosses a step boundary. The job holds
id-token: writeand nothing else;contents: writeis now scoped to the
release job alone rather than granted workflow-wide. - A check that the built artifacts carry the tag's version, not just
lethe/version.py. The wheel is what gets uploaded, and a PyPI version can
never be re-uploaded. docs/releasing.md— how to cut a release, the one-time PyPI Trusted
Publisher setup, and why pushing a tag needs the explicit
refs/tags/X:refs/tags/Xrefspec (a plain tag push can print
Everything up-to-dateand drop the ref).
v0.7.1
Everything in this release is in notary/ and in CI. The lethe package is
unchanged from 0.7.0 — git diff v0.7.0..v0.7.1 -- lethe/ is empty — so there
is nothing to gain by upgrading lethe-delete itself. Upgrade if you run the
notary: as declared, it could not take a payment on a clean install.
Fixed
lethe-notarydeclaredx402>=2but the payment path needsx402[evm]
andx402[httpx]. A clean install started the service and could not take a
payment. Measured on a clean venv: barex402raisesImportErrorfrom
x402.mechanisms.evm.exact.register("EVM mechanism requires ethereum
packages"), andx402[evm]withouthttpxfails preflight with "No module
named 'httpx'". Invisible locally, whereeth_account,eth_utilsand
httpxall arrive transitively. Missingeth_utilsadditionally disabled
the EIP-55 checksum onLETHE_NOTARY_PAY_TOin silence — confirmed by
feeding it an address with one wrong character, which it accepted.lethe-notary[dev]declaredhttpx2, which is a real distribution that
ships a module namedhttpx2. Starlette'sTestClientimportshttpx. The
tests passed only becausehttpxarrived transitively; a clean install of the
extra could not importTestClientat all.notary/tools/pay.pyoverwrote the previous receipt. A fixed
receipt.jsonmeant buying a second certificate destroyed the first
receipt — the artifact the tool's own output calls "the evidence". Measured:
two purchases left one file. Receipts are now named after the certificate's
payload hash, and a differing receipt at that path is refused rather than
replaced. Re-running the same certificate rewrites an identical file, so the
idempotency check stays a two-command exercise.pay.pycrashed with a bareKeyErroron a 402 carrying no
PAYMENT-REQUIREDheader — a stripped header or a URL that is not a
notary. It now says which, and exits 2.PaymentGate.chargereturnedtuple[bool, object | None], and an
objectin the return position meant a type checker could see nothing about
its callers — including the three route handlers whose bodiesmypywas
therefore skipping entirely. It now returns either the response to send or
the settlement record, so the two outcomes are distinguishable by type.
Checking those bodies immediately surfaced six unnarrowed values in
/witness, whereall(isinstance(v, str) for v in ...)validates at runtime
but narrows nothing.
Added
notary/is now covered by CI:ruff,mypy, its own test run, and a
no-extras install job that imports the whole paid path and quotes a price.
It was checked by hand for three releases, which is another way of saying
nobody was checking. Dependabot now watchesnotary/pyproject.tomltoo.- The notary says whether it is charging real money.
network_kind()
classifies a CAIP-2 id astestnet,mainnetorunknown— never guessing,
because assuming either way is unsafe in a different direction. The startup
banner and/.well-known/notaryboth report it, so an operator one
environment variable away from mainnet, and an agent deciding whether to pay,
can both tell. notary/tools/pay.py— the buyer side of the x402 flow. It exists to be
run twice: the second run of the same certificate must report
charged: falseand settle nothing, which is the idempotency guarantee the
payment design rests on.- The skipped-test guard in CI now covers the notary's suite as well, and fails
on a missing report rather than passing over a run that never happened. - Two tests for the settlement reference the notary hands back — the payer's
only independent proof the money moved. v0.7.0 fixed the bug that discarded
it, but the fix was guarded only bylive-marked tests that CI never runs:
dropping the payment block from either the normal response or the
witness-write-failure response left the whole offline suite green.
v0.7.0
Added
-
notary/— a paid countersigning witness, billed over
x402. A separate distribution (lethe-notary); nothing
inlethedepends on it andlethegains no payment dependency.It sells the one thing Lethe structurally cannot give itself. A certificate
is self-attestation —docs/anchoring.mdsays nothing in the artifact brings
in a party the operator does not control — so the notary is that party: an
independent clock, an independent signature, and a countersigned record of
the audit head. That last one is the product, because tip-truncation is
undetectable from inside the chain.The evidence is the receipt, which the operator holds and the notary cannot
repudiate — not the notary's own log, which is why that log is not
hash-chained (chaining it would only let the notary prove things to itself).
/witnessreads the heads back during a dispute; it is a convenience, and
notary/README.mdsays so.A receipt claims only what the notary can support: that a certificate was
presented at a time, is internally valid, and named this head. Not the
presenter's identity, and not that the deletion happened.The controller pays, for evidence about their own compliance — nothing sits
between a data subject and their erasure (GDPR Art. 12(5)), and witness
retrieval, the query run during a dispute, is permanently free. A certificate
that fails verification is never charged for, nor is one already witnessed.Settlement is verified on-chain — 0.01 USDC moved by EIP-3009
TransferWithAuthorizationin block 46487830 — on Base Sepolia. Mainnet
needs a facilitator that settles it, and the first mainnet payment deserves
the same scrutiny: check the chain, not the200.
Fixed
-
A
forgetthat outruns the audit chain now says the rows are gone. If
the completion append exhausts its retries under contention, the deletion has
already happened — but a bareAuditContentionreads as "the forget
failed", and an operator would retry a deletion that completed, find nothing,
and conclude nothing was ever deleted.Lethe.forgetnow raises
ForgetRecordedIncompletely, which states that the deletion completed, names
the record count and theforget_startedhead, says not to re-run it, and
carries the certificate — at that point the only record of the run outside
the chain. -
Concurrent audit appends no longer fork the chain.
AuditLog.append
reads the chain tip, hashes it, and inserts. Two writers that read the same
tip both committed, leaving two entries claiming one predecessor — and
verify_chain()then reported the operator's own log as tampered with, at
exactly the moment they were trying to prove it had not been.This was not hypothetical and not MCP-specific. Every CLI command opens its
own connection, anddocs/anchoring.mdtells operators to runlethe anchor
hourly on a timer, so a scheduled anchor overlapping alethe forgetis the
documented deployment. Measured before the fix: 8 concurrent appends across
separate connections produced 8 entries with 5 distinctprev_hashvalues
andverify_chain()returningFalse.prev_hashnow carries aUNIQUEindex. In a linear chain every entry links
to a distinct predecessor, so uniqueness is the chain invariant; stating it
as a database constraint makes a fork impossible rather than unlikely. The
losing writer is rejected, re-reads the tip, and links onto the winner's
entry. This is the only guard that works across processes — an in-process
lock cannot help two CLI invocations.Retries back off with full jitter. Without it, writers that collide once
collide again in lockstep: measured at 24 concurrent writers, 5 exhausted
their attempts and their entries were never recorded. The chain stayed
intact, but an unrecorded forget is its own kind of hole. With backoff, 40
concurrent writers across separate connections all record, chain intact,
stable across repeated runs.Known limitation, now documented: this makes concurrent connections
safe, not one connection shared by concurrent threads — there the transaction
is shared, so a rollback in one thread aborts another's work. The MCP server
holds a single connection and serializes tool bodies for exactly this reason. -
lethe verify-auditnames a fork instead of only sayingINVALID.
INVALIDcovers a fork, a tampered entry and a truncated tail — three
situations calling for very different responses. An operator upgrading a
chain written before this release, readingINVALIDas "we were breached"
when concurrent appends were the cause, has been told the wrong thing. The
command now printsFORK: rows [n, m] all claim predecessor …for each
split, and says that concurrency explains it.AuditLog.forks()is the
underlying query. -
init_schemaupgrades an existing table in place, adding the index to
chains written by earlier versions. If such a chain already contains a fork
the index cannot be built, and rather than a raw Postgres error this raises
AuditChainForkednaming the duplicated hashes and the rows that share them.
The rows are left untouched — they are evidence, not something to clean up
silently.AuditLog.forks()reports the same thing on demand.
Not changed, deliberately
- MCP tool calls stay serialized. Now that the chain is safe at the
database, the remaining reason for the lock is the single shared psycopg
connection. Adding a connection pool would buy throughput that a
DSAR-deletion workload does not need, in the subsystem where correctness is
the product.lethe/mcp.pyrecords the reasoning.
v0.5.0
Added
-
Verification against a registry of historical keys.
verify_certificate_json(cert, trusted_keys={key_id: public_key})— and the
same onverify_certificate. cert v3 made certificates self-describing about
which key epoch signed them, but both verifiers took a single key, so
docs/key-rotation.mdhad to tell readers to hand-maintain the mapping and
do the lookup themselves. The certificate knows; the verifier now reads it.A certificate naming an epoch that is not in the registry fails with the new
UNKNOWN_KEY_ID, kept distinct fromKEY_MISMATCHbecause the certificate
is fine and the verifier simply has not been given that key. Certificates
predatinglethe.cert/3carry nokey_idand report the same, saying so.
Passing the current key alone to an old certificate still fails: the registry
makes rotation usable, it does not loosen the pin. -
lethe anchor --emit FILE— writes a self-contained anchor record (the
attested head, the raw RFC 3161 token, and theopensslcommand to check
them) for publishing alongside your public key.docs/anchoring.mdsays
anchoring closes backdating on its own but closes tail truncation only if a
token also lives somewhere the operator cannot reach; there was previously no
way to get one out. A token that exists only inside the chain disappears with
the chain. -
Dependabot no longer re-proposes the
mcpmajor bump. The<2bound is
deliberate — 2.x renamedFastMCPtoMCPServerandlethe/mcp.pyis still
v1 code — so the same PR was reopening weekly (#5). Remove the ignore entry
as part of the 2.x migration, not before.
Fixed
-
The recorded timestamping authority no longer carries a credential.
lethe/anchor.pyalready scrubbed the TSA URL out of error messages because
"a TSA endpoint may embed a customer identifier" — then wrote that same URL
verbatim into the audit chain and into the--emitfile, whose entire
purpose is publication. A paid TSA authenticated as
https://acct:secret@tsa.example/tsr?apikey=…would have published the
operator's account credential to every recipient of the anchor record.HTTP userinfo and the query string are now stripped before the authority is
recorded; scheme, host, port and path are kept, since that is what identifies
the authority to a verifier, and the token carries the TSA's certificate
anyway. The request still goes to the full URL. A credential embedded in the
path cannot be stripped —docs/anchoring.mdnow says so. -
--emitwrites atomically. It truncated the target in place, so a
failure mid-write destroyed the previously published record — the copy of the
evidence that is supposed to survive the operator — and could serve half a
document to a reader fetching the path. Now written to a temporary file in
the same directory and renamed over the target, with the temporary removed on
failure.
v0.4.0
Added
-
Namespace allowlist (#8).
lethe_tagaccepted a namespace straight from
its caller, so an agent driving the MCP server could direct a delete at any
table the configured database user could write — not only the stores Lethe
was set up to sweep. Confirmed by reproduction against v0.3.1: a single tag
call naming an unrelated table, then the ordinary preview/confirm/forget
sequence, deleted the row and produced a certificate reading
all_verified: true. The certificate was honest, which is what made it
hard to notice — nothing malfunctioned.Lethe(allowed_namespaces={"pgvector": {"documents"}})and
LETHE_ALLOWED_NAMESPACES=pgvector:documents,...name the(store, namespace)pairs a deployment may ever tag, and therefore ever delete from.Enforced in
Lethe.tag, not at the MCP boundary: the ledger is what
forget()deletes from, so guarding one entry point would leave the CLI, the
library andreconcile(tag_untracked=True)able to write entriesforget()
would then honour.reconcilevalidates every target before scanning, so a
refusal cannot leave a partial remediation.forget()enforces it too, not onlytag(). A ledger row can predate
the allowlist, or be written by anything with SQL access to
lethe_provenance, and the delete path is the one that matters. A layer
outside the allowlist is recorded as unhandled — the same shape as a store
with no configured connector — soall_verifiedgoes False and the
certificate reports that a layer was found and deliberately not swept, rather
than omitting it. Allowed layers in the same run are still swept; the ledger
is preserved so an operator can fix the configuration and retry.Unset means unrestricted, preserving existing behaviour on upgrade — but
a value that is set and empty is a misconfiguration, not a way to say "allow
nothing", because running unrestricted due to a variable expanding to nothing
is the exact failure this control exists to prevent.lethe_statusnow
reportsnamespace_allowlistso an operator can see which mode they are in,
and MCP returns aNAMESPACE_NOT_ALLOWEDenvelope rather than an INTERNAL
traceback. -
lethe ledger-scope— lists what the ledger holds against the configured
allowlist, and--purge-disallowedclears rows outside it (removing Lethe's
record that they exist; it does not delete from the store). Configuring an
allowlist does not retroactively clean the ledger, and a row tagged before it
existed blocks that subject from ever certifying again — so it has to be
visible and clearable. Exits non-zero while out-of-policy rows remain.
Fixed
lethe forget— the command that actually deletes — did not apply the
allowlist at all; it was wired intoreconcileonly.preview()(and solethe_forget_preview) did not mark layers that
forget()will refuse, so the confirm token was minted over a blast radius
that misstated what would happen. Layers now carryallowed.build_contextvalidated the allowlist only after opening a database
connection. Configuration is now checked before any side effect, so a
malformed allowlist fails startup rather than leaving a connection open.
v0.3.1
Fixed
-
The documented verification API was unimportable on a plain install.
lethe/cert_schema.pyimportsjsonschemaat module level, but the
dependency was declared only under thedevandmcpextras — so
pip install lethe-deletefollowed by
from lethe.cert_schema import verify_certificate_jsonraised
ModuleNotFoundError. That is the structured, machine-readable verification
path (the one returningKEY_ID_MISMATCH/PAYLOAD_TAMPERED), and
docs/key-rotation.mdtells readers to call it.jsonschemais now a base
dependency: schema-validated verification is not an optional add-on for a
tool whose claim is that its certificates are independently verifiable.The cryptographic path was unaffected —
lethe verifyand
lethe.certificate.verify_certificateworked throughout.
Added
- A
base-installCI job that installs the package with no extras and
imports every module a user can reach without opting in, then verifies a
certificate end to end. It runs from outside the checkout so Python loads the
installed package rather than the source tree — importing from the tree
succeeds regardless of what the wheel declares, which is precisely why this
slipped through. No unit test could have caught it: the dev environment
always has the extras installed. Confirmed to fail against v0.3.0 and pass
against this release.
v0.3.0
Version 0.2.1 was prepared but never tagged or published; its contents are
included here.
Added
- Certificate schema
lethe.cert/3.lethe.cert/1andlethe.cert/2
certificates still validate and verify.key_id— which key epoch signed the certificate, derived from the public
key (ed25519:+ truncated SHA-256). Recomputed and checked at
verification, so a certificate cannot name a key other than its signer.
New reason codeKEY_ID_MISMATCH.audit_head— the audit-chain position the deletion run started from, so
the certificate and the tamper-evident log reference each other.reverifiable— whether the issuer retained what a later re-query needs.timestamp— reserved, currently alwaysnull: a slot for external
corroboration ofissued_at(e.g. RFC 3161) so adding one later needs no
schema v4.
reconcile()/lethe reconcile— compares what a store actually holds
for a subject against what the provenance ledger knows, exposing records that
bypassed the wrapper.--tag-untrackedtags findings for deletion. New
optional connector capabilityscan(), implemented for pgvector.reverify()/lethe reverify— re-checks absence aftervalid_until,
enabled by the newLethe(retain_verification_ids=True).- External anchoring —
lethe anchortimestamps the audit chain head with
an RFC 3161 authority, and records the raw token as a chain entry. Anchoring
the head rather than each certificate covers every recorded event, keeps the
authority out of the deletion path (a TSA outage never blocks a data-subject
request), and costs one call per interval. Closes backdating: an entry cannot
be inserted before an anchored head, and a timestamp dated in the past cannot
be obtained. Optional dependency:pip install 'lethe-delete[anchor]'.
Seedocs/anchoring.md. docs/anchoring.md— choosing an authority (including eIDAS qualified
timestamps), what anchoring does and does not prove, and how to verify a
stored token withopenssl ts.docs/threat-model.md— who must be trusted for a certificate to mean
anything, and what holds against a third party versus a dishonest operator.docs/key-rotation.md— rotation, verifying older certificates, and the
key-compromise case.SECURITY.md— private vulnerability reporting and scope.- CI — GitHub Actions on Python 3.11 and 3.12 against a real Postgres
service, including a guard that fails the build if any test is skipped. - CodeQL — static analysis on pushes, pull requests, and weekly, using the
security-extendedquery suite. The weekly run matters as much as the diff
runs: new queries find issue classes in code that has not changed. - Release workflow — pushing a
v*tag publishes the GitHub Release, with
notes taken from this file. It refuses to publish if the tag disagrees with
lethe/version.py, or if the changelog section is missing or still marked
unreleased, so a tag and its release cannot describe different things.
Changed
- The certificate
claimnow discloses self-issuance inside the signed
payload:issued_atis the issuer's own clock, and the signature proves only
that the key holder produced it. The limitation travels with the artifact. - The claim's re-verify advice is scoped to
reverifiable, rather than
advising an action the default configuration makes impossible.
Fixed
mcp2.x broke the MCP server. Themcpextra declaredmcp>=1.9,
which resolves to 2.x whereFastMCPwas renamed toMCPServer, so
lethe.mcpraisedModuleNotFoundErroron import. Pinned tomcp>=1.9,<2;
migrating to the 2.x API is separate work.- The package version had drifted from the test suite (
test_versionstill
asserted0.1.0after the 0.2.0 release).pyprojectnow derives the
version fromlethe/version.py, so the two cannot diverge. - Two DB tests raised
KeyErrorinstead of skipping when
LETHE_TEST_DATABASE_URLwas unset. - Test isolation: the new retention table is now dropped between tests.