Skip to content

Releases: bluetieroperations-create/lethe

v0.7.3

Choose a tag to compare

@github-actions github-actions released this 11 Oct 02:21
cb72dc5

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 live info.description, which still
    carries all seven. This entry first claimed the present tense, which is the
    class docs/reviewing.md had 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 its PKG-INFO, so the fix is real —
    it is just not live yet.

    All seven relative links are absolute: six docs/*.md occurrences
    (threat-model.md twice) and ](LICENSE). That file is lethe-delete's
    long description, and readme_renderer emits a relative target verbatim, so
    each one resolved against pypi.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
    main before 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-delete has eight v* 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.md and LICENSE, 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's project.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
    PaymentConfig validating from_env and nothing else.

    It covers the whole link surface, not the part in use today: inline links,
    images, reference-style definitions and raw HTML href/src. Images
    matter because the scan that found the original seven matched href= only,
    and an image is src= — 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 in notary/README.md, and including a renamed
    readme =), while a relative link inside a code fence, a bare #anchor, and
    a mailto: 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_renderer turns
    <a href=docs/x.md> into href="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, in lethe-notary 0.2.0 and 0.2.1.

    That is not untidiness, it is a release that lies. release.yml extracts
    ## [X.Y.Z] from this file verbatim as the GitHub Release notes, so renaming
    [Unreleased] for the next v* tag would have published, as lethe-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}/json carries no
    Changelog URL, metadata is frozen at upload, and the sdist ships no
    CHANGELOG.md. So a Changelog project URL covers releases from the next
    one on, and a line in notary/README.md covers the two already out, because
    their frozen Documentation URL points at that README on main and 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_renderer 46.0 — the library PyPI renders long descriptions with —
    passes ](CHANGELOG.md) through as href="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 is lethe-delete's long
    description. Fixed in the entry below, which also corrects two things this
    entry originally got wrong about them: they are not all docs/*.md (six are,
    with threat-model.md linked 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.yml was 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 in notary/CHANGELOG.md does 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; a noqa on the dead import it described; a comment claiming to
    match an SDK, written from memory, wrong twice; validation attached to
    from_env and 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.py enforces 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/null unless it is a --help smoke test where the
    exit code is the whole signal. Mutation-test...

Read more

v0.7.2

Choose a tag to compare

@github-actions github-actions released this 11 Sep 20:16
04be9b1

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-delete served 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 unpinned mcp>=1.9, so
    pip install "lethe-delete[mcp]" resolves lethe-delete 0.2.0 with
    mcp 2.2.0
    — verified — and lethe/mcp.py at 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 made pip install ./notary fail 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's prev_hash UNIQUE index, and the anchor credential-leak fix:
    pip install "lethe-delete>=0.7" and pip 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.md now installs lethe-delete from 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 uses gh directly. The token is minted and
    used inside one process, so it never crosses a step boundary. The job holds
    id-token: write and nothing else; contents: write is 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/X refspec (a plain tag push can print
    Everything up-to-date and drop the ref).

v0.7.1

Choose a tag to compare

@github-actions github-actions released this 07 Sep 17:23
628a14d

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-notary declared x402>=2 but the payment path needs x402[evm]
    and x402[httpx].
    A clean install started the service and could not take a
    payment. Measured on a clean venv: bare x402 raises ImportError from
    x402.mechanisms.evm.exact.register ("EVM mechanism requires ethereum
    packages"), and x402[evm] without httpx fails preflight with "No module
    named 'httpx'". Invisible locally, where eth_account, eth_utils and
    httpx all arrive transitively. Missing eth_utils additionally disabled
    the EIP-55 checksum on LETHE_NOTARY_PAY_TO in silence — confirmed by
    feeding it an address with one wrong character, which it accepted.
  • lethe-notary[dev] declared httpx2, which is a real distribution that
    ships a module named httpx2.
    Starlette's TestClient imports httpx. The
    tests passed only because httpx arrived transitively; a clean install of the
    extra could not import TestClient at all.
  • notary/tools/pay.py overwrote the previous receipt. A fixed
    receipt.json meant 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.py crashed with a bare KeyError on a 402 carrying no
    PAYMENT-REQUIRED header
    — a stripped header or a URL that is not a
    notary. It now says which, and exits 2.
  • PaymentGate.charge returned tuple[bool, object | None], and an
    object in the return position meant a type checker could see nothing about
    its callers — including the three route handlers whose bodies mypy was
    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, where all(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 watches notary/pyproject.toml too.
  • The notary says whether it is charging real money. network_kind()
    classifies a CAIP-2 id as testnet, mainnet or unknown — never guessing,
    because assuming either way is unsafe in a different direction. The startup
    banner and /.well-known/notary both 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: false and 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 by live-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

Choose a tag to compare

@github-actions github-actions released this 07 Sep 03:29
a50585a

Added

  • notary/ — a paid countersigning witness, billed over
    x402.
    A separate distribution (lethe-notary); nothing
    in lethe depends on it and lethe gains no payment dependency.

    It sells the one thing Lethe structurally cannot give itself. A certificate
    is self-attestation — docs/anchoring.md says 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).
    /witness reads the heads back during a dispute; it is a convenience, and
    notary/README.md says 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
    TransferWithAuthorization in 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 the 200.

Fixed

  • A forget that 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 bare AuditContention reads as "the forget
    failed", and an operator would retry a deletion that completed, find nothing,
    and conclude nothing was ever deleted. Lethe.forget now raises
    ForgetRecordedIncompletely, which states that the deletion completed, names
    the record count and the forget_started head, 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, and docs/anchoring.md tells operators to run lethe anchor
    hourly on a timer, so a scheduled anchor overlapping a lethe forget is the
    documented deployment. Measured before the fix: 8 concurrent appends across
    separate connections produced 8 entries with 5 distinct prev_hash values
    and verify_chain() returning False.

    prev_hash now carries a UNIQUE index. 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-audit names a fork instead of only saying INVALID.
    INVALID covers 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, reading INVALID as "we were breached"
    when concurrent appends were the cause, has been told the wrong thing. The
    command now prints FORK: rows [n, m] all claim predecessor … for each
    split, and says that concurrency explains it. AuditLog.forks() is the
    underlying query.

  • init_schema upgrades 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
    AuditChainForked naming 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.py records the reasoning.

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 03:14
2ef0eec

Added

  • Verification against a registry of historical keys.
    verify_certificate_json(cert, trusted_keys={key_id: public_key}) — and the
    same on verify_certificate. cert v3 made certificates self-describing about
    which key epoch signed them, but both verifiers took a single key, so
    docs/key-rotation.md had 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 from KEY_MISMATCH because the certificate
    is fine and the verifier simply has not been given that key. Certificates
    predating lethe.cert/3 carry no key_id and 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 the openssl command to check
    them) for publishing alongside your public key. docs/anchoring.md says
    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 mcp major bump. The <2 bound is
    deliberate — 2.x renamed FastMCP to MCPServer and lethe/mcp.py is 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.py already 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 --emit file, 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.md now says so.

  • --emit writes 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

Choose a tag to compare

@github-actions github-actions released this 05 Sep 22:03
4e22823

Added

  • Namespace allowlist (#8). lethe_tag accepted 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 and reconcile(tag_untracked=True) able to write entries forget()
    would then honour. reconcile validates every target before scanning, so a
    refusal cannot leave a partial remediation.

    forget() enforces it too, not only tag(). 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 — so all_verified goes 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_status now
    reports namespace_allowlist so an operator can see which mode they are in,
    and MCP returns a NAMESPACE_NOT_ALLOWED envelope rather than an INTERNAL
    traceback.

  • lethe ledger-scope — lists what the ledger holds against the configured
    allowlist, and --purge-disallowed clears 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 into reconcile only.
  • preview() (and so lethe_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 carry allowed.
  • build_context validated 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

Choose a tag to compare

@github-actions github-actions released this 05 Sep 20:54
fdff61e

Fixed

  • The documented verification API was unimportable on a plain install.
    lethe/cert_schema.py imports jsonschema at module level, but the
    dependency was declared only under the dev and mcp extras — so
    pip install lethe-delete followed by
    from lethe.cert_schema import verify_certificate_json raised
    ModuleNotFoundError. That is the structured, machine-readable verification
    path (the one returning KEY_ID_MISMATCH / PAYLOAD_TAMPERED), and
    docs/key-rotation.md tells readers to call it. jsonschema is 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 verify and
    lethe.certificate.verify_certificate worked throughout.

Added

  • A base-install CI 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

Choose a tag to compare

@github-actions github-actions released this 05 Sep 20:31
3bbc5d2

Version 0.2.1 was prepared but never tagged or published; its contents are
included here.

Added

  • Certificate schema lethe.cert/3. lethe.cert/1 and lethe.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 code KEY_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 always null: a slot for external
      corroboration of issued_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-untracked tags findings for deletion. New
    optional connector capability scan(), implemented for pgvector.
  • reverify() / lethe reverify — re-checks absence after valid_until,
    enabled by the new Lethe(retain_verification_ids=True).
  • External anchoring — lethe anchor timestamps 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]'.
    See docs/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 with openssl 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-extended query 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 claim now discloses self-issuance inside the signed
    payload: issued_at is 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

  • mcp 2.x broke the MCP server. The mcp extra declared mcp>=1.9,
    which resolves to 2.x where FastMCP was renamed to MCPServer, so
    lethe.mcp raised ModuleNotFoundError on import. Pinned to mcp>=1.9,<2;
    migrating to the 2.x API is separate work.
  • The package version had drifted from the test suite (test_version still
    asserted 0.1.0 after the 0.2.0 release). pyproject now derives the
    version from lethe/version.py, so the two cannot diverge.
  • Two DB tests raised KeyError instead of skipping when
    LETHE_TEST_DATABASE_URL was unset.
  • Test isolation: the new retention table is now dropped between tests.