Skip to content

verify-audit does not detect tail truncation — the stated contract is accurate, but absence-of-rows is not tamper-evident #12

Description

@iceteaSA

verify-audit works. I ran it against a 502-entry live chain, then built the mutation matrix to establish what "intact" actually covers — because "intact" over an unstated population is the shape of error I published in #9.

The tool's stated contract is accurate and this is not a claim that it lies. The help text says it "fails if any entry was edited, reordered, or inserted" — deletion is not in that list, and that turns out to be exact.

Mutation matrix — DB write access, no master key

Run against a copy of a real store, master key supplied via --key-path:

untampered copy                     -> intact (exit 0)     control: the verifier and key are right
edit one interior field (actor)     -> BROKEN at seq 1      exit 1
delete one interior row (seq 201)   -> BROKEN at seq 202    exit 1
delete the last row                 -> intact (exit 0)      <-
delete the last 50 rows             -> intact (exit 0)      <-
truncate, leaving a sqlite_sequence gap (tip 493, sequence 496) -> intact (exit 0)   <-

Interior mutations are caught exactly as documented. Tail truncation is not, at any depth, and neither is the sqlite_sequence gap it leaves behind.

This is inherent to a backward-linked chain rather than an implementation slip: every entry binds its predecessor, so a truncated chain is a shorter valid chain. There is no in-band anchor for expected length. An append-only forgery is still impossible without the audit key — truncation is the unique undetectable mutation.

Why I'm filing rather than just noting it

Because it invalidates something I published, and the same reasoning is available to anyone else reading the chain.

I have been treating "zero chain rows were written" as an authoritative discriminator — specifically, for separating the vault adjudicated a credential dead from the vault was unreachable. I called it unfakeable and used it as the tiebreaker over two consumer-side signals, on the grounds that a downed daemon writes nothing by construction.

That holds non-adversarially and fails under an adversary, because absence of rows is exactly what truncation manufactures. Dropping the last two rows of a real death episode makes it read as a vault outage. The property I relied on was "the vault records its decisions", and truncation attacks the record without touching a decision.

Suggestions, cheapest first

  1. Say it in the help text. verify-audit's summary lists the three mutations it catches; a reader completes that set as "everything". One clause — "cannot detect truncation of the newest entries; see high-water mark below" — closes the gap between the stated contract and what a reader assumes. This is the whole fix as far as honesty goes.

  2. A persisted high-water mark. A chain_hwm row (or a sidecar file) holding the last-known MAX(seq) plus its entry_mac, updated on write, makes truncation below it detectable in-band. Truncating the mark and the tail leaves an inconsistency with sqlite_sequence unless that is also rewritten, so it raises the bar without pretending to be a proof.

  3. An external witness, which is what actually saved me here. My monitoring recorders happen to log audit_seq every 30 s to a file outside the DB. That is an unplanned high-water witness, and it holds:

2478 sample lines, audit_seq range 458 -> 502, decreases = 0
live MAX(seq) = 502, witness max = 502   (agree)
planted-decrease control -> detected      (the checker can see one)

Worth stating for anyone building the same check: my first pass reported 3 decreases, all false. The regex matched audit_seq= inside the recorder's own arming banners, which echo a historical baseline (v36 audit_seq=458) rather than a current reading. A monotonicity check over a log has to filter to sample lines first, or it compares a historical statement against a live series and reports truncation that never happened.

The generalisable half

verify-audit answers "has the recorded history been altered". It does not answer "is this all of the history", and the second question is the one a reader silently assumes is included. Same shape as the bc counter in #10: the check was honest about what it measured, and its output invited a stronger reading than it supported.

Environment: 560c1b5 binaries, store copied from a rollback backup so the live chain was never mutated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions