Skip to main content

Audit Non-Repudiation

Concept + reference. This page explains how AxonFlow turns the decision chain from a tamper-evident record into a tamper-proof one for signed records, how to verify it, and where the guarantee stops. Read the framing first, then use the verification recipe at the end.

The audit logging page covers what AxonFlow records. This page covers a narrower, sharper question that regulated buyers ask:

Can you prove which agent produced this exact record, and prove it was not altered, even against someone with write access to the database?

There is a real difference between being able to reconstruct what probably happened and being able to prove it. Non-repudiation is the second one.

Tamper-evident vs tamper-proof

It is worth being precise, because the two terms are often blurred in vendor material.

PropertyWhat it meansWhat AxonFlow had beforeWhat signed chains add
Tamper-evidentCasual edits are detectable if you trust the storePer-record audit_hash over each record's own fields(still present)
Tamper-proofA hostile party with DB write access cannot alter or forge a record undetectedOnly via the WORM export pathPer-record signature + linked hash chain on the live DB

Before signed chains, an adversary who could write to Postgres could edit a decision row and recompute its audit_hash, and could insert, delete, or reorder rows without the chain noticing. The only strong guarantee lived in the Object-Lock WORM export, not in the live database.

Signed decision chains close that gap on the live decision_chain table.

Two layers, two distinct proofs

A signed decision chain layers two independent integrity properties. They prove different things, and it is important to keep them separate.

                 prev_hash chain (ordering + completeness)
┌────────────┐ ┌────────────┐ ┌────────────┐
genesis │ record #1 │──▶│ record #2 │──▶│ record #3 │
│ signature │ │ signature │ │ signature │
└────────────┘ └────────────┘ └────────────┘
per-record Ed25519 signature (authorship)
  1. The hash chain proves ordering and completeness. Each record stores a prev_hash that commits to the previous record's chain hash (the first record stores a fixed genesis sentinel). Insert, delete, or reorder any record and the linkage no longer holds, so verification reports exactly where the chain broke.

  2. The per-record signature proves authorship. Each record is signed with an Ed25519 key over that record's own chain hash. Because the chain hash is derived purely from the record's own fields plus its stored prev_hash, a single record can be verified standalone, offline, against the published public key, with no need to fetch or recompute the rest of the chain. This is the "prove this one call from the record alone" property.

In short: the chain proves the sequence, the signature proves the author.

What this proves, and what it does not

Being honest about scope is the point of the feature, so here is the boundary.

It does prove (for signed records):

  • The record was produced by the holder of the signing key. An attacker with DB write access cannot alter a signed record, or forge a new signed record in a position, without the private key.
  • Records were not silently inserted, deleted, or reordered within a chain.
  • A single record's authorship can be checked in isolation, offline, by anyone holding the public key.

It does not (yet) prove, on its own:

  • Truncation of a trailing suffix. Deleting the last N records of a chain still leaves a self-consistent prefix. The WORM / Object-Lock export is what defends against wholesale deletion, and an external timestamp anchor would harden this further. Use both together for the strongest posture.
  • Records written before signing was enabled, or while no signing key is configured. Those are hash-chained where possible but not signed, and verification reports them honestly as unsigned rather than claiming a proof that does not exist.
  • Surfaces other than the decision chain. This covers the decision_chain table. Extending per-record signing to the broader canonical audit_logs writer is tracked as a follow-up and is not claimed here.

We would rather you know these edges than discover them in an audit.

Enabling signing

Signing is driven by a single environment variable on the agent:

# Base64-encoded Ed25519 key: either a 32-byte seed or a 64-byte private key.
# Any of the four common base64 dialects is accepted.
export AXONFLOW_AUDIT_SIGNING_KEY="<base64-ed25519-key>"

# Optional: a human-readable key id. When omitted, a stable id is derived
# from the public key (first 16 hex chars of sha256(public key)).
export AXONFLOW_AUDIT_SIGNING_KEY_ID="audit-2026-q2"

# Optional: retired PUBLIC keys (comma-separated base64, 32 bytes each) kept
# only for verifying records signed before a rotation. See "Key rotation" below.
export AXONFLOW_AUDIT_VERIFY_KEYS="<base64-old-public-key>,<base64-older-public-key>"

When the signing variable is unset, records are still hash-chained (so ordering and completeness are protected) but are not signed. The verification endpoints report configured: false and mark records unsigned. There is no silent half-state: a present-but-invalid key fails loudly at startup rather than quietly disabling signing.

Generate a key with any Ed25519 tool, for example:

openssl genpkey -algorithm ed25519 -outform DER 2>/dev/null | tail -c 32 | base64

Store the key in your secrets manager and treat it like any other signing secret.

Key rotation

Each record stores the signing_key_id it was signed with, so verification resolves the correct public key per record. To rotate:

  1. Generate a new key and point AXONFLOW_AUDIT_SIGNING_KEY at it. New records are signed with the new key.
  2. Add the previous key's public half to AXONFLOW_AUDIT_VERIFY_KEYS so records signed by the retired key continue to verify.

If a record's signing_key_id cannot be resolved to a public key, verification reports it as unverifiable rather than valid. It never silently trusts a record it cannot check, so dropping an old public key from the verify set makes those records fail loudly, not pass quietly.

Verification endpoints

All three endpoints are scoped to the authenticated organization. You can only verify chains and records that belong to your own org.

Verify a whole chain

GET /api/v1/audit/chains/{chainID}/verify

Replays the chain in order, checks every prev_hash link and every signature, and reports the first break if any.

