Skip to content
Documentation menu

Documentation

The audit trail

Every check, run, and approval is appended to a single hash-chained log. The chain is computed over canonical JSON, exports are Ed25519-signed, and the format is specified. An inspector can verify the whole thing offline without trusting your operators or us. This page covers what is in the chain, how a record is hashed, and how to verify it both online and air-gapped.

The guarantee

If a row was altered, inserted, or deleted from the middle of the chain after the fact, verification fails and names the exact sequence number where it broke. That holds whether the change came through the application, a stray UPDATE, or a database administrator.

What is in the chain

There is one table, audit_events, and it is append-only. A single function, recordEvent(), is the only path that inserts into it. Nothing else writes to the log, in the application or out of it.

That function runs inside the same database transaction as the state change it describes. The new agent, the granted skill, the approval, the denied call: each commits atomically with its own audit event, or neither commits. The log is the write path, not a side effect bolted onto it.

Each event carries a fixed set of fields:

FieldWhat it holds
idA UUID for the event.
occurredAtISO 8601 timestamp set when the event is written.
actorWho acted: a user, an agent, or the system, with id and name.
eventTypeWhat happened, for example a skill check, a run step, an approval.
entityType, entityIdThe object the event is about, when there is one.
runIdThe run the event belongs to, when there is one.
payloadThe event-specific body.
prevHashThe hash of the event before this one. This is the chain.

Why one writer

A single insert path with a transaction-scoped advisory lock means chain appends serialize. The read-head, compute-hash, insert sequence is race-free across processes, so two workers cannot both append onto the same predecessor and fork the chain.

How a record is hashed and linked

An event's hash is the SHA-256 of the RFC 8785 canonical JSON of its fields:

hash input

hash = SHA-256(canonicalJson({
id, occurredAt, actor, eventType,
entityType, entityId, runId, payload, prevHash
}))

Canonical JSON (RFC 8785, the JSON Canonicalization Scheme) fixes the serialization byte for byte: object keys sorted by UTF-16 code unit, no insignificant whitespace, numbers per ECMAScript. Two implementations that follow the spec produce the same bytes, so any external verifier reproduces the same hash. The scheme is vendored into MakerChecker rather than pulled from a dependency, so it cannot drift.

One field is deliberately excluded from the hash: seq. That column is the database's storage order. It can have gaps, because an aborted transaction still consumes an identity value, so it is not a reliable index of chain position. Chain order is defined only by linkage: prev_hash → hash → next.prev_hash.

The chain starts at a genesis event. Its eventType is audit.genesis and its prevHash is the SHA-256 of "makerchecker-genesis:" concatenated with the instance id. Rooting genesis in the instance id ties a chain to one deployment, so a chain from a different instance cannot be spliced in to stand in for yours.

Verify online

The live check walks the whole chain in seq order, recomputes every hash, and checks that each prev_hashlinks to the previous event back to genesis. It batches a thousand rows at a time, so a chain of millions of events verifies in constant memory.

Call it from the SDK or hit the endpoint directly with a Bearer token:

import { createClient } from "@makerchecker/sdk";
const mc = createClient({ baseUrl: process.env.MAKERCHECKER_URL });
// Recompute every hash and check linkage from genesis to head.
const result = await mc.audit.verify();
// { ok: true, count: 1422, headHash: "9f3c..." }
if (!result.ok) throw new Error("audit chain failed verification");

A pass returns the count and the current head hash:

result

{ "ok": true, "count": 1422, "headHash": "9f3c..." }

A failure returns the exact sequence number where the chain broke and why. The reason distinguishes the two failure modes:

result

// A row was edited out of band: stored hash no longer matches its content.
{ "ok": false, "count": 880, "failedSeq": "881",
"reason": "hash mismatch: stored 4ab1... recomputed 7c92... (row tampered)" }
// A row was removed or reordered: the link to its predecessor is broken.
{ "ok": false, "count": 880, "failedSeq": "881",
"reason": "broken linkage: prev_hash 4ab1... != expected 9d70..." }

Export and verify offline

Online verification still runs inside the perimeter you control. To give an auditor proof they can check on their own machine, with no access to your database and no need to trust your server, export a signed bundle.

On first boot the instance generates an Ed25519 keypair. The private key is written to disk inside the deployment, mode 0o600, and never leaves. The public key is stored in the instance table and shipped inside every bundle. The bundle's manifest is signed with the private key.

export and verify a full bundle

shell

# Export the whole chain as a signed JSON bundle.
node dist/cli.js audit export --out bundle.json
# Verify it later on any machine, with no database and no server.
# --key pins the instance public key you obtained out of band, so a
# bundle re-signed under an attacker's own key is rejected.
node dist/cli.js audit verify-bundle --in bundle.json --key instance_key.pub
# {
# "ok": true,
# "count": 1422,
# "signingKeyFingerprint": "a1b2c3d4e5f60718"
# }

verify-bundle needs no database connection. It checks the manifest signature, recomputes every event hash, checks the signed digest of the event-hash set, and, for a full bundle, walks the entire genesis-rooted linkage. Pass --keyto pin the public key you obtained through a trusted channel, so a bundle re-signed under an attacker's own key is rejected even though it is internally consistent.

There are two kinds of bundle:

  • Full bundle. The whole chain. Linkage is verifiable end to end from genesis to head, which is what proves completeness.
  • Run bundle.One run's events plus a signed digest of their hashes. Each event hash is verifiable and every event is bound to the signed run, so a foreign event cannot be relabelled in. Use it to hand over a single decision without exporting the instance.

export and verify a single run

shell

# A single run's events, plus a signed digest of their hashes.
# Hand this to an auditor reviewing one decision, not the whole instance.
node dist/cli.js audit export --run <run-id> --out run-bundle.json
node dist/cli.js audit verify-bundle --in run-bundle.json --key instance_key.pub

Tamper resistance (and its limit)

Resistance to tampering comes from three layers, in order:

  1. Database triggers. BEFORE UPDATE, DELETE, and TRUNCATE triggers on audit_events reject those operations outright. The table only accepts appends.
  2. A non-owner runtime role. The server connects as a Postgres role that does not own the table, so it cannot disable or drop the triggers it runs behind. See Self-hosting for how the owner and runtime roles are split.
  3. The hash chain. If someone bypasses both layers and edits or removes a row directly, recomputation no longer matches and verification fails at that sequence number. This catches out-of-band edits and deletion from the middle of the chain.

What the chain alone cannot catch

A hash chain detects edits and middle-row deletion. It does not, on its own, detect tail truncation or a full rollback to an earlier state: a chain that has had its newest events lopped off still verifies cleanly, because what remains is internally consistent. The only defence is comparison against an externally retained signed export. Take periodic full bundles, store them outside the deployment, and check that the live head hash and count have only moved forward. Without that external anchor, completeness is assumed, not proven.

The format is open

Nothing about verification depends on running MakerChecker's code. The hash input is a specified field set over RFC 8785 canonical JSON, the signature is standard Ed25519 over the bundle manifest, and the bundle is plain JSON. An inspector who distrusts both your operators and us can write a verifier in any language and reach the same verdict from the bytes alone.

That is the point of the design: the audit is evidence a third party can check, not a claim you ask them to believe.

Where to go next

See Concepts and model for what generates these events in the first place, and Self-hosting for the non-owner role and key handling that make the triggers and signing credible on your own infrastructure.
Stuck or evaluating for a regulated team?Book a walkthroughOpen an issue on GitHub