---
title: Architecture
description: The parts of nd7, how its processes fit together, how data flows through them, and what each part has to trust.
order: 110
section: Reference
---

nd7 is small on purpose: one library, two binaries, no daemon (no long-running background process), no async runtime, and one file format that everything else is arranged around. This page describes the parts, what runs as what process, how an event gets from Claude Code to disk and from disk to a vault, what each part has to trust, and why two of the more visible choices — two binaries, and hooks before kernel observation — were made. It follows [ARCHITECTURE.md](https://github.com/nd7-dev/nd7-core/blob/main/docs/ARCHITECTURE.md).

<svg viewBox="0 0 760 340" role="img" aria-labelledby="arch-diagram-title" style="max-width:760px;width:100%;height:auto;display:block;margin:1.5rem auto">
  <title id="arch-diagram-title">Three flows through nd7. Across the top, nd7 run applies a Seatbelt floor to claude, whose PreToolUse hook sends each Bash command out through nd7-exec, the floor's only exit, to the kernel. In the middle, each hook event goes to nd7 record, which appends one line to events.ndjson. At the bottom, nd7 ship pushes encrypted batches of that log to a vault.</title>
  <defs>
    <marker id="arch-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="136" y="48" width="294" height="72" stroke-dasharray="4 3"/>
    <rect x="8" y="68" width="96" height="32"/>
    <rect x="152" y="68" width="96" height="32"/>
    <rect x="278" y="68" width="136" height="32"/>
    <rect x="538" y="68" width="96" height="32" stroke="var(--accent, #e9c46a)"/>
    <rect x="664" y="68" width="88" height="32"/>
    <rect x="145" y="190" width="110" height="32"/>
    <rect x="295" y="190" width="130" height="32"/>
    <rect x="305" y="290" width="110" height="32"/>
    <rect x="455" y="290" width="210" height="32"/>
  </g>
  <g fill="none" stroke="currentColor" stroke-width="1" marker-end="url(#arch-arrowhead)">
    <line x1="104" y1="84" x2="132" y2="84"/>
    <line x1="248" y1="84" x2="274" y2="84"/>
    <line x1="430" y1="84" x2="534" y2="84"/>
    <line x1="634" y1="84" x2="660" y2="84"/>
    <line x1="200" y1="100" x2="200" y2="186"/>
    <line x1="255" y1="206" x2="291" y2="206"/>
    <line x1="360" y1="222" x2="360" y2="286"/>
    <line x1="415" y1="306" x2="451" y2="306"/>
  </g>
  <g fill="var(--paper, #121820)">
    <rect x="435" y="55" width="98" height="18"/>
    <rect x="208" y="135" width="86" height="18"/>
    <rect x="400" y="265" width="74" height="18"/>
  </g>
  <g fill="currentColor" font-family="ui-monospace, monospace" font-size="13" text-anchor="middle">
    <text x="56" y="89">nd7 run</text>
    <text x="200" y="89">claude</text>
    <text x="346" y="89">PreToolUse hook</text>
    <text x="586" y="89">nd7-exec</text>
    <text x="708" y="89">kernel</text>
    <text x="200" y="211">nd7 record</text>
    <text x="360" y="211">events.ndjson</text>
    <text x="360" y="311">nd7 ship</text>
    <text x="560" y="311">vault (a server you run)</text>
    <text x="484" y="69">the one exit</text>
    <text x="437" y="279">encrypted</text>
  </g>
  <g fill="currentColor" font-family="ui-monospace, monospace" font-size="13">
    <text x="136" y="40">Seatbelt floor</text>
    <text x="212" y="149">hook event</text>
  </g>
</svg>

*`nd7 run` applies one Seatbelt floor to Claude Code and everything it starts, `nd7-exec` is the floor's only exit, Claude Code's hooks feed `nd7 record`, and `nd7 ship` pushes encrypted batches of the log to a vault.*

## Components

Everything shared lives in one library crate, `nd7_core`, and the two binaries on top of it hold very little code of their own.

- **[`hook`](https://github.com/nd7-dev/nd7-core/blob/main/src/hook/mod.rs)**: the typed model of every Claude Code hook payload, the nd7 event, and the conversion from one to the other. Pure, meaning it reads and writes nothing outside itself: no files, no network.
- **[`hook_prefix`](https://github.com/nd7-dev/nd7-core/blob/main/src/hook_prefix.rs)**: the `PreToolUse` reply that routes a Bash command through `nd7-exec`. Pure.
- **[`policy`](https://github.com/nd7-dev/nd7-core/blob/main/src/policy.rs)**: what a session may do, rendered as the two Seatbelt profiles that enforce it. Pure.
- **[`session`](https://github.com/nd7-dev/nd7-core/blob/main/src/session.rs)**: the directory one `nd7 run` owns, where `nd7-exec` finds the rules it applies.
- **[`sandbox`](https://github.com/nd7-dev/nd7-core/blob/main/src/sandbox.rs)**: Seatbelt: apply a profile between fork and exec, or to the calling process. macOS only.
- **[`session_log`](https://github.com/nd7-dev/nd7-core/blob/main/src/session_log.rs)**: the append-only per-session log: lock, head, chain, verify. The only module that knows where events live on disk.
- **[`vault`](https://github.com/nd7-dev/nd7-core/blob/main/src/vault/mod.rs)**: the wire format and cryptography the machine and the vault server share. Pure.
- **[`ship`](https://github.com/nd7-dev/nd7-core/blob/main/src/ship.rs)**: `nd7 enroll` and `nd7 ship`: the session directory, the vault's own files, and the network.

And two binaries:

- **`nd7`** — the command line. `record` is what hooks call; `verify` checks a chain; `enroll` and `ship` talk to a vault; `run` starts a sandboxed session; `hook-prefix` is the hook `run` installs; `allow` and `deny` change a running session's rules.
- **`nd7-exec`** — the one program the sandbox allows to start outside its own rules. It applies the session's current rules to itself and becomes the shell for one command. It never writes anything.

## Process model

Nothing in nd7 is long-lived. There is no daemon, no socket, no async runtime, and no configuration to parse beyond environment variables.

- `nd7 record` is a fresh process per hook event. It reads standard input, appends one line, exits 0. It does each step in order and waits for it to finish, with no concurrency anywhere.
- `nd7 hook-prefix` is a fresh process per Bash tool call. It reads the payload, prints one JSON reply, exits 0.
- `nd7-exec` is a fresh process per Bash command, and it does not outlive that command: it replaces itself with `/bin/zsh` through `exec`. So no extra process sits in the middle, to be left holding an orphaned command when Claude Code kills it, and the shell's exit status is the command's own.
- `nd7 run` is the only one that stays: it lives as long as the program it started, owns the session directory, and removes it on exit.
- `nd7 ship` runs once and exits by default. `--every` makes it loop, but the recommended shape is a system timer running the run-once form.

[ADR-0003](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0003-library-plus-thin-binaries-no-async-runtime-no-daemon), one of the project's architecture decision records, holds the measurements behind this. On the reference laptop, starting any process at all costs about 3.6–3.9 ms; wrapping it in `sh -c` adds about 6 ms; opening, appending and closing the log costs about 1 ms. A client that did nothing but start up and connect to a socket cost about 8 ms, as much as the whole nd7 binary. A daemon could remove at most that 1 ms, while adding problems of its own: keeping the process alive, surviving crashes without losing entries, and version skew, where the daemon in memory is older than the binary on disk. The last of those would break the rebuild-and-go development workflow. So: no daemon.

Asynchronous hooks were considered and rejected for a different reason. Claude Code does not wait for them to finish, so an entry could be appended after a later event's hook had already run, which would put the log out of order.

## Data flow: recording

```
Claude Code ──hook JSON on stdin──▶ nd7 record ──▶ log writer ──▶ events.ndjson
                                    (intent)        seq, ts, prev, hash
```

In order:

1. `Invocation::now` takes the timestamp, hostname and parent process id before it reads standard input, so `ts` marks when the hook fired.
2. The recorder parses the payload into the typed model. All 33 documented events have a type of their own, and the payload is kept whole as well.
3. `Event::new` produces one event. Six kinds get a body of their own; anything else, including event names this build has never seen, becomes a `hook` event carrying the raw payload.
4. `SessionLog::append` takes the session's exclusive advisory lock, which is a lock every nd7 process agrees to respect. It then reads `head` and the log's last line and checks them against each other. Finally it assigns `seq` and `prev`, seals the entry with its own hash, appends it with one write, and rewrites `head` through a temporary file and a rename.
5. Exit 0, with nothing on standard output.

Two rules make this safe to do from a process that lives for a few milliseconds. The lock means two hooks running at once cannot collide. The order of writes, entry first and `head` second, means a crash leaves `head` at most one entry behind, which the next append detects and repairs from the end of the log. The log itself is never rewritten.

## Data flow: sandboxing

```
nd7 run ──floor profile──▶ claude ──Bash call──▶ PreToolUse hook (nd7 hook-prefix)
                                                        │ rewrites the command
                                                        ▼
                                    nd7-exec ──reads policy.sb, applies it──▶ /bin/zsh -c …
```

`nd7 run` builds the rules from three directories: the project directory resolved to its full real path, the temporary directory, and the home directory as the system's account records give it. It writes those rules into `~/.nd7/sessions/<pid>/` as `record.json` and `policy.sb`, and applies the rendered floor (the baseline set of rules, which nothing inside can widen) to `claude` and to everything `claude` starts. The only way out of the floor is running one exact path, `nd7-exec`, which is marked so that the program it starts is no longer inside the sandbox.

`nd7-exec` then finds its session by walking up the chain of parent processes, which the caller cannot fake because the kernel answers those questions. It reads that session's `policy.sb`, applies it to itself, and becomes the shell. `nd7 allow` edits the same two files in a way that leaves no half-written state, so the next command picks up the change with no restart and no signal.

Failure is closed rather than open: if the hook stops being applied, the command simply runs without the prefix, under the floor, which is never wider. [How the macOS sandbox works](/docs/sandbox) has the rules and the reasoning.

## Data flow: shipping

`nd7 ship` is a second reader of the log, and the only part of nd7 that uses the network. It reads the entries after the last one the vault acknowledged, then runs the same chain verification the local verifier runs over exactly those bytes. It splits those bytes into batches of at most 8 MiB, compresses and encrypts each batch with a key held for that one chain, and pushes it. That chain key is itself encrypted to the admin keys the machine pinned when it enrolled. The vault sees encrypted batches, hashes and timestamps, never the entries themselves.

`vault` is pure — no sockets, no files, no clock — so the server can build on the same module, and the two cannot end up disagreeing about the format on the wire. `ship` is the part that holds the files and the network. Nothing in the hook path depends on either, and `nd7 record` never opens a socket. See [Ship sessions to a vault you run](/docs/vault).

## Session layout on disk

```
$XDG_STATE_HOME/nd7/                 (default ~/.local/state/nd7)
├── sessions/
│   └── <session_id>/
│       ├── events.ndjson            append-only event log, one entry per line
│       ├── head                     "<seq> <hash>\n", chain head for fast append
│       ├── lock                     empty, the advisory-lock target
│       ├── shipped                  "<seq> <hash>\n", last entry the vault acked
│       └── chain.key                0600, present while the chain is open
└── vault/                           only on an enrolled machine, all 0600
    ├── machine.key                  Ed25519 seed, signs every request
    ├── machine.id                   vault-assigned id
    ├── server                       base URL
    └── recipients                   pinned admin public keys, signed
```

Per-session files rather than one big log, because the session is what you read, ship and delete as a unit. Keeping it that way makes it obvious what a retention rule or an upload covers, and it means two sessions running at once never wait on each other. The files go in the *state* directory of the XDG Base Directory specification rather than the data or cache one. What they hold is machine-local state that should survive a reboot, and it is neither data a person wrote nor something safe to throw away.

The sandbox's own records are separate, at `~/.nd7/sessions/<pid>/`, and are short-lived.

## Trust boundaries

**Recording** has almost nothing to defend: it runs as you, reads what Claude Code pipes to it, and writes a file you own. Its only defensive rules are about not interfering — always exit 0, never print to standard output, never tell Claude Code to block or change anything — and about the log's own integrity. Each entry describes itself; absolute paths are stored exactly as they arrived rather than being tidied up; and nothing assumes the log will be read on the machine that wrote it.

**Sandboxing** has a real boundary. It trusts the kernel; the `nd7` and `nd7-exec` binaries and the directory holding them, which `nd7 run` checks at session start to be writable by nobody but you; the session directory under `~/.nd7`, which the profile makes unreadable and unwritable from inside the sandbox; and the walk up the chain of parent processes, which the kernel answers. It does not trust the model, the command text (which it quotes and never interprets), or the caller's arguments, working directory and environment. That last point is why `nd7-exec` takes the home directory from the system's account records rather than from `$HOME`.

**Shipping** trusts the machine to record honestly at the moment it records. The attacker it defends against is someone who rewrites the logs afterwards, or who steals the vault's database. Hence encryption on the machine, to keys the vault does not hold, and a fingerprint pinned at enrolment so that a dishonest vault cannot hand over a key of its own instead.

## Why two binaries

Two reasons, and they are different reasons.

The **library** split is ordinary. Every future producer of entries — a Codex adapter, the kernel collector — and every reader needs the same writer and the same event model. One Cargo binary cannot import another, so the shared code lives in `src/lib.rs`, and adding a tool is one more `[[bin]]` entry.

The **`nd7-exec`** split is a security property. The floor allows exactly one exact path to start outside the sandbox. That path has to be a program that does one narrow thing, takes nothing from the caller beyond the command text, and cannot be argued into applying different rules. Making it a subcommand of `nd7` would mean letting the whole of that command line out of the sandbox. Keeping it separate also gives the check at session start something to check: `nd7 run` verifies that this one file, and the directory holding it, are writable by nobody but you.

## Why hooks first

[ADR-0001](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0001-start-with-hooks-captured-intent-kernel-effects-later). What the kernel observes is the stronger evidence, but collecting it needs special entitlements or root, a long-running daemon, separate code per operating system, and a way of working out which process did what. Capturing intent through hooks needs a settings entry and a binary that reads standard input. Starting with hooks meant a readable session within days, on every platform Claude Code runs on, and it forced the decisions about schema and storage early, against real data rather than against a guess.

The cost is that every claim has to be qualified — the log says what the agent *said*, not what happened — which is why `source` names the kind of evidence in every entry, and why the verifier prints its caveat. Kernel-observed effects will land in the same log with `source: effect:*` and no change of format, because the fields needed to match them up with intent entries (`ts`, `cwd`, `argv`, `paths`, `hook_ppid`, `tool_use_id`, `host`) are stored from the first entry onwards.

Three things the design deliberately avoids for the same reason: field names specific to one agent in the envelope, which is the set of fields every entry shares; assuming that only one process ever appends to a session, which is why there is a lock and a `head` file; and assuming the log is read on the machine that wrote it.