{
"chain_id": "5f1d...",
"org_id": "acme",
"total_records": 12,
"valid": true,
"authorship_proven": true,
"linkage_valid": true,
"signatures_valid": true,
"signed_records": 12,
"unsigned_records": 0,
"signing_key_id": "9a3c1f20b7e4d6a8",
"public_key": "MCowBQYDK2Vw...",
"verified_at": "2026-06-22T10:00:00Z"
}

valid means no integrity violation was detected. It can be true for a chain that happens to have zero signed records, because nothing failed. Gate on authorship_proven for the strong claim: it is true only when every record is signed and all signatures and linkage verify. That keeps an all-unsigned chain from being mistaken for a proof of authorship.

When something is wrong, valid is false and the response names the exact failure:

{
"valid": false,
"linkage_valid": false,
"first_broken_seq": 7,
"first_broken_record_id": "a1b2...",
"break_reason": "linkage break at chain_seq=7 (record a1b2...): prev_hash does not match the previous record's chain-hash (insertion, deletion, reordering, or a pre-signing legacy record that predates migration 125)"
}

Verify a single record (standalone)

GET /api/v1/audit/records/{recordID}/verify

Proves one record's authorship without consulting any other record. The response returns the recomputed material so the result is independently checkable.

{
"record_id": "a1b2...",
"chain_id": "5f1d...",
"chain_seq": 7,
"signed": true,
"signature_valid": true,
"valid": true,
"digest_preimage_b64": "aWQ6NDA6...",
"record_digest": "3c9d...",
"prev_hash": "77ab...",
"chain_hash": "b40e...",
"record_signature": "iX8...==",
"signing_key_id": "9a3c1f20b7e4d6a8",
"public_key": "MCowBQYDK2Vw...",
"verified_at": "2026-06-22T10:00:00Z"
}

The digest_preimage_b64 field is the exact byte string that hashes to record_digest. It lets a third party reproduce the digest without trusting our hash, and the format is fully specified below so the same party can also rebuild it from the raw record fields and confirm the endpoint did not misrepresent them.

Publish the verification key

GET /api/v1/audit/signing-key

Returns the current public key and key id so external tooling (or an auditor) can re-verify offline:

{
"algorithm": "ed25519",
"signing_key_id": "9a3c1f20b7e4d6a8",
"public_key": "MCowBQYDK2Vw...",
"configured": true
}

Verifying a record offline

The point of signing the record's own chain hash is that you do not have to trust AxonFlow's verifier. Anyone holding the public key can confirm authorship independently, from the record alone. The procedure:

  1. Obtain the record and the public key (from the signing-key endpoint, an export, or your key store).
  2. Reproduce the record digest from the raw fields, using the exact format in the next section. The endpoint also returns digest_preimage_b64; base64-decode it, SHA-256 it, and confirm it equals record_digest. Then rebuild the same pre-image yourself from the raw fields to confirm the endpoint did not misrepresent them.
  3. Recompute the chain hash: sha256( record_digest + "|" + prev_hash ) (the endpoint returns this as chain_hash).
  4. Verify the signature: ed25519.Verify(public_key, ascii_bytes(chain_hash), base64_decode(record_signature)).

If step 4 returns true, the record was authored by the key holder and has not been altered, including its claimed position (chain_seq and prev_hash are part of the signed material). No other record is involved.

Record digest pre-image format

The record digest is sha256(pre-image), hex-encoded. The pre-image is built by concatenating these fields in this exact order, each terminated by a record-separator byte 0x1E:

  • Scalar fields are encoded as label + ":" + decimal_len(value) + ":" + value.
  • List fields are encoded as label + "#" + decimal_count + ":" + decimal_len(joined) + ":" + joined, where joined is the list items concatenated with a unit-separator byte 0x1F between them, in stored order.

Field order:

id                     (scalar)
chain_id (scalar)
request_id (scalar)
parent_request_id (scalar, empty string when absent)
step_number (scalar, decimal integer)
chain_seq (scalar, decimal integer)
org_id (scalar)
tenant_id (scalar)
client_id (scalar, empty string when absent)
user_id (scalar, empty string when absent)
decision_type (scalar)
decision_outcome (scalar)
system_id (scalar)
model_provider (scalar, empty string when absent)
model_id (scalar, empty string when absent)
policies_evaluated (list)
policy_triggered (scalar, empty string when absent)
risk_level (scalar)
requires_human_review (scalar, "true" or "false")
processing_time_ms (scalar, decimal integer)
input_hash (scalar, empty string when absent)
output_hash (scalar, empty string when absent)
audit_hash (scalar)
data_sources (list)
created_at_unixmicro (scalar, created_at as a decimal integer of microseconds since the Unix epoch, UTC)

len(value) is the byte length of the UTF-8 value. NULL database columns are treated as the empty string. created_at is folded as Unix microseconds (not a formatted timestamp) so it round-trips exactly through Postgres TIMESTAMPTZ.

A note on coverage: the digest covers the decision's identity, type and outcome, policy evaluation, risk level, timing, and the input/output content hashes. Free-form metadata is intentionally excluded, because JSON object storage normalizes key order and number formatting on a write/read round-trip, which would make the digest non-reproducible. The substance of the decision is still committed through the input, output, and audit hashes.

A note on chains that span the migration

Records created before signing was introduced (migration 125) have no prev_hash and no signature. A chain that mixes pre-migration and post-migration records will report a linkage break at that boundary, because the pre-migration records are not part of the cryptographic chain. This is expected: those records are reported as unsigned, and only records created after signing was enabled carry the full guarantee.

How this fits with the rest of the audit story

  • Audit logging is the broader record of every interaction.
  • Decisions and explainability tell you why a decision was made.
  • The WORM / Object-Lock export protects against wholesale deletion.
  • Signed decision chains, described here, let you prove authorship and detect tampering on the live database.

Used together, these move the audit trail from "we can reconstruct what happened" to "we can prove it."