Specify M3a (the decision path) and write the runbook
Split M3 into M3a and M3b, record the M3 design decisions, propose P13 (tool image built by Nix, named by digest), and add docs/runbook.md with an entry for every fail-closed state, including loopd's existing ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+299
@@ -0,0 +1,299 @@
|
||||
# Runbook
|
||||
|
||||
One entry for every state in which Boxmaker refuses to work or holds something back, and for
|
||||
events worth understanding when you see them. Every message for such a state ends with
|
||||
`see docs/runbook.md#<entry>`; a gate check makes sure the entry exists.
|
||||
|
||||
Each entry says what you see, why the system refuses, how to confirm, how to fix, and how to check
|
||||
the fix. Commands assume:
|
||||
|
||||
- `$BOXMAKER_HOME` is the harness home: `/var/lib/boxmaker` when deployed, `.state` in the
|
||||
repository during development.
|
||||
- `$GRANTS` is `[paths] grants` from `brokerd.toml`: `/etc/boxmaker/grants` when deployed.
|
||||
- Until M7 there are no service units: the daemons run in a terminal, and "stop" means Ctrl-C or
|
||||
`pkill -x <name>`.
|
||||
|
||||
Entries for M3a states are written ahead of the code and describe it as specified in
|
||||
`docs/specs/2026-09-18-m3a-decision-path.md`.
|
||||
|
||||
## grants-invalid
|
||||
|
||||
**What you see.** `brokerd` prints one or more lines naming a grant file and a problem, then this
|
||||
entry. Every tool call is denied, and the model tells you "the grant files have an error".
|
||||
|
||||
**Why.** One grant file is invalid, so `brokerd` denies everything until it is fixed. It cannot
|
||||
skip the bad file: if the bad file was a `deny` grant, skipping it would allow what it was written
|
||||
to forbid.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
bxctl grants check
|
||||
```
|
||||
|
||||
It prints each problem as `<file>:<line>: <problem>`. Common ones: a misspelled field (unknown
|
||||
fields are errors); `secret` set (not supported until M4); `patterns` set (not supported); `hosts`
|
||||
on a file tool or `paths` on `http_fetch`; a relative path or one with `..`, `.` or `//`; a host
|
||||
with uppercase letters, a port, or an IP address; a file name with characters outside `[a-z0-9-]`.
|
||||
|
||||
**Fix.** Edit the file, or move it out of `$GRANTS` while you work on it:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/grants-parked && mv "$GRANTS/<file>.toml" ~/grants-parked/
|
||||
```
|
||||
|
||||
Do not park a `deny` grant unless you are sure nothing else would then allow what it forbids.
|
||||
|
||||
**Check.** `bxctl grants check` prints `grants: ok`. No restart is needed: `brokerd` reads the
|
||||
grants again at the next call.
|
||||
|
||||
## audit-unavailable
|
||||
|
||||
**What you see.** `brokerd` prints an error writing to `$BOXMAKER_HOME/audit/`, then this entry.
|
||||
Tool calls are denied, and the model says "the audit log cannot be written".
|
||||
|
||||
**Why.** A call runs only after its decision is on disk. If the record cannot be written, nothing
|
||||
runs.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
df -h "$BOXMAKER_HOME"
|
||||
ls -ld "$BOXMAKER_HOME/audit"; ls -l "$BOXMAKER_HOME/audit" | tail -3
|
||||
```
|
||||
|
||||
Look for a full disk, a directory or file not owned by the user `brokerd` runs as, or a read-only
|
||||
file system.
|
||||
|
||||
**Fix.** Free space or correct ownership (`chown` to the `brokerd` user; files are mode 0600, the
|
||||
directory 0700). Do not edit, move or delete audit files to make space: that breaks the chain.
|
||||
|
||||
**Check.** Ask for any tool call. It is decided normally, and `bxctl audit verify` prints
|
||||
`audit: ok`. If `brokerd` keeps failing after the cause is gone, stop and start it.
|
||||
|
||||
## audit-chain-broken
|
||||
|
||||
**What you see.** `brokerd` refuses to start and prints `<file>:<line>: <what>`, then this entry.
|
||||
Or `bxctl audit verify` prints the same and exits 1.
|
||||
|
||||
**Why.** A record does not parse, its `seq` is not the next number, its `prev` is not the hash of
|
||||
the line before, or a file does not continue from the one before it. Something changed the log
|
||||
after it was written: an edit, a deleted or reordered line, a file restored from a backup, a
|
||||
partial copy. `brokerd` will not add records after a history it cannot vouch for.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
bxctl audit verify
|
||||
sed -n '<line-1>,<line+1>p' "$BOXMAKER_HOME/audit/<file>"
|
||||
```
|
||||
|
||||
Work out what happened before going on: a restore, a manual edit, a disk problem, or none you know
|
||||
of. If none you know of, treat it as a possible intrusion and keep the files as they are.
|
||||
|
||||
**Fix.** Keep a copy first, then accept the break explicitly:
|
||||
|
||||
```sh
|
||||
cp -a "$BOXMAKER_HOME/audit" ~/audit-copy-$(date +%F)
|
||||
brokerd serve --config <path> --accept-break
|
||||
```
|
||||
|
||||
`brokerd` appends an `accepted_break` record naming the file and line, and continues from there.
|
||||
Nothing is repaired or deleted. The break stays visible in every later verification.
|
||||
|
||||
**Check.** `bxctl audit verify` prints `audit: ok` and lists the accepted break with its file and
|
||||
line. A second failure later in the log needs its own accepted break.
|
||||
|
||||
## audit-recovered
|
||||
|
||||
**What you see.** `brokerd` prints "audit: recovered a torn final line" at start, then this entry.
|
||||
It starts normally.
|
||||
|
||||
**Why.** The last record was being written when `brokerd` or the machine stopped, so the file ended
|
||||
without a newline. That is expected after a crash, not an error. `brokerd` kept the partial line,
|
||||
ended it, and wrote a `recovery` record holding its length and hash, chained from the last complete
|
||||
record. The partial record was never acted on: records are written before the action they allow.
|
||||
|
||||
**Confirm.** `bxctl audit verify` lists the recovery with its file and line.
|
||||
|
||||
**Fix.** None needed. If recoveries happen often, find out why `brokerd` is being killed.
|
||||
|
||||
**Check.** `bxctl audit verify` prints `audit: ok`.
|
||||
|
||||
## brokerd-already-running
|
||||
|
||||
**What you see.** `brokerd` exits at start with "brokerd is already running" and this entry.
|
||||
|
||||
**Why.** Only one `brokerd` may write the audit log. It holds a lock on
|
||||
`$BOXMAKER_HOME/audit/.lock` for as long as it runs, and the lock is released when the process
|
||||
ends, however it ends.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
pgrep -a brokerd
|
||||
```
|
||||
|
||||
**Fix.** Use the running one, or stop it (`pkill -x brokerd`) and start again. Deleting `.lock`
|
||||
does not help and is not needed.
|
||||
|
||||
**Check.** `pgrep -a brokerd` shows one process.
|
||||
|
||||
## broker-state-damaged
|
||||
|
||||
**What you see.** `brokerd` prints an error reading or writing
|
||||
`$BOXMAKER_HOME/broker/sessions/<id>.json`, then this entry. Either every call for that session is
|
||||
denied ("this session's broker state is damaged"), or one call failed with "the result could not
|
||||
be recorded".
|
||||
|
||||
**Why.** The file holds the session's taint and untrusted flag. If `brokerd` cannot read it, it
|
||||
does not know how sensitive the session's data is, so it denies. If it cannot write it after a
|
||||
tool ran, it withholds the result: content that raised the taint must not reach the model unless
|
||||
the raised taint is on disk.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
ls -l "$BOXMAKER_HOME/broker/sessions/<id>.json"*
|
||||
cat "$BOXMAKER_HOME/broker/sessions/<id>.json"
|
||||
df -h "$BOXMAKER_HOME"
|
||||
```
|
||||
|
||||
A good file is one line such as `{"taint":"private","untrusted":false}`.
|
||||
|
||||
**Fix.** For a write failure, fix space or ownership as in
|
||||
[audit-unavailable](#audit-unavailable). For a damaged file, do not guess low: set it from the
|
||||
session's results. The session's audit `result` records show the highest `taint_after`, and any
|
||||
result from an untrusted grant means `"untrusted":true`. If unsure, write
|
||||
`{"taint":"secret","untrusted":true}`, which is always safe. The session only loses access to
|
||||
grants with a lower `max_taint`. A `.tmp` file beside it is a write that did not finish and can
|
||||
be removed.
|
||||
|
||||
**Check.** The next call for the session is decided normally.
|
||||
|
||||
## socket-forbidden
|
||||
|
||||
**What you see.** `brokerd` prints that a message of some kind arrived on the wrong socket, then
|
||||
this entry.
|
||||
|
||||
**Why.** `broker.sock` accepts only tool requests from `loopd`; `admin.sock` accepts only admin
|
||||
requests from `bxctl`. Nothing in the harness sends a wrong kind, so this is a bug or a component
|
||||
doing what it should not, such as something trying to approve through the tool socket.
|
||||
|
||||
**Confirm.** Note the time, kind and socket. Check what was running: `pgrep -a 'loopd|bxctl'`.
|
||||
Look at the session logs active at that time.
|
||||
|
||||
**Fix.** If a harness version mismatch explains it (a new `bxctl` against an old `brokerd`),
|
||||
rebuild and restart both. Otherwise keep the logs, stop `loopd`, and investigate before running it
|
||||
again.
|
||||
|
||||
**Check.** No further messages of this kind.
|
||||
|
||||
## broker-unavailable
|
||||
|
||||
**What you see.** `loopd` prints that it cannot reach `broker.sock`, then this entry. Tool calls
|
||||
fail with "the tool broker is unavailable", the model says so, and the conversation goes on.
|
||||
|
||||
**Why.** `brokerd` is not running, was restarted during a call, or `loopd`'s `[broker] socket`
|
||||
points somewhere else. Pending approvals are lost when `brokerd` restarts. `bxctl audit verify`
|
||||
lists them as abandoned.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
pgrep -a brokerd
|
||||
ls -l "$BOXMAKER_HOME/run/loop-broker/"
|
||||
```
|
||||
|
||||
Compare the socket path with `[broker] socket` in `loopd`'s config and `[sockets] broker` in
|
||||
`brokerd.toml`.
|
||||
|
||||
**Fix.** Start `brokerd`, or make the two paths agree. `loopd` needs no restart: it connects per
|
||||
call.
|
||||
|
||||
**Check.** Ask the model to use a tool. The call is decided (allowed, asked or denied) instead of
|
||||
failing.
|
||||
|
||||
## loopd-selftest-failed
|
||||
|
||||
**What you see.** `loopd` prints `selftest: FAILED: <reason>` and this entry, and exits 1.
|
||||
|
||||
**Why.** `loopd` will not start against a server that differs from the one its config describes,
|
||||
that does not parse tool calls, or whose cache does not work.
|
||||
|
||||
**Confirm and fix, by reason.**
|
||||
|
||||
- `chat template sha256: expected …, got …`, `context per slot: …` or `slot count: …`. The server
|
||||
changed: a model update, or new flags in `~/src/nixos/hw/straylight/default.nix`. Check what
|
||||
changed on straylight. If the change is intended, update `[expect]` in `loopd`'s config:
|
||||
|
||||
```sh
|
||||
curl -s 'http://straylight:11434/props?model=ornith-1.5-35b-a3b' | jq -j .chat_template | sha256sum
|
||||
curl -s 'http://straylight:11434/props?model=ornith-1.5-35b-a3b' | jq '.default_generation_settings.n_ctx, .total_slots'
|
||||
```
|
||||
|
||||
A template change also invalidates the M0 measurements. Re-run the checks in
|
||||
`docs/inference-contract.md` before trusting the cache behaviour.
|
||||
- `the tool call did not come back parsed: …`. The template or the server's tool parser changed.
|
||||
Treat it like a template change. Do not start `loopd` until tool calls parse again.
|
||||
- `turn 2 did not reuse turn 1's cache: …`. Another client may have taken the slot between the two
|
||||
requests. Check `curl -s 'http://straylight:11434/slots?model=ornith-1.5-35b-a3b'` and run
|
||||
`loopd selftest --config <path>` again. If it fails twice with the slot otherwise idle, the cache
|
||||
is not working: check the server flags for `cache_prompt` and the KV settings.
|
||||
- A connection, timeout or HTTP error. Check that `inferproxy` is running and that its upstream
|
||||
answers: `curl -s http://straylight:11434/health`. A model load can take tens of seconds, so
|
||||
retry once.
|
||||
|
||||
**Check.** `loopd selftest --config <path>` prints `selftest: ok`.
|
||||
|
||||
## session-log-damaged
|
||||
|
||||
**What you see.** A turn on an existing session fails with `<file>:<line>: <reason>` naming
|
||||
`sessions/<id>/<epoch>.jsonl`, and this entry. Other sessions work.
|
||||
|
||||
**Why.** `loopd` replays the log exactly to rebuild the conversation, so a line it cannot read
|
||||
would change what the model sees. It refuses rather than guess. `last line does not end in a
|
||||
newline` means a write was cut off: `loopd` writes a record only after the request it describes
|
||||
has finished, so a cut-off line was never used.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
f="$BOXMAKER_HOME/sessions/<id>/0.jsonl"
|
||||
wc -l "$f"; tail -c 300 "$f"
|
||||
```
|
||||
|
||||
**Fix.** Keep a copy first. Then either start a new session (always safe), or, for a cut-off last
|
||||
line only, drop it:
|
||||
|
||||
```sh
|
||||
cp -a "$f" "$f.damaged-$(date +%s)"
|
||||
head -n <line-1> "$f.damaged-"* > "$f" # keep the complete lines before the damaged one
|
||||
```
|
||||
|
||||
Anything else (a damaged line in the middle, an unknown record type) means the file was changed
|
||||
by something other than `loopd`. Start a new session and keep the old log for inspection.
|
||||
|
||||
**Check.** The next turn on the session succeeds. The first request re-reads the conversation if
|
||||
the cache was lost, which `loopd` logs as a cache loss.
|
||||
|
||||
## core-memory-unreadable
|
||||
|
||||
**What you see.** Starting a new session fails with an error naming `memory/core.md`, and this
|
||||
entry. Existing sessions still work: they use the baseline saved when they started.
|
||||
|
||||
**Why.** `memory/core.md` exists but cannot be read. A missing file is fine, but starting without
|
||||
memory you wrote, and without telling you, is not.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
ls -l "$BOXMAKER_HOME/memory/core.md"; head -c 100 "$BOXMAKER_HOME/memory/core.md"
|
||||
```
|
||||
|
||||
Look for wrong ownership or mode, or a file that is not UTF-8 text.
|
||||
|
||||
**Fix.** Make it readable by the user `loopd` runs as (`chmod 0600` and `chown` to that user), or
|
||||
save it again as UTF-8.
|
||||
|
||||
**Check.** A new session starts, and its `0.baseline.json` contains the text.
|
||||
Reference in New Issue
Block a user