---
title: How the audit log works
description: One entry per hook event, where the files live, the per-session lock, the hash chain, and what nd7 verify does and does not prove.
order: 70
section: Audit log
---

nd7's audit log is one file per session, and that file is append-only: lines are only ever added at the end, and nothing already written is changed. A hook is a command you register in your settings, which Claude Code runs at a fixed point, such as before a tool call. Claude Code calls `nd7 record` for each hook event, and `nd7 record` appends exactly one line. Each line carries a fingerprint of its own bytes and of the line before it, so no line can be changed or removed without the chain of fingerprints saying where. This page covers what gets recorded and where it lands, how nd7 stops hooks that run at the same moment from tripping over each other, what `nd7 verify` checks, and the one thing the log honestly does not prove.

## Hooks in, entries out

Claude Code runs a configured command for each hook event and pipes one JSON object to its standard input. `nd7 record` does five things, in order, and nothing else:

1. Take the invocation facts — wall-clock time at nanosecond resolution, the hostname, and the parent process id — **before** reading standard input, so the timestamp marks when the hook fired rather than when parsing ended.
2. Read standard input to the end and parse it into a typed model, which means nd7 has a named shape for the event rather than a bag of loose JSON. All 33 documented Claude Code hook events are typed, and the payload is also kept whole.
3. Turn it into one nd7 event.
4. Append that event to the session's log.
5. Exit 0 with nothing on standard output.

Two rules hold throughout. `nd7 record` always exits 0, and it never prints to standard output. Claude Code treats JSON on standard output, and an exit code of 2, as instructions that change what the agent does next, and the recorder must never issue one of those. If anything fails — malformed JSON, an unwritable directory — it writes a single line to standard error and still exits 0. Losing one event is better than blocking the agent.

## The six kinds with their own shape, and everything else

Six hook events get an entry shape of their own, with the interesting fields pulled out and named:

- **`SessionStart`**: entry kind `session_start`. The body holds why the session started: `startup`, `resume`, `clear`, `compact`, `fork`.
- **`UserPromptSubmit`**: entry kind `prompt`. The body holds the prompt text, verbatim.
- **`PreToolUse`**: entry kind `tool_call`. The body holds tool name, tool use id, the full tool input, plus `argv` and `paths` copied out of it into fields of their own.
- **`PostToolUse` / `PostToolUseFailure`**: entry kind `tool_result`. The body holds tool name, tool use id, the response, `ok` true or false, duration.
- **`Stop`**: entry kind `turn_end`. The body holds the final assistant message of the turn.
- **`SessionEnd`**: entry kind `session_end`. The body holds why the session ended.

Every other hook event is kept whole under `kind: hook`, with the full payload in `body.raw`. That is deliberate: when an upgrade of Claude Code adds or renames an event, nd7 still stores it in full instead of quietly dropping it. [The log format](/docs/log-format) has every field.

## Where logs live

```
$XDG_STATE_HOME/nd7/sessions/<session-id>/
```

with `~/.local/state/nd7` as the default when `XDG_STATE_HOME` is not set. That variable comes from the XDG Base Directory specification, the usual convention for where a program keeps machine-local state. There is one directory per session, named after Claude Code's own `session_id` used verbatim; nd7 rejects an id containing a path separator or `..`. The directory holds:

- **`events.ndjson`**: the log: one JSON object per line, append-only
- **`head`**: `"<seq> <hash>\n"`, the chain's last position, so an append need not scan the log
- **`lock`**: empty; the file every appending process locks, so that only one appends at a time (see below)
- **`shipped`**: `"<seq> <hash>\n"`, the last entry a vault acknowledged (only once enrolled)
- **`chain.key`**: the encryption key for this chain, mode 0600, present only while the chain is open

There is no reader command yet. `nd7 sessions` and `nd7 show` were planned and then dropped. The things that read the log are programs — later phases working out sandbox rules from it, and the vault's viewer — rather than a person watching a timeline in a terminal. Until something else exists, ordinary tools work:

