Shipmind Labs

A hash-chained audit log should verify without its own library

· 11 min read

A compliance record usually gets read by someone who has the file and not the code. An auditor, a lawyer, a counterparty's engineer, or one of us two years later gets an export and no running system, and has to decide whether the thing in front of them is what it claims to be. If answering that means installing the library that wrote the file, the record proves nothing except that the library still agrees with itself.

So the constraint we design to is a strange one for a logging component: the format has to argue its own case to a reader with standard tools. Not a tidy export, but a file where a single line can be checked with a JSON parser and a SHA-256, and where the thing being checked is exactly the bytes that were hashed.

We ran into this in disclosurekit (https://github.com/shipmindlabs/disclosurekit), a package that works out which transparency duties of Article 50 of the EU AI Act apply to a system, says what a notice must contain and where it has to appear, and keeps the record of the decision. Those rules have applied since 2 August 2026. The interesting part here is not the legal table, it is EvidenceLog in src/log.ts, which has to still be readable when the deployment that produced it is gone.

Every entry names its own terms#

A record that leans on out-of-band knowledge is a record you cannot hand to a stranger. Each entry therefore carries, inside the entry itself, what it is, how it was hashed, when it was made, and which version of the rules produced it.

typescript
append(kind: EventKind, detail: Record<string, unknown> = {}): Entry {
  const recorded = plainJSON(detail, "", new Set<object>()) as Record<string, unknown>;
  const previous = this.#entries.at(-1);
  const withoutHash = {
    format: EVIDENCE_FORMAT,
    alg: HASH_ALGORITHM,
    seq: this.#entries.length,
    at: this.#now().toISOString(),
    kind,
    rules: this.#rules,
    detail: recorded,
    previousHash: previous?.hash ?? GENESIS,
  };
  const entry: Entry = { ...withoutHash, hash: digest(withoutHash) };
  this.#entries.push(entry);
  return entry;
}

Four of those fields exist purely for the reader. format and alg say what dialect the line is in and which hash to run against it. at comes from an injected clock, so tests are not at the mercy of one. rules is the version of the rule set that produced the decision, and you can inject it on the constructor: a decision taken under an older reading of the article gets recorded as what it was, with new EvidenceLog({ rules: "eu-ai-act-article-50@0" }), rather than being silently restated the way we read the article now. previousHash is the previous entry's hash, or the GENESIS anchor for the first entry, and that is what makes the file a chain instead of a pile of independently hashed lines.

The log gets written by the parts of the package that decide things. recordDecision() appends an "assessment" entry carrying the inputs it was given and the applies, excluded and needsReview lists; withDisclosure() appends a "disclosure-shown" entry per disclosed response. The kinds stay small and literal ("assessment", "exemption-claimed", "disclosure-shown", "content-unmarked") because a reader has to be able to tell what happened without a mapping table they do not have.

One canonical form, used twice#

Hashing JSON means picking one spelling of it. Two callers that build the same detail with their keys in a different order have to produce the same hash, or the chain reports vandalism every time somebody reorders a call site. That is a test in the repo and not an aspiration: key order does not change an entry's hash.

typescript
/** Stable stringify, so an unchanged entry always hashes the same. */
function canonical(value: unknown): string {
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
  if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
  const entries = Object.entries(value as Record<string, unknown>)
    .filter(([, v]) => v !== undefined)
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
  return "{" + entries.map(([k, v]) => JSON.stringify(k) + ":" + canonical(v)).join(",") + "}";
}

function digest(parts: Omit<Entry, "hash">): string {
  return createHash(HASH_ALGORITHM).update(canonical(parts)).digest("hex");
}

Sorted keys, dropped undefined, no whitespace. The export then uses the same function:

typescript
toJSONL(): string {
  return this.#entries.map((entry) => canonical(entry) + "\n").join("");
}

That is the whole argument of the design. toJSONL() does not serialise the entry a second way for human convenience, it writes canonical(entry), which is the hashed bytes plus the hash member. Because the keys are sorted, removing that one member from a line leaves exactly the string that was fed to the digest. You can check a line where it lies.

It is worth saying what goes wrong when the two forms drift, because we had it happen. A Date in detail used to be hashed as {} and written to the file as a string, so the record was never the bytes a reader would check, and the entry verified against itself in memory and against nothing on disk. The fix was a normalisation step in front of the hash, plainJSON(), so that what gets hashed is what JSON will write. It also refuses what it cannot record faithfully: a class instance in detail raises UnrecordableDetail with the path, at append() time, instead of quietly becoming {} in a file somebody will later rely on.

One record per line has a second benefit that has nothing to do with hashing: the file appends, greps and splits with tools that have never heard of this package.

Checking a line with a JSON parser and a SHA-256#

Here is the reader's side, written without the library. No dependencies, and the canonicalisation is short enough that you can re-derive it from a one-paragraph description of the format.

javascript
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

const FORMAT = "...";   // the dialect this reader knows how to read
const ALG = "sha256";   // the algorithm it knows how to run
const ANCHOR = "...";   // the genesis anchor the first entry points at

const canonical = (value) => {
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
  if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
  return "{" + Object.entries(value)
    .filter(([, v]) => v !== undefined)
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([k, v]) => JSON.stringify(k) + ":" + canonical(v))
    .join(",") + "}";
};

