---
title: Design decisions (ADRs)
description: A plain summary of each architecture decision record in nd7-core, with what was decided and why.
order: 130
section: Reference
---

nd7 keeps a decision log: one entry per settled decision, newest last, never edited once accepted. Each entry is an architecture decision record, or ADR, which is a short note saying what was decided, when, and why. A decision that turns out wrong is replaced by a later entry saying so, rather than rewritten in place. This page summarises each record in one paragraph and links to the original. If you want the alternatives that were rejected and the measurements behind them, follow the links: the originals are longer and more precise than these summaries.

The full log is [docs/DECISIONS.md](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md).

## ADR-0001: Start with hooks-captured intent, kernel effects later

*2026-09-17, accepted.* The strongest evidence about what an agent did is what the kernel observed; the weakest is the agent's own account of what it meant to do. Collecting from the kernel needs special entitlements or root, a long-running background process, separate code per operating system, and a way of working out which process did what. Collecting from hooks needs a settings entry and a program that reads standard input. **The decision:** record intent only, from Claude Code hooks, but write it in the *final* log format. Every entry names the kind of evidence it is in a `source` field, and stores the fields that kernel evidence will later be matched against (timestamp, working directory, argv, paths, parent pid, tool use id, host), so that entries about observed effects can be added without changing the format. The cost, accepted openly, is that every claim has to be qualified: the log and its reader must say "intent, not effect", or people will trust it further than they should.

[Read it](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0001-start-with-hooks-captured-intent-kernel-effects-later)

## ADR-0002: Phase 1 hook set, frame writer and registration form

*2026-09-18, accepted.* Four questions were blocking the first entries. **The decision:** register for `PostToolUseFailure` as well as `PostToolUse`, and record it as a `tool_result` with `ok: false`. That is necessary because a successful response carries no exit code, so failures would otherwise be invisible. Record `Stop` as the end of a turn by default, because that is what lets a reader group a turn's tool calls together. Write entries as NDJSON, newline-delimited JSON, one object per line, with a packed binary container produced from the same bytes as the stated plan for later. And register the hook in exec form, a program name plus arguments, rather than as a shell command line. The shell wrapper was measured at about 6 ms per hook, and it makes the recorded parent process id the shell's rather than the agent's, which destroys a field later work needs for matching.

[Read it](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0002-phase-1-hook-set-frame-writer-and-registration-form)

## ADR-0003: Library plus thin binaries, no async runtime, no daemon

*2026-09-18, accepted.* The first binary used an async runtime and a spawned thread to do one blocking read and one file append. Measured on the development laptop: starting any process at all costs about 3.6–3.9 ms, a shell wrapper adds about 6 ms, nd7's own startup and parse about 3 ms, and the log append 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 binary. **The decision:** a library crate with thin binaries, plain blocking input and output in the hook path, no async runtime, and no daemon. A daemon could save at most the 1 ms append, while adding problems of its own: keeping the process alive, surviving a crash without losing entries, and version skew, where the running process keeps old code in memory and so would break the rebuild-and-go development workflow. If a future single-writer daemon is ever wanted, the place to put it is the log type itself, should the Phase 2 collector make one natural.

[Read it](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0003-library-plus-thin-binaries-no-async-runtime-no-daemon)

## ADR-0004: One binary, `nd7`, with `record` as the hook command

*2026-09-19, accepted; refines the naming in ADR-0002 and ADR-0003.* The first binary was called `nd7audit`, installed on the path as `nd7` through a symlink, with `hook` as its only subcommand. Once verifying and reading were in sight, it was clear that there is one tool people use, not several, and that `hook` described how Claude Code calls nd7 rather than what nd7 does. **The decision:** the binary is `nd7` and its subcommands are verbs — `record`, `verify`, and the rest. A program of a different kind arriving later, such as the Phase 2 collector daemon, gets its own name rather than becoming another face of this command line. `cargo install` now yields `nd7` directly, and the install instructions lose a symlink step.

[Read it](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0004-one-binary-nd7-with-record-as-the-hook-command)

## ADR-0005: Ship logs to a vault the machine encrypts for, not the vault

*2026-09-19, **proposed** — the one record not yet marked accepted.* A hash chain proves a log is unchanged only if whoever checks it trusts the chain's head; anyone who can write the file can rewrite the chain. A copy on a server the agent cannot reach turns the chain into evidence. But entries hold everything the agent saw, including secrets, so a stolen server or a dishonest operator must yield nothing readable. **The decision:** `nd7 ship` gathers each session's unshipped entries into batches, verifies them on the machine, compresses them, and encrypts each batch with a key held for that one chain. That chain key is itself encrypted to the public keys of the organisation's admins. The server stores the encrypted batches plus a clear index of sequence numbers, hashes and timestamps. It checks only that the chain links up, and never holds a key that decrypts anything. There is no server master key and no per-organisation key: with one of those, a dumped database plus that one key would be total loss. The honest consequence is that an admin key opens everything in its organisation — that is what an admin is — with the damage limited by hardware-backed keys, few admins, and new chain keys whenever a leak is suspected.

