# Decisions Newest first. A decision that changes `docs/design.md` lands in the same commit as the change. ## Decided | Date | Decision | Reason | |---|---|---| | 2026-09-27 | M5b settles three open items. An invalid-arguments denial carries its reason (`ToolResponse::Denied.detail`, one line, 300 characters at most). `loopd` neutralises the model's chat-template tags when text from outside is first logged (user messages, tool results after their cap, the memory block), checked against the tokenizer by the self-test in two `/tokenize` requests. The secret store is `bxcore::secrets`, behind the `secrets` feature, for every role but `loopd`. | M5b spec, sections 4, 5 and 8; landed 2026-09-27, device-checked on straylight (review in `docs/implementer-log.md`). The self-test's per-tag requests overran `inferproxy`'s burst on the device and were batched (review finding 10). | | 2026-09-27 | The two driver trials of M5b's run, judged: `RESUME` stays, and now also resumes a session stopped at the time limit, the case where it was needed (task 10); `SLOTS_URL` stays, though it never fired. Sessions run with `opencode run --pure` and `OPENCODE_DISABLE_CLAUDE_CODE=1` by default (`PLUGINS=1` restores both), and the prompt names the log row. | The run's evidence (implementer lessons T47, T48): global plugins rewrote the system prompt mid-session and cost 76 of 111 minutes of prompt processing in tasks 01 to 10; task 10 stopped at the limit with its work uncommitted; task 14's row took its name from the heading. `89a50bc`. | | 2026-09-26 | `proto` becomes `bxcore`, the one shared crate: it keeps everything `proto` held and gains `secrets` (behind a Cargo feature that `loopd` never enables) and `sanitize`; later shared code goes there too. The rule becomes: every role depends on `bxcore` and on no other workspace crate, and `bxcore` depends on no workspace crate. The rename is made by changing the crate and manifests and fixing what the build reports, not by search-and-replace. | Owner's decision (M5b design). A second shared crate for secrets was the alternative; one crate keeps the rule simple. Not `core`, which shadows Rust's own crate (checked). A spike found the four layers the rename touches (compiler, formatting, fixture path strings, the crate-dependency check); M5b spec, section 3. | | 2026-09-26 | Every commit holding work by Ornith or another local model ends with `Co-Authored-By: `, the model named as in `OPENCODE_MODEL` after the provider (for example `ornith-1.5-35b-a3b`). `tools/run-plan.sh` puts the exact line in the session prompt and stops a run whose commit lacks it; the log's Model column is filled the same way. When the design model commits a local model's work, it adds that line beside its own. It replaces the `Implemented-By` trailer. | Owner's rule, for tracking which model wrote what across the owner's repositories. The implementer often cannot know its own model, so the driver supplies the name. `f98ec2a`. | | 2026-09-26 | Boxmaker adopts the combined Ornith guidance (`~/projects/ornith/GUIDANCE.md`, from this repository's `ASSESSMENT.md` and two others'): in `AGENTS.md`, experiments under `.state/scratch/`, a refused tool call does not end a turn, one `todo!()` at a time, the gate's 600000 ms timeout, commit messages by `git commit -F` (`0356c87`); a project `opencode.json` with an `implement` agent whose permissions deny reads outside the repository, edits to the plans and design documents, subagents, web access and destructive git commands (`31fe078`); in the driver, a 90-minute session limit and a message for a `stopped` row (`411d9e0`); a pre-handover checklist, `docs/plans/CHECKLIST.md` (`224617e`); and in task files, environment facts and pasted contracts. Two driver guards are trials, switched on for M5b's run and judged at its review: `RESUME=1` (one resume when a session leaves work uncommitted) and `SLOTS_URL` (stop a session when no llama slot has been busy for 10 minutes). Not adopted yet: a verdict classifier, the spec-extraction pipeline (for M5c's Honcho contract, if needed), smaller `tool_output` limits and a custom agent prompt. | Owner's decision. Each adopted item answers a recorded failure in one of the three projects; the trials and the omissions are the guide's own untested items, taken one at a time so their effect can be seen. OpenCode settings were checked against its 1.18.30 schema and permission docs. | | 2026-09-25 | P24, Milestones: M5b splits. M5b is hardening (the sanitizer, tag neutralisation in `loopd`, image links escaped in `gatewayd`, the invalid-arguments reason, and the secret store made available to every role, which the Honcho keys of M5c will need), which needs neither `roncho` nor Honcho and closes a gap open since M3. Honcho becomes M5c, planned once `roncho` has a tagged release. The scheduler and compaction move from M5c to M5d. Documents and decision rows written before this date use the old numbers. | Owner's decision, 2026-09-25. The hardening can be specified and run now; the Honcho half waits on `roncho` (`docs/specs/2026-09-25-roncho-requirements.md`). | | 2026-09-25 | `memoryd` reaches Honcho through the owner's `roncho`, not a client of its own. `roncho` first gains a blocking client with a small dependency graph (no async runtime), no default server, no hidden environment settings or redirects, and request shapes checked against rift; the requirements are `docs/specs/2026-09-25-roncho-requirements.md`, handed to `roncho`'s developer. Boxmaker will allow that one git source in `deny.toml`, pinned by tag. | Owner's decision (M5b design). One client for the fleet. As it stands `roncho` is async only (tokio, reqwest), defaults to `api.honcho.dev`, and differs from rift in its search shapes, so it cannot be used unchanged. | | 2026-09-24 | M5a's check on memory writes stays short and fixed: control characters, invisible characters, chat-template markup, six phrases. A research thread on prompt-injection defences feeds a sanitizer module for memory writes (and possibly other persisted text), built as hardening in M5b. Memory writes are not posted to the chat; `bxctl memory log` lists them. | Owner. The archive routing of untrusted sessions' writes is the main defence; a pattern list is defence in depth and should rest on research, not guesses. | | 2026-09-24 | Memory writes need no approval, as in Hermes (the owner's `~/.hermes` has no `write_approval`). What a session that has seen untrusted content wants remembered becomes a note, labelled untrusted, not identity in the prompt (refined the same day: identity and experience are separate kinds of memory). Every write is scanned, journalled (listed by `bxctl memory log`, not posted anywhere) and can be undone; nothing is deleted. Brief change P21 proposed; the M5a spec is revised to match. Session-scoped approvals for other tools become an open decision. | Owner: an agent harness should act without a stream of questions, and reading and writing memory is what an agent is. The only risk the approval guarded is injected text reaching every future prompt, and that needs a session that read untrusted content; `brokerd` knows which sessions those are. | | 2026-09-24 | M5a spec review: the policy of `recall` (`auto`) and `memory` (`ask`) is built into `brokerd`, and grant files cannot name them; tool calls are indexed by name and arguments, tool results are not; summaries of tool output, where a tool opts in, come with M5c's summariser. | Owner: an agent that cannot write memories is no better than a plain chat window, and a grant file must not be able to turn edits off or make them unattended. A web fetch's summary is worth keeping; most tools' output is not. | | 2026-09-24 | Compaction (new epochs) comes with the scheduler in M5c, not in M5a. Until then a session has one epoch, and M5a's rules say "the next session" where the brief says "the next epoch". `soul.md` starts from a generic file in the repository; `memory.md` and `user.md` start empty. M5a spec drafted: `docs/specs/2026-09-24-m5a-local-memory.md`. | Owner. `loopd` has no epochs yet (every session is epoch 0; a full context is `SessionFull`); compaction is an idle-time job on the background slot, which is what M5c builds. Over Mattermost each thread is a session, so a new thread already picks up approved edits. | | 2026-09-24 | M5 direction, before a spec. Memory is three local files and Honcho. The files: `memory/soul.md` (owner-written), `memory/memory.md` and `memory/user.md` (agent-edited, `ask`), read once per epoch, as Hermes does. Honcho, the fleet's memory service on rift (v3.2.0; models on dixie), reached by a new role, `memoryd`, which also owns the memory files and the SQLite index (owner, same day): each Boxmaker writes to its own workspace, only turns from sessions that saw no untrusted content, and can read other workspaces such as `hermes-fleet`, each with an owner-set trust level (untrusted by default). Local full-text search over the session logs stays. `consolidate` moves entries out of `memory.md` into `memory/archive.md` (searched by `recall`) and, from M5b, into Honcho. From M5c a scheduled job on the background slot proposes which entries stay at which tier, from counts of `recall` hits that `memoryd` keeps from M5a; its proposals are ordinary `ask` edits. The owner can mark any workspace trusted or untrusted; which ones is configuration, not design (the owner trusts the Hermes agents' workspace today). M5 splits into M5a (files and search, no network), M5b (`memoryd`, Honcho through the owner's `roncho` client) and M5c (the scheduler). Brief changes P17 to P20 proposed. | Owner, 2026-09-24: Honcho lets several Boxmakers share memory across the fleet; the Hermes agents do Boxmaker's job today and may be trusted. `brokerd` never holds conversation history and `loopd` has no network, so syncing turns needs a role of its own. Honcho's derivation replaces the brief's notes, pre-compaction flush and embedding index. | | 2026-09-24 | M4b, changing the agreed outline (row of 2026-09-23): no reactions, an approval is answered only by `!approve ` or `!deny [reason]` in its thread; an `[approvals] users` list in `gatewayd.toml`, which falls back to `allow.users` when empty; approvals are posted in the asking thread, channel threads included; on restart, `gatewayd` marks its old approval posts dead and `brokerd`'s courier posts pending approvals again. Spec `docs/specs/2026-09-24-m4b-approvals.md`. Brief change P16 proposed. Also: `gatewayd` strips a leading mention of its own bot from every message before handling it; in a thread shared by several Boxmakers the operator addresses approval commands, and a bare one is taken by every Boxmaker with that id pending there. | Owner: reactions are not used. Approvers are separate because an agent allowed to talk to Boxmaker should not be able to approve for it, but the default keeps one list for one person. Redacting secrets from arguments comes with the tools work. A restart must not leave posts that look answerable and are not. The mention is how the operator chooses the bot; the frontend removes it so neither the commands nor the model see it. | | 2026-09-23 | M4a spec approved with two changes: a secret read from a file prints a warning at startup (and still starts); and in allowed channels and group messages shared with other agents, Boxmaker answers a post only if it names this bot, or it is a reply in one of this Boxmaker's threads that names no other user (`@channel`, `@here`, `@all` name nobody). One Boxmaker per machine, each with its own bot account; nothing is shared between them. | Owner's notes. The owner runs several agents in channels, possibly one Boxmaker per machine. A post's `from_bot` property is user-settable in v11.11.0, so bots are told apart by the allowlist, not by that property. | | 2026-09-23 | M4 is split: M4a is conversations over Mattermost (`gatewayd`, the secret store), M4b is approvals over Mattermost. Spec `docs/specs/2026-09-23-m4a-gateway.md`. | As with M2 and M3: the authority-bearing half is reviewed separately, and the open question about long approvals lands in M4b. | | 2026-09-23 | `gatewayd` reaches Mattermost over TLS, by default at the tailnet name `https://straylight.scylla-hammerhead.ts.net`; plain HTTP stays possible by URL. TLS is `rustls` 0.23 with `ring` and `rustls-native-certs` (the host's trust store, plus an optional CA file); `deny.toml` gains ISC and BSD-3-Clause. | Owner: portability, and a bigger picture that favours the tailnet name over host loopback. The host's store keeps a privately installed CA trusted; `webpki-roots` would not, and `aws-lc-rs` is a larger C build for nothing needed. | | 2026-09-23 | Our own blocking WebSocket client (RFC 6455), adversarially tested; SHA-1 for its handshake check lives in `proto`. | `tungstenite` would add about ten crates for a few hundred lines of protocol. | | 2026-09-23 | Secrets: one `SecretStore`, three backends chosen per secret: a systemd credential (encrypted at rest to the TPM and host key), an environment variable, or an owner-only file. Proposed as brief change P15. | Owner: a generic backend for portability. systemd 260 on straylight encrypts user-scoped credentials to its TPM (checked). | | 2026-09-23 | In Mattermost: a thread is a session, and a top-level message starts a new one. Messages that arrive while a turn runs are sent together as the next turn. No thinking and no status in Mattermost: Boxmaker shows as typing while it works, then posts the answer. A message starting with `!` is a command to Boxmaker and never reaches the model (`!!` escapes it); M4's commands are `!approve ` and `!deny [reason]`. | Owner's choices. `/` cannot be used: Mattermost's clients treat it as a slash command and do not post it (checked in the web client's source, v11.11.0), and a real slash command would need a listening port. | | 2026-09-23 | M4b, agreed ahead of its spec: one `gateway.sock` connection per approval, open while it is pending; the approval posted in the asking thread or the owner's direct messages; answered only by an allowlisted ✅/❌ reaction on the approval post or `!approve`/`!deny` in its thread, after the post and while pending; long approvals in up to 4 parts with only the last answerable, else `bxctl` only; `bxctl deny` as another name for `refuse`. | Keeps `gatewayd` away from `admin.sock`; a closed connection withdraws the post; a hidden tail of an approval is where injected text would hide. | | 2026-09-22 | M3b spec (`docs/specs/2026-09-22-m3b-runner.md`), draft for the owner's review, written for an overnight run. New: the tools' argument structs and the host rules move to `proto`, shared by `brokerd` and `toolkit`; `toolkit` exits 0/1/2 (done / the tool's own error, shown to the model / misuse, a fixed sentence); the egress proxy refuses any allowed name that resolves to a non-public address (including the tailnet's `100.64/10` and `::/96`); grant paths may not contain `:` or `,`; `[runner]` names the image by digest; tool time limits sit under `loopd`'s 120 s broker timeout, so `shell` defaults to 100 s. | A grant allows a host on the internet, not what its name points at; two programs reading the same data need one definition; `--volume` uses `:` and `,` as separators. The shell limit follows from the M3a deadline in `BrokerPort`. | | 2026-09-22 | M3a review fixes. `loopd` waits at most a day after a pending frame and `brokerd` refuses `[approvals] ttl_ms` over a day. A socket whose directory is `/` or a symbolic link is refused at start. A listener out of file descriptors or memory pauses and retries instead of stopping `brokerd`. `brokerd` and `bxctl` share one rule for audit log file names (`proto::is_audit_log_name`, real months and days only). The fixes were made by the design model, and the fix commits were reviewed by a separate agent before the merge. | A far `expires` parked a turn for ever; `chmod` through a link changed its target; an idle-connection flood could stop the daemon; the two components disagreed about which files were the log. Ornith was under heavy contention. | | 2026-09-18 | M3a plan checks, `brokerd`. Every tool request gets a `Decision` record, including those denied `grants_invalid` or `state_unreadable`; forbidden kinds get none. An unreadable session state is recorded as `secret`, untrusted. A refusal that cannot be recorded is `error internal`, not `ok`. The re-decision's outcome is the matched grant's mode. A pending frame that cannot be sent is handled like a lost connection. Expiry lives in `admin`. A request frame without a read timeout is accepted for M3a. | Found while writing the reference for tasks 10 to 15: the spec left each case open, and `bxctl refuse` would have reported success for a refusal that was not on disk. | | 2026-09-18 | M3a plan checks, audit. `DecisionRecord::Allowed {}` and `Ask {}` are empty struct variants. The resumed verifier accepts a break naming an older file inside a failed region too. When the previous file's last line does not parse, an ordinary start verifies the whole log. A `Recovery` that describes no line is a failure. `abandoned` and `unfinished` both name the decision's `seq`. The report carries what a `Recovery` or `AcceptedBreak` must hold. `--accept-break` with nothing to accept writes nothing. | serde ignores `deny_unknown_fields` on unit variants of an internally tagged enum, so `{"outcome":"allowed","x":1}` decoded; found by `strict.rs` in two areas, and the struct-variant fix was kept over a hand-written `try_from` as the smaller one that also refuses `"reason":null`. As specified, a correctly accepted break could stop every later start while `--accept-break` said "nothing to accept". | | 2026-09-18 | M3a plan checks, policy and `loopd`. A host name's last label starts with a letter. For `write_file` a grant path equal to the argument does not count toward the match. `redecide` returns `Result`, and `Denial` carries the `deny` grant and its hash. `BrokerPort`'s waits are deadlines, not per-read timeouts; the envelope id is `request.call.0`. `approval_pending.tool` and `tool_denied.name` are the target tool, not `call_tool`. `echo` stays in the test registry. | `127.0.0.1` and `127.1` fitted the host grammar, so "no IP literals" was false. A per-read timeout let a trickling peer hold a turn for ever. `call_tool` tells the owner nothing. | | 2026-09-18 | M3a plan checks, `bxctl` and the runbook. A runbook anchor must be written out in the source, and the check fails when it finds no pointer. `--say` shows the approval block and does not ask; `--json` prints only the event and never contacts `brokerd`. Everything the model wrote is escaped, tool names and the final answer included. The approval list shows arguments in `serde_json`'s compact form. | A computed anchor could not be checked. "Print the event only" had no meaning without `--json`. The spec's example contradicted section 6. | | 2026-09-18 | From M3a, a full reference implementation is written only where it still pays: the audit writer and `ChainVerifier` (which also generates the tampering fixtures) and the approval handoff. `policy` is checked by the naive oracle inside its property test. Everything else gets a skeleton: the task's real signatures with `todo!()` bodies, checked to compile against the given tests. Each plan's README records, per task, which check it had and what defect in the spec, task or tests that check exposed; a category that exposes none across a milestone drops a level (reference, skeleton, nothing). Given tests stay for policy, audit and approvals whatever the record says. | In M1 to M2b the reference checked the tasks, not the implementer: it proved tests could pass and signatures compiled (T16), shared the author's blind spots (T5), and caught none of the implementer's defects, which review and probing found. An unsatisfiable test costs one stop-and-report. The tokens are better spent on review. Where a wrong implementation is an authority bypass, the code's author must not also write its acceptance tests. | | 2026-09-18 | Spec review of M3a, approvals. The approval block in `bxctl chat` is fetched from `brokerd` by id, never taken from `loopd`'s event. It shows the parsed arguments serialised again, with control, invisible and direction-changing characters as `\uXXXX`. The owner types the approval id, not `y`. `bxctl chat` escapes model text the same way. A `tool_denied` turn event carries the reason to the owner. | A compromised `loopd` must not choose what the owner approves; `\u002fetc` and `/etc` must look alike; U+202E can make a path read as another; a line already waiting in stdin must never approve; `chat` wrote model text raw, so a conceal sequence could hide the block. | | 2026-09-18 | Spec review of M3a, `brokerd` internals. One lock (the ledger) covers the audit writer and the session state files; the pending table has its own, and whoever removes an entry answers it. After any failed audit write, `brokerd` denies with `audit_unavailable` until restarted. `policy` is pure, and `decide` never returns a `Decision` for an `ask` grant: only `redecide` turns an `Ask` into one. A `RunError`'s text is fixed text, never tool output, and a failure does not raise taint. `BrokerPort` waits `[broker] timeout_ms` for a first frame, and until `expires` plus that after a pending one. | Without the lock two results could lower a session's taint. A partial line followed by more records is a broken chain. `loopd` logs failures as `public`, so tool output in one would be unlabelled. | | 2026-09-18 | Spec review of M3a, policy. A `deny` grant must have `max_taint = "secret"` or the grant set is invalid. A result's `result_class` and `untrusted` are combined over every matching grant (highest class; untrusted if any says so), and within a mode the grant with the longest matched path wins before the id tie-break. A grant path of `/` is invalid. | A `deny` grant with a lower `max_taint` stopped denying once the session read a secret. With overlapping `auto` grants the id tie-break chose the label, so a secret could come back `private`. M3b mounts grant paths at the same path, where `/` would replace the tool image. | | 2026-09-18 | Spec review of M3a, audit. A line followed by a matching `Recovery` is recovered whether or not it parses; an unparseable last line is recovered like a torn one. The writer never moves to an earlier day's file, and `Recovery` and `AcceptedBreak` go in the latest file. `--accept-break` verifies the whole log; the verifier checks an `AcceptedBreak`'s `file`, `line`, `last_good`, `prev` and `seq`, and the `seq` is counted from lines, not read from them. The `Approval` record carries the re-decision's grant and session state. The report lists allowed calls with no `Result`. | As first written, a record cut exactly before its newline made `brokerd` break its own chain; so did a clock stepped back over midnight; a break in an older file could never be accepted; and a damaged line could choose the next `seq`. | | 2026-09-18 | M3 is split. M3a is the decision path: grants and matching, session taint, the hash-chained audit log, approvals through `bxctl`, the `broker.sock` and `admin.sock` protocol, and `loopd`'s `BrokerPort`; tools do not run (the runner is a trait with a refusing implementation). M3b is the Podman runner, the four tools and the egress proxy. Review happens after each. | The security logic is reviewed before real tools run on it, as with M2a and M2b. Spec `docs/specs/2026-09-18-m3a-decision-path.md`. | | 2026-09-18 | Grant loading fails closed: one invalid grant file denies every call (`grants_invalid`) until it is fixed. M3 grants may not set `secret` or `patterns`; each tool has fixed rules for which constraints it takes. Among matching grants the most restrictive mode wins (deny, ask, auto). An approval re-decides against the current grants and taint, and runs only if the outcome is still ask or auto. | A skipped, mistyped `deny` grant would silently become an allow. Pattern-matching shell commands is a false comfort; the container is the boundary. | | 2026-09-18 | Every fail-closed state has an entry in `docs/runbook.md` with what you see, why, how to confirm, how to fix and how to check; its message ends with `see docs/runbook.md#`, and a gate script checks that every referenced entry exists. | Owner's requirement: a refusing system must come with clear, actionable remediation. | | 2026-09-18 | `brokerd` listens on two sockets: `broker.sock` (tool requests only, for `loopd`) and `admin.sock` (approvals and grant checks, for `bxctl`). Each refuses the other's messages with `forbidden`. Replaces the single `broker.sock` of the pre-M1 spec. | Anything that could reach the approving socket could approve its own calls. Until M7 all roles run as the owner's user, so the split is enforced in code; M7 enforces it with mounts. | | 2026-09-18 | The audit log records events (`decision`, `approval`, `result`, `recovery`, `accepted_break`) in one chain. A decision is on disk before anything runs; results are recorded by hash and size, not content. A broken chain stops `brokerd` until the owner runs it once with `--accept-break`, which records the break; nothing is ever repaired or deleted. Chain verification is a pure function in `proto` shared by `brokerd` and `bxctl audit verify`. Approval ids are the `seq` of the decision record. | A secret read must not be copied into the audit log. Verification must work when `brokerd` refuses to start. | | 2026-09-18 | Secrets move to M4. In M3 a grant that sets `secret` is invalid. | None of M3's tools needs a secret; the first is the Mattermost bot token. | | 2026-09-18 | Until M7, `brokerd` and its containers run as the owner's user through rootless Podman; a container escape is the owner's user. Containers are hardened (`--network=none` unless granted, `--read-only`, `--cap-drop=all`, `no-new-privileges`, process and memory limits). | Separate users per role are M7's work. Measured on straylight: rootless Podman 5.8.6 with crun, 40 to 80 ms per container. | | 2026-09-18 | Tool containers run from one OCI image built by Nix (`dockerTools`) holding static `toolkit`, `busybox`, `curl` and the CA bundle, loaded with `podman load` and named by digest. No registry pull at call time. The owner's Gitea registry is the route when there is more than one host. | Pinned contents with no egress. Nix already builds the three binaries statically. | | 2026-09-18 | `http_fetch` runs `curl` in a container with no network, through a per-call SOCKS5 proxy in its own container on a mounted Unix socket. The proxy is ours (in `toolkit`), accepts host names only, checks each against the grant's hosts, and copies bytes; TLS stays end to end in the tool container. `clock` moves into `loopd`. | No TLS stack of our own. Redirects are checked at the proxy. Measured on straylight 2026-09-18: allowed host 200 in 0.26 s; other host and IP literal refused. | | 2026-09-18 | M2b: the baseline (system prompt, core tool schemas, `memory/core.md`) is snapshotted per epoch into `sessions//.baseline.json`; resume uses the snapshot, so edits apply only to later sessions. The agent is called Boxmaker; the first `system.md` is drafted by the design model and edited by the owner. A `Usage` log record follows each `Assistant` record. `bxctl chat` shows reasoning dimmed by default. The core tool set is `clock`, `find_tool`, `call_tool`; `echo` is the first discoverable tool. | Owner's choices during the M2b design review; spec `docs/specs/2026-09-18-m2b-agent-loop.md`. Dimmed thinking shows progress and helps spot stalls. | | 2026-09-17 | A compromised `loopd` can degrade the shared `llama-server` for other clients (large prompts, unpinned requests). Accepted for v0 and written into the threat model. `inferproxy` stays a byte forwarder with a connection cap and an accept-rate limit; it does not enforce slot or model policy. If isolation is wanted later, do it on the server side. | The harm is availability only, the owner would notice, and a policy proxy would put a parser for untrusted input into the component meant to have none. | | 2026-09-17 | M2a limits: 10 min wait for a busy slot, 3 min for a model load, 30 s liveness after the first byte, thinking cap 4,096 tokens with `max_tokens` 8,192 as backstop. All are config values. The slot gate is held per request, never across a tool call. | Owner's choices during the M2a design review; measurements (j) to (n) in `docs/inference-contract.md`. | | 2026-09-17 | SHA-256 comes from the owner's `emsha` crate, version 1.0.4 or later, not `sha2` and not hand-written code in this repo. It is wrapped behind one function in `proto`, whose tests carry their own vectors: `abc`, the million-`a` message, and lengths 55, 56, 63, 64 and 65. The crate's custom licence is not an issue: the owner is its author. | No dependencies, no `unsafe`, `no_std`, owner-maintained. Version 1.0.3 hashed every message of length 63 mod 64 wrongly; a differential test against `sha256sum` found it while the crate was being vetted, and the owner fixed it in 1.0.4 (3,204 cases pass). | | 2026-09-17 | M2 is split. M2a is the inference path (`inferproxy`, HTTP and SSE client, llama client, fake server, startup self-test). M2b is the agent loop (session log, turn loop, channel protocol, `bxctl chat`). Review happens after each. | The risky, timing-dependent work gets reviewed before the turn loop is built on it. | | 2026-09-17 | Tool results are untrusted by default: each grant has `untrusted`, default true, and `brokerd` tracks a per-session untrusted flag beside taint. Review of Laguna's work is once per milestone, as an experiment for M1 to be revisited after the M1 review. Laguna keeps `docs/implementer-log.md`. | Owner's choices during the pre-M1 design review. Details in `docs/specs/2026-09-17-pre-m1-design.md`. | | 2026-09-17 | Threat model: the main adversary is injected text steering the model. Secondary: any one role process or tool container is compromised, and the goal is containment. `brokerd` trusts nothing `loopd` reports beyond the request itself and tracks session taint on its own. | Owner's choice. It matches the role split the brief already has. | | 2026-09-17 | Mattermost and the tailnet are trusted. A Mattermost compromise presumes the whole machine is compromised and is out of scope. Forged approvals are therefore out of scope, and there is no per-grant approval-path field. An accidental secret or PII leak into Mattermost is an accepted risk in v0. | Owner: Mattermost sits behind a single-user tailnet with ACLs. | | 2026-09-17 | Data classes: every session starts at `private`. A `secret` result raises it to `secret`. `public` is a provenance label and never lowers a session. The grant that authorises a call names the class of its results (default `private`); tools never label their own output. A grant's allowed data classes are the highest session taint under which it applies. Unattended egress is controlled by grant mode, not by lowering the floor. | `memory/core.md` is in every baseline, so no session is really public. Job-scoped grants are left to M5. | | 2026-09-17 | IPC: Unix stream sockets, strict JSON bodies in frames with a 4-byte length prefix and a size cap checked before allocation. Unknown fields rejected, tagged enums, no floats, golden-file fixtures as the wire spec. CBOR and protobuf were considered. | Every role needs a JSON parser anyway (llama-server, Mattermost, model-written tool arguments), so a binary envelope adds a parser without removing one. Both ends share the `proto` crate. | | 2026-09-17 | Each daemon listens on one socket named after itself (`infer.sock`, `broker.sock`, `loop.sock`, `gateway.sock`). Peer authentication is by directory permissions per pair of roles. Blocking I/O with threads; no async runtime. | Small audited dependency tree, and fewer ways for the implementing model to go wrong. | | 2026-09-17 | Audit and session logs stay JSONL. Type safety comes from the `proto` record types and strict decoding; the files are never built by string formatting. Writes go through one writer module so the format can change later. | Volume is one record per tool decision; `fsync` and inference dominate the cost by orders of magnitude. | | 2026-09-17 | Boxmaker uses the shared `llama-server` router and the shared Ornith instance. No dedicated instance. `inferproxy` stays. | The owner runs coding agents against the same router, and a second resident copy of Ornith does not fit beside Laguna. | | 2026-09-17 | The harness runs on straylight, the same host as `llama-server`. | Owner's choice. One host to secure, and no inference traffic crosses the tailnet. | | 2026-09-17 | Container runtime is rootless Podman. | It is already the standard runtime on straylight and the owner's other hosts. | | 2026-09-17 | Implementation is done by Laguna S 2.1, served by straylight, through OpenCode on the owner's development machine; builds are then deployed to straylight. Design, specs, measurement and review are done by a stronger model. Ornith-1.5-35B-A3B remains the model the harness serves. | The owner wants to test a local model on real implementation work. Plans must be written as small closed tasks with tests specified up front. | | 2026-09-17 | M0 is run by the design model, not by Laguna. | M0 is measurement and interpretation, and its findings bind the design. | ## Open | Decision | Needed by | |---|---| | Thinking cap size. It starts at 4,096 tokens so that the right size can be found from data. From M2b every completion logs its reasoning-token count and whether the cap fired. Revisit once 200 completions are logged, or sooner if the owner sees answers cut short: raise the cap if more than 5% of completions hit it. Each hit also costs a re-read of that turn. | after M2b has run for a while | | Whether `proto`'s structs should refuse the JSON array form. serde's derived `Deserialize` accepts `[…]` in field order, so `"body":[]` decodes as `Empty`; true of every `proto` struct since M1. Not an authority problem (the values are validated the same way); closing it needs hand-written `Deserialize` for every struct. Found by the M3a plan checks. | before M7 | | Repository licence. Crates are `publish = false` until it is chosen. | before publishing | | Audit integrity beyond a self-contained hash chain (for example anchoring the chain head outside `brokerd`). Deferred by the owner on 2026-09-17 to a later revision. | after v0 | | How an approval whose arguments do not fit one Mattermost post is shown. Settled on 2026-09-18: what is being approved is never truncated, in any channel, because the unseen tail is where injected text would put its payload; `bxctl` prints arguments in full. Leading option for Mattermost: a thread of numbered chunks split on character boundaries of the escaped text, where only a reply or reaction on the last post counts and that post repeats the id, the chunk count and the hash of the whole; above some number of chunks, the post points to `bxctl approvals` and approval is local only. The post size limit and rate limits must be checked against the Mattermost API reference first. | M4 | | Whether cloud-led sessions are ever allowed, and for which data classes. | M6 | | Session-scoped and standing approvals for tools other than memory: answering an approval with `!approve 42 session` (the same pattern for the rest of the session) or `!approve 42 always` (standing), words after the id as `!deny 42 ` has (owner's syntax, 2026-09-24). To settle: what "session" covers (this call's tool and path or host, or the whole grant it matched); and where "always" writes, since `brokerd` cannot write grants (a proposed grant for the owner, or a separate directory of chat-approved grants). A temporary grant is new authority (the grants are the owner's files, and `brokerd` cannot write them), so it needs its own design. Owner's point, 2026-09-24: a harness should not ask a stream of questions. | after M5 | | Projects in memory. Honcho can separate projects by a workspace per project (hard walls, shared naturally by every agent on the project, but Honcho's picture of the owner splits per workspace) or by scopes inside one workspace (one picture, recall filtered by project; semantics not yet read). Either way a project is a `memoryd` setting: its channels and threads, its workspace or scope, `write` and `trust`. A shared project workspace is written by several agents, so its trust is the owner's call per project. Test both on rift before choosing. Owner's idea, 2026-09-24. | M5c or later | | A session's trust across compaction. From M5b (plan task 09) `loopd` reads whether a session has read untrusted content from its log: true once any tool result in `sessions//0.jsonl` is labelled untrusted, and it never goes back. Compaction starts a new epoch file, so the state must be carried into the new epoch (a record at its head, or `untrusted()` reading every epoch), or a compacted session would read as clean and `gatewayd` would leave its links live. | the milestone that adds compaction | ## Applied to the design brief Approved by the owner on 2026-09-17. One commit each. | # | Change | Evidence | |---|---|---| | P1 | Inference contract 6: the `tools` array is fixed per epoch. `find_tool` returns schemas as a tool result and the model calls them through a `call_tool(name, arguments)` meta-tool. Never instruct the model to call an undeclared tool. | M0 (h): adding a tool re-read the whole prompt; undeclared calls were coerced into `write_file`. | | P2 | Inference contract 2: the session log stores assistant messages exactly as returned, including `reasoning_content`, and replays them unchanged. Remove the "known risk" about dropped thinking blocks. | M0 (e): Ornith's template keeps every think block. | | P3 | Inference contract 7: requests set `return_progress: true`; progress events count as liveness. | M0 (i): otherwise the stream is silent during prefill. | | P4 | Inference contract 8: the thinking cap uses `reasoning_control` and the control endpoint. | README b10809. Exercised 2026-09-17, `docs/inference-contract.md` (k): it works, and a forced end costs a re-read of that one turn. | | P5 | Settle the open question: chat-completions with server-side tool parsing. | M0 (b), (c). | | P6 | Code constraints: `Decision` lives in `brokerd`, has a private field and does not implement `Deserialize`. `proto` carries a plain `DecisionRecord` for the audit log. | Rust privacy is per crate, and a deserializable type can be built by anyone. | | P7 | Inference contract 1: the baseline budget test needs the server's tokenizer, so `make gate` has an offline part and an on-device part (`make verify-device`). | `/tokenize` is a server endpoint. | | P8 | Target environment: describe the shared router as deployed (router mode, `--models-max 2`, other clients on the same Ornith instance, q8_0 KV, six-hour idle unload). Drop "memory is abundant". | `docs/inference-contract.md`, "What is running". | | P9 | Inference contract 5: slots are pinned with `id_slot` but not reserved. Another client can evict a harness slot, and the router can unload Ornith. Cache loss is an expected event: `loopd` detects it (`cache_n` far below the previous request's total), logs it in the session log, and carries on. It is never an error. A session always uses the same slot; threads may share the main slot. | Shared router decision. M0 (d): evicted sessions came back from the host-RAM prompt cache in under a second on the same slot, and cost a full re-read on another slot. | | P10 | Inference contract 7: a pinned request can queue behind another client's request on the same slot, and the router can spend tens of seconds reloading the model. The liveness timer starts at the first byte received, and a separate, longer "waiting for slot or model" limit covers the time before it. | Shared router decision. Measured 2026-09-17, `docs/inference-contract.md` (j): a queued request receives no bytes at all; `/slots` shows whether the slot is busy. | | P11 | Inference contract 10: the serving flags live in `~/src/nixos`, not here. This repo records the expected values (template hash, per-slot `n_ctx`, slot count, KV type, sampling) and the startup self-test compares them with `/props` and `/slots`. KV type is recorded as q8_0. | Shared router decision. | | P12 | Network isolation 2: `inferproxy` is kept. Remove the conditional about dropping it. Its upstream is the router's TCP listener on the host. | Shared router decision; M0 (g). | Later changes to the brief: | Date | Change | Reason | |---|---|---| | 2026-09-17 | The roles table gained a one-sentence Purpose column, and the crates that are not roles got a purpose table under Code constraints. No behaviour changed. | Owner's request: a reader, human or agent, should learn what each piece is for before what it holds. | | 2026-09-18 | P14, State: the list gained `broker/sessions/.json`, each session's taint and untrusted flag, written only by `brokerd`. | The M3a spec keeps this state in files the list did not name. It can be rebuilt from the audit log's `result` records, so the audit log stays the record of truth. Owner: a plain file also makes debugging easier. | | 2026-09-22 | P13, Authority contract 4: tool containers run from one image built from source by Nix and named by digest; nothing is pulled at call time. | Approved with the M3 design (2026-09-18); applied when the M3b spec came to rely on it. A pull at call time would be unlisted egress. | | 2026-09-23 | P15, State, the secrets line: three `SecretStore` backends chosen per secret (a systemd credential by default, an environment variable, an owner-only file), the last two in plaintext and for portability, a file secret warning at startup. | Owner's decision and approval, 2026-09-23 (M4a). The brief's "no plaintext secrets on disk" did not allow the file backend. | | 2026-09-24 | P16, Network isolation 3: approvals are commands (`!approve`, `!deny`) replied in the approval's thread; emoji reactions are not used. | Owner's decision and approval, 2026-09-24; M4b spec `docs/specs/2026-09-24-m4b-approvals.md`. | | 2026-09-24 | P17, Memory 1, the baseline and State: the core block becomes three files (`soul.md`, owner-written; `memory.md` and `user.md`, agent-edited at `ask` with add, replace, remove and `consolidate`), read once per epoch; moved entries go to `memory/archive.md`. | Owner's approval, 2026-09-24; see the row of that date under Decided. | | 2026-09-24 | P18, Roles and Network isolation: a sixth role, `memoryd`, owns `memory/` and `index/`, syncs turns to Honcho and runs memory tool calls; `loopd` has four sockets and no longer holds the memory files. | Owner's approval, 2026-09-24; see the row of that date under Decided. | | 2026-09-24 | P19, Memory 2 to 8: `recall` over a local FTS5 index (session logs, archive) and Honcho, `ask_memory`; Honcho writes only from clean sessions; per-workspace trust set by the owner; notes, the flush job and the embedding index go; memory text stays on the owner's hosts. | Owner's approval, 2026-09-24; see the row of that date under Decided. | | 2026-09-24 | P20, Milestones: M5 becomes M5a (local memory in `memoryd`), M5b (Honcho) and M5c (the scheduler and the tiering job); the embedding-index M5b goes. | Owner's approval, 2026-09-24; see the row of that date under Decided. | | 2026-09-24 | P21, Memory 1, 2, 6, 7, State and Milestones: memory is identity (`soul.md`, `memory.md`, `user.md`, in the prompt, edited only by clean sessions) and experience (`notes.md` and session logs, recalled with a source tag: `identity`, `memory`, `memory-tainted`); writes need no approval; `note` in any session; an untrusted session's identity `add` becomes a note; a check on every write, a journal and undo; the policy of the memory tools is built in. | Owner's approval, 2026-09-24; see the rows of that date under Decided. | ## Proposed changes to the design brief | # | Change | Reason | |---|---|---| | P22 | Target environment, Roles and Milestones: Boxmaker becomes a fleet of independent agents, one per host. Each has its own identity (`soul.md`), memory, grants, audit log and credentials (its own Mattermost bot); they share only the fleet's memory, through Honcho (M5c). Each agent's inference is a configured endpoint that speaks llama-server's API: the host's own router, another host's, or a router such as crossbar. A new last milestone, M8, fleet deployment: `nixosModules.boxmaker` (`nix/module.nix`) for the owner's NixOS hosts, used from their system flake; `dist/` units, a config example and `make install` for other Linux hosts (an Omarchy machine); a host's inference measured, as M0 did on straylight, before an agent runs there. The flake itself (packages, the tool image, a dev shell) comes first, without the module. Open for M8: whether the roles run as systemd user units, as Hermes does on straylight (`linger`), or in M7's rootless containers; and what an endpoint must provide (`id_slot` pinning and queueing, `return_progress`, `timings_per_token`, `reasoning_control` and `/v1/chat/completions/control`, `/props`, `/slots`, prefix reuse on one slot), which the owner passes to crossbar's developer. Crossbar's answer (checked against its main, 4c64158, 2026-09-25): it cannot serve Boxmaker as deployed. It routes by the path's first segment or an `X-Crossbar-Route` header, never proxies `/slots` or `/tokenize`, may send `/control`, `/props` and `/slots` to a different host from the stream's, puts `/control` behind its limiter, and answers errors in its own shape. Until then an agent's `inferproxy` points straight at one host's llama-server. Crossbar's minimum for Boxmaker (a v2.3): a default route per listener, every call of a route on one host (a route per agent), side calls outside the limiter, and llama-server's error shape. Still open after that: a request queued in crossbar looks idle on the host's `/slots`, so Boxmaker retries it after 30 s; pinned routes should not queue in crossbar. Two agents on one host need different `[slots] main`. | Owner's direction, 2026-09-25: three agents run on three machines; boxmaker deploys to NixOS nodes from the system config and to an Omarchy machine; dixie gets an agent after its second GPU. | | P23 | Memory 4 and 5, Milestones (M5c): each agent writes to two Honcho workspaces of its own, a trusted one and an untrusted one. A finished turn is written as two messages, the owner's (or other speaker's) from their peer and the agent's final reply from the agent's peer; tool calls, tool results and intermediate reasoning are not written. A turn is clean when the session is still clean at the turn's end, because the reply is made from the whole context: clean turns go to the trusted workspace, the rest to the untrusted one, which `recall` and `ask_memory` read as `memory-tainted`. The gate is `brokerd`'s session state, asked over a read-only socket query when `memoryd` learns the turn ended; a late answer can only file a clean turn as untrusted, never the reverse. `loopd`'s log labels are a second check. M5c's check "a turn from a tainted session never reaches Honcho" becomes "never reaches the trusted workspace". | Owner's direction and approval, 2026-09-25 (M5b design, now M5c): Honcho holds untrusted memories too, labelled by workspace since conclusions carry no per-entry provenance. Reading `brokerd`'s state file instead was rejected: a wrong path reads as clean (fails open), its modes block other users (M7), and it would make a private file an interface. To be applied to the brief with the M5b spec. |