Section 7 · Section 8
Audit anchoring
A per-token hash chain protects one package's sequence. It does nothing about an event quietly deleted from the middle of the whole log. Batch anchoring is what closes that, and it is not a blockchain.
What anchoring is
Every hour of active operations — or every ten thousand events, whichever comes first — the log closes a batch, builds a Merkle tree over the event hashes, signs the root together with the batch metadata, and writes that signed anchor to two independently controlled durable stores.
An auditor can then be handed one event and a short proof path, and check for themselves that it was in the batch, without being shown any of the other events in it.
What anchoring is not
It is not a public ledger. No raw token, no postal reference, no voter-linked metadata and no event detail is published anywhere. Publishing even a bare root can only be considered after legal and privacy review, and never in a form that lets anyone enumerate voter status.
The specification is explicit: a public blockchain must not be the lifecycle record.
Run it
Build a batch, take a proof, break it
This builds a real tree over real event hashes in your browser, using the domain-separated construction below.
The construction
MBLTS-MERKLE-V1
leaf = SHA-256( 0x00 || event_hash )
internal = SHA-256( 0x01 || left || right )
odd count at any level: the final node is duplicated
an empty batch has no root and MUST NOT be anchored
The 0x00 and 0x01 prefixes are domain separation, and they are not decoration.
Without them a leaf and an internal node are the same shape, and an attacker can present an internal node
as though it were a leaf — claiming inclusion for something that was never an event. The prefixes make the
two structurally impossible to confuse.
The duplication rule has a known ambiguity, and this build does not paper over it.
Duplicating the final node means a batch of [A,B,C] and a batch of [A,B,C,C]
compute the same root. So a root, on its own, does not pin how many events were in the batch.
A verifier that checks only the root is accepting a weaker claim than it thinks it is.
The button above demonstrates the collision on real hashes. It is a genuine property of the rule the specification names, not a flaw introduced here.
What closes it: sign the metadata, not the root alone. The batch record already carries
event_count and the first/last global sequence numbers. This engine puts all of it — root
included — through canonical serialization and signs that. Two batches with different counts
therefore produce different signature preimages even when their roots collide, and
verifyBatch() reports COUNT_MISMATCH outright.
Bind the count, or version the padding rule to something unambiguous. Do not ship the root by itself and call it settled.
The verifier, in full
export async function verifyInclusion(eventHash, proof, expectedRoot) {
let node = await merkleLeaf(eventHash); // SHA-256(0x00 || h)
for (const step of proof) {
const sib = unhex(step.hash);
node = step.position === 'LEFT'
? await merkleNode(sib, node) // SHA-256(0x01 || sib || node)
: await merkleNode(node, sib);
}
return timingSafeEqualHex(hex(node), expectedRoot);
}
That is the shipped function, copied out of /assets/mblts.mjs — the same bytes
your browser just executed. On the verification page you can run it against
deliberately corrupted input and watch it refuse.
Where the anchors live
One copy in a write-once, retention-locked repository the election authority controls. One copy in a repository it does not control — an auditor, an oversight body, or an escrow arrangement. A tamper-evidence scheme where the same party holds every copy of the evidence is a filing system with extra steps.