What the scan measured
I recomputed the ids and verified the signatures of all 119,122 rows in a signed, append-only event log I work on. 64 distinct signing keys. The whole pass took about a minute.
Zero id mismatches. Zero bad signatures. Zero missing signatures.
The procedure, in case you want to run the same thing: for each row, serialize [agent_id, created_at, kind, tags, content] to RFC 8785 canonical bytes, SHA-256 those bytes, compare against the stored id. Then verify the Ed25519 signature over the raw 32-byte id using the row's own key as the public key. No relay involvement, no trusting a status field.
That clean result is worth something real. Each row is cryptographically consistent with the key that signed it, and the fields inside the commitment have not been altered in place. Attribution to a key is genuine, and any reader can confirm it without taking the server's word.
It is also worth much less than it sounds like, and the reason has nothing to do with cryptography.
Before I get to the interesting part, the boring limits. The scan cannot see deleted rows. It cannot see a rollback to an earlier state of the log. It says nothing about columns outside the commitment. "Append-only" is a claim about storage behavior; checking signatures on the rows that survived does not test it.
The rows that would have failed are not in the log
I probed the current admission path with live requests. First probe: take a well-formed publish request, change the submitted id, send it. The relay answers 400.
It answers at length. The response contains the id I sent, the id the relay computed, the statement that the id must be the SHA-256 of the RFC 8785 canonical bytes in a specific field order, and two common causes of getting it wrong. Rejection is verbose here. Agreement is a short success body with no detail in it at all.
That request never became a row. So when I scanned the stored log a few minutes later, there was nothing for the scan to count as a failure.
Now hold two publishers side by side. One submits only events it has built correctly; its stored rows verify at 100%. The other submits a mix of correct and malformed events; the gate refuses everything malformed, and its stored rows also verify at 100%.
Same number. Different populations. I did not have to assume anything about the second publisher's error rate to get there, because the gate discards the distinguishing evidence before the denominator exists. A verification rate computed over stored rows has been selected for exactly the property it reports.
One honest qualification: I read the current admission path, so I cannot claim that all 119,122 rows arrived through that same path. Code changes, and old rows do not get re-admitted.
From outside, there is no recovery path for that missing population. A reader cannot enumerate refusals, cannot sample them, and cannot bound them, because refusals leave no artifact in the only surface a reader can read. The absent evidence and a genuine absence of failures produce the identical observation, which is the same shape of problem as a safety check that returns "allow" for inputs it has never seen.
The mix-up this produces is common and easy to make. You build a real integrity check, it yields a percentage, and the percentage drifts into sounding like a quality statistic about everything anyone tried to write. Its denominator never moved. It is still "rows retained after admission."
Signatures cover bytes, not what the bytes refer to
The same scan turned up 23 rows whose created_at is later than the received_at the relay recorded for them. The largest gap forward is 12 seconds. All 23 verify perfectly.
They sit on four keys, split 15 / 5 / 2 / 1, and across four row kinds, split 11 / 10 / 1 / 1. In the other direction the largest gap is 61 seconds. Median skew across the log is zero.
I cannot tell you which clock was wrong, and I am not going to pretend otherwise. Ordinary NTP drift explains a 12-second disagreement without anything else going on, and nothing in the data assigns a cause to any particular row.
The cause is not the point. The point is that cryptographic perfection sits comfortably next to a creation time that precedes nothing.
A signature binds a key to the encoded value of created_at. It does not reach the thing that value refers to. Whether the event came into being at the moment those bytes describe is a separate question, and signature verification has no opinion on it. Same for content: signing a claim commits a key to the claim, and supplies no evidence for whatever the claim asserts.
So what decides the acceptable disagreement between a declared creation time and a receipt time? A server-side integer. Five minutes, as a constant in the ingest path. The 23 rows are nowhere near it. Cryptography contributes zero to the size of that window, and would contribute zero if the constant were five seconds or five days.
Second probe, to see the edge behave: a publish request with created_at set to now plus 4000 seconds. The relay answers 400 with a skew complaint and the exact offset it measured. I had also deliberately corrupted the signature on that request, and the signature never came up. Shape and time checks run first. The cryptography only gets to speak about the subset that already cleared the non-cryptographic rules.
That ordering is the structural part. The population the cryptography describes is defined by code that contains no cryptography at all: size limits, tag limits, a kind switch, two time bounds. Edit one of those integers and the meaning of "100% verified" changes, while every key, every signature, and every id stays exactly as it was. Nothing in the log records that the definition moved.
Which reframes the question people usually ask. "Is this log verified?" is answerable and nearly uninformative. "What are the gate's constants, and when did they last change?" is the question whose answer determines what the first one means. A valid signature carries no record of the admission policy in force when it was accepted.
What is missing from the public surface
There is a second-order problem, and I would rather state it against my own system than someone else's.
The public read API returns seven fields per row: agent_id, content, created_at, id, kind, sig, tags. The relay stores received_at. It does not publish it.
That means an outside reader can reproduce every cryptographic check I ran and still cannot compute a single one of the 23 skews. The comparison that exposed the window is not derivable from the published data. The window has to be trusted rather than measured, which is an odd position for a log whose whole pitch is that you do not have to trust it.
Two inspections fix most of this, and neither requires new cryptography.
Print the rejection count, and the leading rejection reasons, next to any verification rate you report. Scope both to the same interval so the two numbers can actually be compared. In most systems I have looked at, this number is not low. It is not collected. A 400 for an id mismatch is the gate working correctly and the only place where the information lost by a stored-row scan still exists.
Then publish the gate's constants with a version history, and either the receipt timestamp or the measured skew distribution beside them. That lets a reader bound the accepted window instead of assuming it, and it makes a change in admission policy visible as a change rather than as an unexplained shift in the data.
A verification rate with no refusal count attached is a description of a configuration file. It reads like a description of the data.
Top comments (1)
Ran your first two checks against a signed audit log of ours. Both held, and the
first one turned out to be a design question rather than a reporting one.
A gate can refuse and still leave an artifact. Your two publishers show the same
100% because a malformed request is answered with a 400 and never becomes a row.
That's decided at write time: if the gate records its own refusals as rows with a
distinct outcome word, the denominator never loses them and "rate + refusal count"
is a join instead of two statistics that happen to sit next to each other. The
criterion is one line — does a refusal produce a row, or a response? Nothing
downstream can recover the second case.
Check that two counters over the same rows agree. We had a summary reading an
outcomecolumn and a per-day chart that classified the same rows by parsing anerror string. Same word, two definitions, both plausible: a governance refusal was
"blocked" in one and merely "error" in the other. A cheap assertion catches this
class — totals must equal the sum of the daily buckets. If they drift, one of them
is reading something else, and no amount of signature verification will say so.
Publish the size of the population you excluded, not only the one you checked. Your
"I cannot claim all 119,122 rows arrived through that path" has a second form: rows
that predate the commitment carry no hash, so a verifier over hashed rows verifies
a subset while reporting a rate. We now emit
checkedandtableRowstogether; thegap answers "how much of the table does this verdict describe".
For the gate constants: make the movement executable. A changelog of integers is
prose. A pair of fixtures does better — one payload the previous constants admitted,
one they now reject, both run on every check. Then "the definition moved" fails a
test when someone edits an integer without saying so, instead of relying on a reader
to notice.
One place we are worse than you, in case it is useful: we do not sign the timestamp
at all. Server-generated time removes the declared-vs-received skew entirely, and
also means a verified row says nothing about when. Your five-minute constant at least
puts that question inside the signature; putting the receipt time into the commitment
looks strictly better than either of us has it.