[Read it](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0005-ship-logs-to-a-vault-the-machine-encrypts-for-not-the-vault)

## ADR-0006: Store frame content verbatim, and leave two events unregistered

*2026-09-19, accepted.* `Write` and `Edit` inputs carry whole files, `Read` responses carry file contents, and `Bash` responses carry a full diff of every file the command touched. There were three options: store all of that exactly as it arrives, cut it short above some size and keep a hash of the rest, or hash the content fields nd7 knows about. Real numbers settled it. A 116-entry session of about an hour and a half came to 365 KB, with a median entry of 1.5 KB and a largest of 49.7 KB. The largest session recorded since was 1487 entries over about ten hours, and 4.8 MB uncompressed. **The decision:** store content exactly as it arrives. A few megabytes per working day is not a problem worth losing information over, and the diffs are the closest thing Phase 1 has to evidence of what actually happened, already computed for free. Separately, leave two noisy events out of the default registration: `MessageDisplay`, which is assistant text as it streams, and `PostToolBatch`, which repeats responses already recorded. Together they were 702 of those 1487 entries. The consequence, stated plainly, is that entries hold secrets unencrypted on disk, which is the premise the vault's encryption is designed around.

[Read it](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0006-store-frame-content-verbatim-and-leave-two-events-unregistered)

## ADR-0007: Sandbox Claude Code with one Seatbelt floor and a trampoline, and refuse every other profile

*2026-09-21, accepted.* macOS applies one sandbox profile, meaning one set of rules the kernel enforces, to a process and everything it starts. It refuses any attempt by a process already inside a sandbox to apply a different profile. Looser, tighter, a superset, a subset, or the same rules with other parameters all fail. Only a profile that means exactly the same thing is accepted, and applying it does nothing. Two consequences were measured. A session's boundary cannot change while the session runs. And Claude Code's own per-command sandbox cannot start inside an outer profile: depending on the outer rules, it either failed every Bash call with exit code 71 or silently switched itself off. Three facts made a design possible anyway. One `exec` of a literal path can be marked so that the program it starts is outside the sandbox. A `PreToolUse` hook can rewrite a Bash command and force Claude Code to allow it. And a process can read its own chain of parent process ids from inside the sandbox, so a helper can find its session by looking up that chain, which a caller cannot fake.

**The decision:** `nd7 run` applies one profile — the floor, the baseline rules for the whole session — to the agent and everything it starts, with exactly one way out: the `nd7-exec` binary sitting next to `nd7`. A hook rewrites every Bash command to run through `nd7-exec`. That program finds its session by looking up the chain of parent processes, applies that session's current rules to itself, and becomes the shell. It takes nothing from its arguments, its working directory or its environment, and it refuses to run at all if it cannot find a session and its rules. The kernel refuses every other profile, and Claude Code's own sandbox is turned off for the session so that the refusal costs nothing. `nd7 allow` and `nd7 deny` edit the session's rules, and the next command sees them; there is no daemon and no socket.

Three alternatives were measured and rejected. One let the system's own sandbox tool out through a second exit: it worked, but left commands running under Claude Code's profile, with nd7 no longer the outermost sandbox. Another was an interposer, code inserted to intercept Claude Code's own starting of processes: that worked end to end too, but it depends on how Claude Code spawns processes, which is not a stable interface. The consequences are stated just as clearly. Nothing inside can loosen the sandbox, and nothing depends on how Claude Code handles processes. If the hook stops being applied, commands run under the floor, which is never wider, so the failure is closed rather than open. Claude Code's own sandbox settings have no effect, and filtering by hostname is lost. And `Write` and `Edit`, which run inside the agent process itself, are out of `nd7 allow`'s reach.

[Read it](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0007-sandbox-claude-code-with-one-seatbelt-floor-and-a-trampoline-and-refuse-every-other-profile)

## Related

- [Architecture](/docs/architecture): what these decisions built.
- [How the macOS sandbox works](/docs/sandbox): ADR-0007 in operation.
- [The log format](/docs/log-format): ADR-0002 and ADR-0006 in the file.
- [Ship sessions to a vault you run](/docs/vault): ADR-0005 in operation.
