Skip to content

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 03:29
· 66 commits to main since this release
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.