const lines = readFileSync(process.argv[2], "utf8").split("\n").filter((line) => line !== "");

let previousHash = ANCHOR;
for (const [index, line] of lines.entries()) {
  const { hash, ...rest } = JSON.parse(line);
  const problem =
    rest.seq !== index ? `expected seq ${index}, found ${rest.seq}`
    : rest.previousHash !== previousHash ? "does not follow the previous entry"
    : rest.format !== FORMAT ? `record format ${rest.format} is not one this reader can verify`
    : rest.alg !== ALG ? `hash algorithm ${rest.alg} is not one this reader can verify`
    : createHash(ALG).update(canonical(rest)).digest("hex") !== hash
      ? "contents do not match the recorded hash"
      : null;
  if (problem) throw new Error(`line ${index}: ${problem}`);
  previousHash = hash;
}
console.log(`${lines.length} entries verified`);

We are not asking you to ship this script. What matters is that the format makes it writable by someone who does not trust us, in whatever language they already have, and that their answer and ours come out the same. A verifier that only exists inside the package is a verifier with a conflict of interest.

The first failure, named#

verify() returns the first entry that does not hold, with its index and a reason, rather than a boolean.

typescript
if (entry.seq !== index) {
  return { ok: false, brokenAt: index, reason: `expected seq ${index}, found ${entry.seq}` };
}
if (entry.previousHash !== previousHash) {
  return { ok: false, brokenAt: index, reason: "does not follow the previous entry" };
}
// A record this version cannot read is not one it can vouch for either.
if (entry.format !== EVIDENCE_FORMAT) {
  return {
    ok: false,
    brokenAt: index,
    reason: `record format ${entry.format} is not one this version can verify`,
  };
}

Two kinds of damage show up differently, and both reports are useful. An edited entry fails on its own digest: the repo's test rewrites detail on entry 1 and gets brokenAt: 1 with contents do not match the recorded hash. A deleted entry fails on linkage instead, because dropping entry 1 leaves the following record pointing at a hash that is no longer in front of it, and the report names index 1, the position where the chain parts.

What none of this does is prevent the edit. Nothing stops anyone with write access from changing a line, and no hash chain inside a file the editor controls could. If deterrence is the requirement, the answer lives elsewhere: write-once storage, an external timestamp, a copy held by a party the editor does not own. The chain's job is narrower, and it does that job completely, since it makes an edit show and it names the entry. For most questions asked of a compliance record, that is the actual question: whether the file was altered, and where, rather than whether it could have been.

An unknown algorithm is a failure, not a skip#

The format and algorithm checks run before the digest gets recomputed, and they are refusals. If format is not the format this version writes, or alg is not the algorithm it hashes with, verification stops and the reason names the value it found.

The tempting alternative is to treat an unreadable line as something to pass over: skip it, verify the rest, report ok. That hands you a green result for a file you did not read, which is worse than no result at all, because somebody will quote it. A record this version cannot read is not one it can vouch for. It is the same discipline the rest of the package applies to its inputs, where a question nobody answered goes to needsReview instead of being treated as a no.

What it costs to run#

  • verify() recomputes the digest of every entry, so it is linear in the length of the log. Treat it as a batch check and keep it off a request path.
  • detail must stay plain JSON. Anything carrying its own prototype has to be converted at the call site, and the complaint arrives at append() with a path rather than at read time with a shrug.
  • The chain is per log. Splitting a file for size means the reader needs both the anchor and the sequence to span the parts, which makes rotation a decision you take before the first entry rather than after a year of them.
  • The rule version has to be set deliberately when you reconstruct or backdate a decision. Defaulting to the current RULES_VERSION is right for live use and wrong for a replay.
  • Verification only covers what is in the file. A log that was never written says nothing, which is why the gaps get recorded too: unmarked content gets a "content-unmarked" entry with the method and the reason, so a reader with the file and nothing else can see that nothing was ever marked.

Close#

We build this shape often: e-signature workflows to national digital-signature standards, KYC and AML moderation tooling for compliance teams, deal records where a token accumulates signed documents and state from listing through closing. The pattern repeats, since the artefact outlives the service that produced it and is eventually read by someone with no access to either. A log whose verification depends on its own library is a record with a dependency on us, and that is probably the one dependency a record of this kind should not have.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com