```sh
ls ~/.local/state/nd7/sessions/
tail -f ~/.local/state/nd7/sessions/<session-id>/events.ndjson \
  | jq -c '{seq, kind, ev: .body.hook_event_name, tool: .body.tool_name}'
```

The first event for an unknown session id creates the directory. There is no separate "open the session" step, because hooks can arrive in any order after a crash, and `SessionStart` may fire with `resume` or `compact` on a session that already has a log.

## The per-session lock

Claude Code can run several tool calls at once, so several hook processes can try to append at the same moment. Without coordination, two of them could claim the same sequence number, or link onto the same previous entry and lose one of the two lines.

Every append takes an exclusive advisory lock on the session's `lock` file, which is a lock the operating system offers and every nd7 process agrees to respect (`flock` underneath). It holds that lock for the whole read-then-write cycle: reading `head`, appending the entry, rewriting `head`. `nd7 ship` takes the same lock while it reads the log and rewrites `shipped`. `nd7 verify` takes a shared lock, which several readers may hold at once but no writer can, so that no append can land between reading the log and reading `head`.

This is tested rather than asserted: a 16-thread unit test and a 40-process integration test both fail when the lock is removed. The benchmark run fired 50 recorder processes at one 10,000-entry session. All 50 exited 0, the entry count moved by exactly 50, and `nd7 verify` was clean afterwards.

## The chain and the `head` companion file

Every entry ends with `hash`, the BLAKE3 fingerprint of its own bytes, and carries `prev`, the fingerprint of the entry before it. That is what makes the log a chain: each entry names the one behind it. The first entry's `prev` is the fingerprint of the session id, so a chain cannot be moved from one session to another and passed off as that session's.

<svg viewBox="0 0 760 232" role="img" aria-labelledby="chain-diagram-title" style="max-width:760px;width:100%;height:auto;display:block;margin:1.5rem auto">
  <title id="chain-diagram-title">Three log entries side by side. Each shows its sequence number, its prev field and its hash field. An arrow runs from each entry's hash to the next entry's prev. Entry zero's prev is the fingerprint of the session id. Below the last entry, the highlighted head companion file holds that entry's sequence number and hash.</title>
  <defs>
    <marker id="chain-arrowhead" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto">
      <path d="M0 0 L8 4 L0 8 Z" fill="currentColor"/>
    </marker>
  </defs>
  <g fill="var(--paper-2, #171f29)" stroke="currentColor" stroke-width="1">
    <rect x="10" y="40" width="176" height="100"/>
    <rect x="292" y="40" width="176" height="100"/>
    <rect x="574" y="40" width="176" height="100"/>
    <rect x="574" y="180" width="176" height="36" stroke="var(--accent, #e9c46a)"/>
  </g>
  <g fill="none" stroke="currentColor" stroke-width="1" marker-end="url(#chain-arrowhead)">
    <line x1="186" y1="98" x2="288" y2="78"/>
    <line x1="468" y1="98" x2="570" y2="78"/>
    <line x1="662" y1="140" x2="662" y2="176"/>
  </g>
  <g fill="var(--paper, #121820)">
    <rect x="190" y="48" width="94" height="18"/>
    <rect x="472" y="48" width="94" height="18"/>
  </g>
  <g fill="currentColor" font-family="ui-monospace, monospace" font-size="13">
    <text x="10" y="24">events.ndjson, one entry per line</text>
    <text x="22" y="62">seq   0</text>
    <text x="22" y="82">prev  &lt;session id&gt;</text>
    <text x="22" y="102">hash  8c12ab…</text>
    <text x="22" y="122">kind  session_start</text>
    <text x="304" y="62">seq   1</text>
    <text x="304" y="82">prev  8c12ab…</text>
    <text x="304" y="102">hash  4d77e1…</text>
    <text x="304" y="122">kind  tool_call</text>
    <text x="586" y="62">seq   2</text>
    <text x="586" y="82">prev  4d77e1…</text>
    <text x="586" y="102">hash  b90f35…</text>
    <text x="586" y="122">kind  tool_result</text>
    <text x="586" y="203">head  2 b90f35…</text>
    <text x="237" y="62" text-anchor="middle">hash → prev</text>
    <text x="519" y="62" text-anchor="middle">hash → prev</text>
    <text x="10" y="203">head caches the last entry, and is checked against it</text>
  </g>
