verify-trail.mjs recomputes the hashes in an export of a Confluence document
control audit trail and tells you whether they still agree with the rows they
were computed over.
It exists because you should not have to take the vendor's word for it. The app that keeps the record is written by the same people who publish it, so a button inside the app saying "verified" would be that vendor vouching for itself. This runs on your machine, against a file you hold, from source you can read before you run it.
Node 18 or later, and nothing else. There is nothing to install.
node verify-trail.mjs my-export.json
Keep every export you take. Once you hold more than one, pass them all, oldest first, and each earlier file is also checked against the later ones:
node verify-trail.mjs 2026-06-01.json 2026-09-01.json 2026-12-01.json
It exits 0 when every chain it read recomputes, 1 when something is broken,
and 2 when it could not tell.
Every release names the SHA-256 of the verify-trail.mjs it ships. The digest
is computed by the release job from the bytes it attaches, so it is not a value
anybody typed. Compare it against your copy:
shasum -a 256 verify-trail.mjs
On Windows:
certutil -hashfile verify-trail.mjs SHA256
Take the digest from the release page for the version you downloaded rather than from a link that follows the newest release, so that the file and the digest you compare it against are frozen together.
fixtures/ holds exports whose contents are known, one per export format the
verifier reads. Every release attaches it as fixtures.zip, so download that
beside verify-trail.mjs and extract it there, which gives you the same
fixtures/ directory this repository holds at that tag:
node verify-trail.mjs fixtures/dc-audit-export-v3.json
Run it against each of them first, one file at a time, and each
should report every chain verifying and exit 0. Then alter one character
inside any row in a copy of one, run it again, and it should report that
document broken at the sequence number you touched. A checker that cannot be
made to fail is not telling you anything.
Pass the fixtures one at a time rather than all together. Several files given at once are read as successive takes of the same trail, oldest first, and these are not that.
The hashes cover the rows under each document's rows, field by field, as
construction.fields lists them. Nothing else in an export is covered by any
hash, and a change to it is not detected by this check. That includes:
- the title each document is named by, under
titles - the moment the export says it was taken,
exportedAt - the standing of each approver key, under
attribution, including whether an identity was erased - the readable copy of each row, under
rendered, which this check never prints
The verifier prints the first three because an export is unreadable without
them, and it prints them below a line that says no hash covers them. Every
verdict above that line, verified, BROKEN or CANNOT TELL, names a document
by the page id its own hashed rows carry, and by nothing else. A document whose
label in the file names a different page id from its rows is reported broken.
Why titles are not brought under a hash. Anybody who can edit a page can rename it, so a title is prose rather than a fact about the approval, and putting it into a chained row would make an ordinary rename look like a tampered record. A digest over the titles written into the same file would not help either: whoever changed a name could compute the digest again. What makes a row's hash worth checking is an export you already hold that carries the same row, and no earlier export can vouch for a title that is allowed to change between the two.
So a title in an export is a stamped observation, not a lookup, and not a verified fact. It records what the app saw the page called, and when. Two exports taken a year apart may name the same page differently, and each is true of its own stamp. The page id is the thing to match a document by. A document released from control, and a page this app has never managed to read, are named by id alone and counted as ones the file cannot tell you about. That is not a report that they have no titles.
Version 1.1.0 printed a document's title inside its verdict line, as
verified 1000202 "Quality manual" as at 2026-08-04, where a name altered in the
file read as part of what was verified. Its verdicts about the chains are
correct. The name beside them was never checked, and from the release after it
it is not printed there.
- verified, with a row count and a head hash per document. Every row hashes to the value stored with it, every row follows the one before it, and the sequence numbers run without a gap.
- BROKEN, naming the document and the sequence number. A row was altered, or a row was removed from the middle of a chain, or a row does not follow the one before it. The sequence number is where the chain stops agreeing with itself, which is at or before the change.
- Cannot tell. The file could not be read, or it is in a format or a hash construction this verifier does not implement. This is not a report that the record is intact, and it is not a report that it is broken.
- identity erased. Somebody exercised their right to have their personal data erased, and the app erased the account behind that approver key. The approval still stands, the row is untouched, and the chain still verifies. This is a lawful erasure and not a defect in the record.
An export never contains an Atlassian account identifier. Rows carry a pseudonymous approver key, and the mapping from that key to an account stays inside the app so that an erasure request can be honoured without rewriting a single hashed row. What an export carries is the standing of each key: whether the app still holds an account for it, whether it was erased, or whether it could not be looked up.
A hash chain detects any altered row and any row removed from the middle. It cannot, on its own, detect rows removed from the end, because nothing inside a chain records how long the chain should be. A trail whose last three rows were deleted verifies perfectly as a shorter trail.
Detecting that needs an anchor the vendor cannot reach, which is an export you already hold. That is why this command takes several files, and why keeping every export is worth the trouble: comparing an earlier one against a later one is what turns "the rows present agree with each other" into "nothing that was here before has gone".
Every export describes its own hash construction under construction, which is
enough to check the record without this program at all:
- the digest is
sha256, rendered as lower case hex - ten parts are hashed in the order
construction.fieldslists: the literal algorithm identifier, then nine fields from the row - each part is prefixed with its length in UTF-8 bytes and a colon
- the parts are joined with
| - the first row of every document chains from
construction.genesisHash, which is sixty four zeros
verify-trail.mjs refuses to answer if an export describes a construction other
than the one it implements, rather than checking it against the wrong rule.
Each release is a version of the verifier, and a released version never stops reading an export format the app has already shipped. Releases are numbered automatically, one patch after the latest, whenever the verifier or its fixtures change.
The first line it prints on every run is the SHA-256 of the file itself, the
same value the release page carries, followed by the export formats it reads. An
output you keep alongside an export therefore records exactly which file produced
it, and comparing that line with a release page tells you which release it was.
Releases up to v1.1.0 print a version number there instead.
Apache License 2.0. See LICENSE and NOTICE.
Open an issue on this repository. If you believe the verifier reports a record as intact when it is not, say so in the issue title, and include the export and the output rather than a description of them.