Skip to content

audit

Audit evidence a verifier can check offline

By Olivares AI 11 min read

Your auditor asks for evidence of what your AI agents did last quarter. You hand them a CSV export of the audit log. They ask one question: “Can you prove this was not edited after the fact?” Most teams cannot. The log lives in a mutable database, exported by the same system that wrote it. The auditor has to take the entire trail on faith, which is precisely the property that makes it not evidence.

This post explains how the platform constructs audit evidence that holds up without faith. A previous post covered why per-agent identity and a tamper-evident ledger matter for Claude Code and MCP deployments. This one goes deeper into the three properties that make the evidence independently verifiable: hash-chaining, per-event cryptographic signing, and export in a machine-readable compliance format an auditor’s tooling already understands.

The hash chain: every event commits to all previous events

The ledger is append-only and hash-chained per tenant. Each event carries the SHA-256 hash of the previous event in its prev_hash field. The chain hash of event N is computed over a canonical binary preimage that includes: event N’s own fields (tenant, sequence number, timestamp, actor, action, target, metadata digest, payload hash) concatenated with the prev_hash from event N-1. At sequence 1, prev_hash is all zeros — the genesis anchor.

The critical property: modifying, inserting, or deleting any event in the middle of the chain changes its hash, which breaks the prev_hash link of the next event, which breaks the next, and so on to the tip. A single edit is detectable by walking the chain and recomputing each hash.

The preimage is a fixed, length-prefixed binary encoding with a versioned domain separator — not JSON. This is a deliberate choice against three attack surfaces that a JSON-based hash would leave open:

  • Key ordering: JSON object key order is not guaranteed by most serializers. A different key order produces a different hash even for semantically identical data, which creates false chain breaks — or worse, lets an attacker reorder keys to forge a matching hash.
  • Whitespace and number formatting: {"seq": 1} and {"seq":1} and {"seq": 1.0} are semantically equivalent JSON but produce different SHA-256 digests.
  • Concatenation forgery: without length prefixes, two adjacent short fields can be merged to forge a third long field with the same hash. Length-prefixing each field with its byte count (4-byte big-endian) closes this.

The implementation in core/internal/store/canon/canon.go is the single source of truth. Both Append (writing) and Verify (reading back) call the same EventHash function. There is no second implementation to drift:

func EventHash(e Event) []byte {
    var buf []byte
    buf = lps(buf, domainEvent)       // "olivares.audit.v1"
    buf = lps(buf, e.TenantID)
    var seq [8]byte
    binary.BigEndian.PutUint64(seq[:], uint64(e.Seq))
    buf = append(buf, seq[:]...)
    buf = lps(buf, e.OccurredAt)
    buf = lps(buf, e.Actor)
    buf = lps(buf, e.ActorKind)
    buf = lps(buf, e.Action)
    buf = lps(buf, e.TargetKind)
    buf = lps(buf, e.TargetID)
    buf = append(buf, fixed(e.MetaDigest)...)
    buf = append(buf, fixed(e.PayloadHash)...)
    buf = append(buf, fixed(e.PrevHash)...)
    sum := sha256.Sum256(buf)
    return sum[:]
}

The domain separator "olivares.audit.v1" binds the hash to its purpose and version. A hash from a different domain (a checkpoint, a payload, a metadata digest) can never collide with an event hash, even if the raw bytes happened to match.

Ed25519 signing: every event is its own anchor

A hash chain proves internal consistency, but not authenticity. An attacker with raw database write access could recompute the entire chain from scratch with altered events and produce a valid chain — different from the original, but internally consistent. Hash chains detect tampering; they do not prove origin.

Per-event Ed25519 signatures close this gap. Every event appended to the ledger is signed at write time. The signature covers a domain-separated preimage of the tenant, sequence number, and the event’s chain hash:

domain ("olivares.audit.event.v1") || tenant || seq (8 bytes, big-endian) || hash

The signature is stored on the event but is excluded from the chain-hash preimage by design. This is not accidental: if the signature were included in the hash, signing an event would change the hash it is supposed to attest. The signature attests the hash without altering it.

An external verifier holding only the public key can confirm each event individually: recompute the chain hash from the event’s fields, reconstruct the preimage, and verify the Ed25519 signature. If any event was altered after signing, the signature check fails for that specific event — the verifier does not need to trust the system that produced the evidence.

The platform also supports key rotation. A chain whose signing key changed mid-life verifies end-to-end by pinning the current key plus the prior generations’ public keys. The verify function accepts a set of candidate keys and considers an event valid if any candidate verifies it.