</svg>

*Each entry names the one behind it by fingerprint, so changing any line breaks every arrow after it; `head` is only a cache of where the chain has got to.*

Reading the whole log to find where the chain has got to would make appends cost more as the log grows. Instead `head` holds the last entry's sequence number and hash, and an append reads that one small file plus the log's last line, which it finds by reading backwards from the end. The measured result is that recording costs the same however large the log is (O(1) in log size): over a fifty-fold growth in log length the median time moved by 22 microseconds, which is inside the noise. See [Benchmarks](/docs/benchmarks).

`head` is only a cache of what the log already says, and nd7 never trusts it on its own. Every append reads both `head` and the log's last line and checks them against each other. Two disagreements are **repaired**, because in both the log itself is intact and only the cache is behind:

- `head` is missing although the log has entries;
- `head` names the entry before the last one, and the last entry's `prev` is exactly that hash. This is an append that died between writing its line and rewriting `head`.

Every other disagreement is refused: `head` ahead of the log, further behind it than one entry, or naming the last entry with a different hash. In those cases nd7 appends nothing, leaves both files untouched, and reports an error naming the position. Appending onto a chain that already disagrees with itself would look exactly like tampering to anyone reading it later, so the writer will not do it.

Surviving a crash follows from the order of the writes. The entry is appended with a single write to a file opened in append mode; only then is `head` rewritten, by writing `head.tmp` and renaming it over `head`. A crash in between leaves `head` one entry behind, which the next append detects and repairs. The log itself is never rewritten.

## nd7 verify

```sh
nd7 verify <session-id>
```

It walks the chain from the first entry. For each line it checks, in order, four things: that the line is a sealed entry, meaning it ends with a `hash` member the writer put there; that this hash equals BLAKE3 of the line's own bytes; that its sequence number matches its position in the file; and that its `prev` matches the previous entry's hash — the session-id fingerprint at position 0. Then it compares the log's last entry with `head`. It stops at the first failure, names the entry, and exits 1.

Each entry is checked against nothing but its own bytes and the previous entry's stored hash. That is what would let nd7 later spread the walk across several cores without changing a single check.

On success it prints the count, the first 16 characters of the head hash, and always this:

```
verified: 1487 frames, head 8f2c1a09b3d7e455
note: proves the log is unchanged since its last frame was written by this machine; anyone with write access could rewrite the whole chain.
```

An empty or missing log verifies with zero entries. [The log format](/docs/log-format) lists every way verification can fail.

## What the chain proves, and what "intent, not proof" means

Two caveats, both stated plainly in the repository.

**The chain is self-checking, not third-party evidence.** A clean verify proves the file has not been modified since it was written — *if* you trust `head`. Someone who can write the directory can rewrite the entire chain, `head` included, and the rewritten log will verify. Closing that gap is what [shipping to a vault](/docs/vault) is for: a copy on a machine the agent cannot reach makes a later local rewrite show up, because the rewritten chain no longer matches the copy. The format reserves a field, `sig`, for a signature, and nd7 never writes it today.

**The log records intent, not effect.** Every entry comes from Claude Code's own hook stream: what the agent said it was about to do, and what it reported back afterwards. It is not proof of what actually ran on the machine. That is why every entry carries a `source` field naming the kind of evidence it is — `intent:claude-code` today — so that no reader can mistake one kind for the other. Recording what the kernel itself observed, written into the same log with `source: effect:*`, is a planned phase; see [Roadmap](/docs/roadmap).

The one place Phase 1, the version described here, comes close to evidence of effects is `bashEditDiff`. When a Bash command modifies files, Claude Code's own response carries a full unified diff of each one, and nd7 stores that diff exactly as given. It is the single biggest reason entries can reach tens of kilobytes, and the reason the decision went to storing content in full rather than cutting it short.
