DEV Community

Rain S
Rain S

Posted on

Silence Reads as Completeness: An AI Audit Package Must Carry a Coverage Statement

An auditor receives an evidence package. The hash chain verifies.

What will they read that as?

Most people read it as two things: the rows were not altered, and they are all here.

The chain proves only the first. Delete the last few rows and the remaining chain is still internally consistent.

This piece proposes one thing: every evidence package should carry a coverage statement, in data, saying what it read and what it cannot show, signed together with the package.

  1. What the chain proves, and what it does not A hash chain means each row carries the digest of the one before it. Change a row and the rest stop matching. Remove a row in the middle and the chain breaks. Reorder them and it breaks.

It does not stop deletion from the tail. Remove the last few rows and what remains is a shorter chain that is entirely self-consistent.

So "the chain verifies" answers internal consistency, not completeness. Completeness needs something outside the chain, such as periodically writing the tail digest somewhere else.

This is not an implementation detail. It is the inference a reader will make. A record that does not state its boundary gets read as complete.

  1. The scene: a gap we deliberately left out We maintain a registry of gaps. Each entry says one thing this kind of package cannot show.

As of this writing there are eleven entries.

One candidate is deliberately not among them: the claim that a side-effect row cannot show who did it. We did not add it, because whether it still holds has not been settled. Our side-effect entity carries an owner. That reading may only mean the entity has the field while the endpoint does not expose it.

An unverified gap is worse than no gap. It attaches a false caveat to every package, and the reader has no way to tell which caveat is real.

So one of our tests is dedicated to the absence. It asserts that the registry contains no origin for that claim.

This may be the least intuitive line in the design: this test does not check that something is there. It checks that something is not.

It also says the coverage statement needs a coverage boundary of its own. We can only declare the gaps we have verified.

  1. Gaps are data, not prose If every report carried a hand-written paragraph saying "this report may not include...", that paragraph would drift away from the system. Someone changes collection and forgets the caveat, or changes the caveat and forgets the system.

So the gaps live in one place. Each entry has:

a stable id that is never reused
a severity
whether it limits this package or the whole deployment
a predicate over a closed set of facts, all of which must hold for the gap to apply
one English statement, the field that carries the caveat on the wire
an internal origin that does not
The closed set is deliberate. A predicate that can read anything is a predicate nobody can evaluate, and an entry whose condition cannot be evaluated can only ever apply. That is the silence-is-completeness failure again.

So each package declares the gaps that actually apply to it, not a copied list of caveats that drifts.

Adding a gap changes the registry only, as long as it reads the facts we already collect. The statement gains an entry and the service code does not change. This is structural: change the registry and the registry revision in the statement moves with it. A search for the eleven gap ids in the service code returns nothing.

  1. What a coverage statement contains First make the statement an object rather than a paragraph. In our package it currently has these fields:

registryRevision: which version of the gap registry the statement was computed against
scope: whether it covers one action, a period or an entity, with its start, end and subject
sources: the sources actually read, each with a state and a row count
verdict: complete, partial or unknown
gaps: the gaps that apply, each with an id, severity, scope and one English statement
That is the minimum machine-readable shape. It answers three questions: which rules were used, what was read, and how the verdict reads.

If you want human review in the statement, one sentence is not enough. You need three fields: review status, who confirmed it, and when; plus an exception reason, meaning why something is not covered and who accepted that exception. Those fields are not in our statement today. They live on the authorization and confirmation rows. Putting them in means deciding on a cross-table read and making sure they are signed too. Otherwise you have added another paragraph that drifts.

One more thing about the signature: the statement is not signed on its own. It is signed as part of the package's canonical string. A separate hash for the statement would be a different design.

  1. The verdict is computed The registry also has to say how a verdict reads. Our rule has three lines:

no applicable gap means complete
any gap that says "we cannot even tell what is missing" means unknown
everything else means partial
The important boundary is that unknown is not a stronger partial. partial means I know what is missing. unknown means I cannot even tell.

Reporting unknown as partial would claim we know what we are missing, which is exactly what that gap says we do not.

The verdict is not written by hand. A hand-written verdict is just another value that drifts.

  1. The statement is signed with the package If the coverage statement were a note beside the package, it could be rewritten after the fact into a nicer version and the signature would still verify. The section would be decoration.

So in the new evidence-package format the coverage statement is a required section, and it is part of the canonical string that gets signed. The offline verifier checks for it first; a package of this version without it fails.

The same statement has to say what was consulted. It lists only the sources this package actually read, not the sources the system happens to have. Listing a source nobody queried is the same silence this whole thing exists to break.

The signature covers the statement's content, and two values in that content are not computed. They are pinned, and neither is optimistic.

The first is whether one cache serves more than one deployment. The code cannot see a second deployment. false is the correct answer for the deployment shape this repository supports, one deployment with one set of databases. The gap stays in the registry for shapes it does not support. There is no input for that today, so we record it rather than paper over it.

The second is whether the package has an external anchor. The anchor has no endpoint, which we measured. So a package cannot carry one, and the statement says unknown instead of pretending the chain is enough.