On top of per-event signatures, periodic checkpoints notarize the chain tip under a separate signature domain (olivares.audit.checkpoint.v1). For organizations that need to defend against host-level compromise — not just database-level — checkpoints can be signed by an off-box KMS/HSM key (AWS KMS, GCP Cloud KMS, Azure Key Vault) where the private key never lives on the host. Per-event signatures handle the DB-only attacker; off-box checkpoints handle the host-compromise attacker. The two threat models are distinct; neither signature alone covers both.

The ledger contract: sealed in the same transaction

A common failure in audit systems is eventual consistency between the state mutation and the audit record. The state changes, the audit write is queued or batched, and if the audit write fails the state change has already committed. The result: unaudited mutations that exist in the system but not in the evidence.

The platform enforces a stronger contract. Both the state mutation and the ledger seal happen in the same database transaction. If the seal fails, the entire transition rolls back — the state mutation never commits. This is not best-effort; it is deny-closed.

The session runtime ledger illustrates this. When a Claude Code session transitions state (created, launched, stopping, stopped, failed), appendRunEvent records the transition in two places atomically:

  1. The global hash-chained audit ledger via sc.Audit().Append — the tamper-evident chain anchored by a PayloadHash.
  2. The per-session queryable ledger — an append-only projection linked to the global chain by audit_seq.

Both writes happen inside the caller’s Mutate transaction. The code comment in runtime_ledger.go states the design intent directly: “the ledger is the system of record, so if the seal fails the whole transition rolls back — it is NOT best-effort.”

The PayloadHash itself commits only to canonical, non-sensitive transition facts — the run reference, sequence, event type, state transition, and timestamp. It never includes transcript content, prompts, environment values, or secrets. The ledger proves what happened; it does not store what was said.

The same pattern applies to workspace file mutations in workspace_ledger.go. A file write, mkdir, move, or delete is sealed before the filesystem operation runs. If the evidence cannot be appended, the mutation does not execute. The seal carries the operation type, path, and a SHA-256 of the written content — never the content bytes themselves.

OSCAL export: machine-readable evidence your auditor’s tooling ingests

A tamper-evident ledger is necessary but not sufficient for an auditor. If the evidence is in a proprietary format, the auditor still depends on your tooling to interpret it. OSCAL — the Open Security Controls Assessment Language, maintained by NIST — is the format that closes this gap.

The platform exports sealed evidence packages as an OSCAL bundle containing three models:

  • Component definition: the control plane’s capabilities expressed as implemented requirements against a compliance framework (NIST SP 800-53, ISO 27001, EU AI Act, and others). Each implemented requirement carries the control ID, the capability keys that evidence it, and the real status as a custom property.
  • Assessment results: per-control findings with an OSCAL-compliant status. Each finding’s target carries satisfied or not-satisfied with the precise product status preserved in the reason field.
  • Control mapping: a crosswalk from framework controls to the platform’s capability reference model, using the OSCAL 1.2.0 control mapping model. The relationship is always intersects-with — the capabilities address part of a control. It never asserts conformance; that assertion lives only in assessment results, gated on live operational evidence.