Both are worth stating precisely, because they show what the statement is for. It does not promise completeness. It points at where completeness is missing.

  1. Three common "only" cases
    Only a trace, no statement: the reader takes a missing trace as a missing action. Absence of a trace and absence of the action are two different facts.
    Only an approval, no approved scope: approving one call is not approving every argument and destination of that run. If the scope is not written, the next person has to guess.
    Only a log, no integrity check: a log can be edited. A chain proves internal consistency, but the tail can still be removed. Without an external anchor, completeness is still an inference.
    All three point at one thing: the presence of a record is not the same as coverage.

  2. A boundary this design does not cross
    A coverage statement makes incompleteness readable. It does not make an audit complete.

It can only cover facts we can compute. What we cannot compute goes to unknown, or, if it is a guess we have not verified, it stays out of the registry entirely.

The fact set is closed, and that is both its shape and its ceiling. A gap that needs a new fact does not appear by adding a row to the registry.

So it is not a compliance certificate. It is a usage note: this is what the package can answer, and this is what it cannot.

  1. Four checks you can run Does your evidence package say what it did not read? If all it says is "verified", the reader will supply "probably complete". Is that sentence hand-written, or computed from a registry? The hand-written one drifts. Is the verdict written or computed? In particular, can unknown be downgraded to partial? Is the statement inside the signature? If not, it can be rewritten after the fact without breaking the seal. If you run high-risk agent sessions, the cheapest implementation is not a new report page. Make package generation an automatic step at the end of the session, and make the coverage statement a required section of the package. The system computes it; the person writing the report does not fill it in. That is the difference between a coverage statement and a disclaimer.

Closing
Back to the opening: silence gets read as completeness.

What we do: the gaps live in one place, eleven entries with an origin for each; predicates read a closed set of facts; the verdict is computed and unknown is not downgraded; the coverage statement is signed with the package; and only the sources actually read are listed.

The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system, re-checks identity, authorization, scope, risk and confirmation state before a tool runs, and writes every decision into a record that can be verified offline. The coverage statement sits at the seam between confirmation and audit in that governance loop: confirmation answers who approved this action, and the statement answers how far the record can prove.

Repository (a Gitee mirror is the faster route from inside China):

GitHub: https://github.com/rain6fish/KeelBase
Gitee: https://gitee.com/rain6fish/KeelBase
Two things stated plainly. The external anchor has no endpoint today, so any conclusion about tail completeness is unknown. That is not caution, it is the fact. And the deliberate absence: we would rather declare one gap fewer than attach an unverified caveat to every package.

An honest audit package does not say it has no gaps. It says which ones it has.

Drafted with an AI assistant. The system, the positions and the mistakes are mine.

Top comments (2)

Collapse
 
anp2network profile image
ANP2 Network •

sources[].rowCount quietly merges two different coverage claims: rows that were read, and rows that could be interpreted. Authenticity doesn't separate them, and that's the part I'd push on.

In an append-only signed event log we run, one record type holds 1,759 task deliveries. 1,748 carry a JSON object payload. The other 11 carry a bare string, one of which is "please". All 11 come from a single signing key, and we checked them instead of trusting a non-empty signature field: canonicalising the preimage and hashing it reproduced the row id 11/11, and Ed25519 verified 11/11. They are authentic rows. No row anywhere in the log's history names them, and not one has a verification record.

The positive control is what makes that readable rather than just a broken pipeline. Verification records name 1,739 of the 1,748 well-formed deliveries, and their pointers resolve 3,531 of 3,531. That same key's 36 well-formed deliveries are verified 36 of 36, and the newest of the 11 predates the log's newest verification by roughly 110 days, so a pending queue doesn't explain it either.

Causation stays out of reach, since the log never records whether an evaluator read a row. What's left is narrower: within that one key, on the same tasks, the only variable moving with the presence of a verification record is the shape of the payload. The signature authenticates the record type and the payload bytes while saying nothing about whether the payload is valid. The JSON parse is the only check that can tell the two classes apart, and its result is written down nowhere. A row count reads 1,759 either way.

Which is why I think your partial/unknown line is the right distinction and rowCount can't reach it: one number can't say which rows it knows are missing. A count per stage plus the id set of the drop can, and that touches your schema without touching your thesis. Would you run the report's parser alongside an existence-only reader and treat the gap as a set difference over row ids rather than a difference of counts, given that dedup or a window edge produces a count gap too? The companion decision is a denominator policy fixed in advance: whether stored rows with no verification record count against the failure rate.

Collapse
 
rain6fish profile image
Rain S •

You are right about what the number hides. In our statement sources[].rows is an existence count: it comes from a count over the scope. There is no second count for rows that parsed, so a row that is authentic and unreadable lands in the same number as a row that is authentic and readable. The positive control in your log is what makes that a finding rather than a broken pipeline, and it is the part I would want to reproduce before changing anything.

On the set difference: yes, ids are the right unit, and your dedup and window-edge point is the reason. In our package the statement sits inside the signed canonical, so shipping a full id set would grow the signature input and force a deterministic order. The shape I would try first is a count per stage plus a digest of the drop ids, with the reader recomputing the digest from the source rows. That keeps the statement small and still makes the gap checkable.

The denominator is the part I would not decide from here. Our chain is verified as a whole, not per row, so there is no per-row verification record to use as your denominator. The closest question is which rows the scope claims should have been interpretable, and that is a policy, not a count. I do not have it written down yet.

So I would take the set difference before the denominator, and leave the second question open rather than invent an answer.