The honesty constraint in the OSCAL export is worth stating explicitly. OSCAL’s finding status enum has exactly two values: satisfied and not-satisfied. There is no “partial” or “by design.” A control that is partially implemented, addressed by design, gapped, or unmapped maps to OSCAL not-satisfied, with the real product status riding in status.reason and a custom property under the platform’s own namespace (https://olivares.ai/ns/oscal). The export never launders a partially-met control into satisfied. Only controls backed by live operational evidence at seal time receive OSCAL satisfied.

Every OSCAL document carries ledger anchor properties: the manifest hash, the ledger sequence number at seal time, the ledger hash, and the integrity verification result. These are the bridge between the OSCAL document the auditor reads in their GRC tool and the underlying tamper-evident chain they can verify independently.

Activity to evidence pipeline

What offline verification means concretely

“Offline verification” is not a marketing phrase. It describes a specific technical procedure: the verifier takes the exported evidence, runs a verification tool on an air-gapped machine, and confirms the evidence’s integrity without any network access to the system that produced it.

The platform’s archival export writes a directory tree of JSONL segments (one line per event, canonical JSON) plus a manifest per segment. The manifest records the segment’s sequence range, event count, first and last chain hashes, a SHA-256 of the events file, and the previous segment’s last hash for cross-segment continuity.

The offline verifier (VerifyArchiveDir) then does the following, entirely in constant memory with no network calls:

  1. Load manifests and pair them with event files. A stray events file (no manifest) or a manifest whose events file is missing is a failure. The unit of evidence is the pair.
  2. Stream each event file line by line. For each event, re-derive the chain hash from the archived fields using the same EventHash function the live system uses. Compare it against the stored hash. Check prev_hash linkage and sequence gap-freedom.
  3. Verify canonicality. Re-marshal each parsed line and confirm it produces byte-identical output to the on-disk bytes. This prevents unknown-field smuggling or duplicate-key attacks that would pass a hash check but carry hidden data.
  4. Verify per-event Ed25519 signatures. For each non-checkpoint event, reconstruct the signature preimage and verify against the pinned public key(s).
  5. Verify checkpoint signatures. For each checkpoint event, verify the signature under the checkpoint domain against the pinned checkpoint key(s). If only event keys are pinned (no checkpoint key), a checkpoint event is marked “unverifiable” — deny-closed, not skippable.
  6. Check cross-segment continuity. Confirm that each segment’s first sequence follows the previous segment’s last sequence plus one, and that the previous segment’s last hash matches the current segment’s prev_segment_last_hash.
  7. Verify the events file digest. The SHA-256 of the events file computed during streaming must match the manifest’s events_sha256.

The verifier reports the first inconsistency it finds, with the specific sequence number and a machine-readable reason: hash-mismatch, prev-mismatch, seq-gap, event-sig-invalid, event-sig-missing, checkpoint-sig-invalid, count-mismatch, events-sha256-mismatch, or segment-link-mismatch.

An honest limitation: the offline verifier attests exactly the range it checked and nothing outside it. A removed prefix or tail is undetectable offline — the directory does not say where the chain began or ended. The verify report includes a per-tenant Ranges field with a StartsMidChain flag so the auditor knows exactly what was attested. The in-chain signed checkpoints of the live system cover the tail; the offline export covers the archived range. Together they form the complete attestation.

LayerWhat it provesWhat it does not prove
Hash chainInternal consistency; any edit breaks the chainOrigin (who wrote the events)
Per-event Ed25519Origin; each event was signed by the key holderDefence against host-level compromise
Off-box checkpointHost-compromise resistance (KMS/HSM key never on host)Per-event granularity (covers checkpoints only)
OSCAL exportMachine-readable, framework-mapped compliance evidenceThat every control is fully met (only live evidence counts)
Archive verifyOffline re-derivation of all of the aboveEvents before or after the exported range

The code path: from state mutation to sealed evidence

The sequence from a Claude Code session state change to verifiable evidence touches three layers. In runtime_ledger.go, the appendRunEvent function constructs a PayloadHash by SHA-256 hashing the canonical, length-prefixed transition fields:

func runEventPayloadHash(runRef string, seq int64, event, from, to, detail, atTS string) [32]byte {
    h := sha256.New()
    for _, part := range []string{
        runRef, strconv.FormatInt(seq, 10), event, from, to, detail, atTS,
    } {
        _, _ = h.Write([]byte(strconv.Itoa(len(part))))
        _, _ = h.Write([]byte{':'})
        _, _ = h.Write([]byte(part))
    }
    var sum [32]byte
    copy(sum[:], h.Sum(nil))
    return sum
}

That hash is then passed to sc.Audit().Append, which assigns the next per-tenant sequence number, links to the previous event’s hash, computes this event’s chain hash via EventHash, signs it with Ed25519, and inserts the sealed event — all within the caller’s transaction.

The result: by the time the transaction commits, the session’s state change and its tamper-evident audit record are either both persisted or both rolled back. There is no window where the state changed but the evidence did not.

Related posts

Frequently asked

Can an attacker who compromises the database re-sign the forged events?

Per-event Ed25519 signatures defend against DB-only compromise: stolen backups, injected rows, or a replica with an RLS-bypass role. The signing key lives in the data directory, not in the database. For the host-compromise case, the platform supports off-box checkpoint signing via KMS/HSM (AWS KMS, GCP Cloud KMS, Azure Key Vault), where the private key never lives on the host. Per-event signatures stop DB-level attacks; off-box checkpoints stop host-level ones. Neither alone covers both threat models.

What happens if the OSCAL export marks a control as satisfied but the operational evidence later changes?

The OSCAL export maps the product status to the OSCAL finding status enum at seal time. Only controls backed by live operational evidence at that moment receive OSCAL satisfied. A control with status by_design, partial, gap, or unmapped is mapped to OSCAL not-satisfied, with the precise product status preserved in the reason field and a custom property. Each sealed evidence package is immutable and timestamped. If evidence changes, a new package is sealed reflecting the current state. The old package remains intact and re-verifiable, creating a time-series of compliance posture rather than a single overwritable assertion.

See what your agents can reach

Olivares AI is the open, self-hosted platform for your AI estate. Deploy it on your own infrastructure and get the access map your security and platform teams have been asking for.