Files
nisse/docs/Architecture.md
T
dongarandClaude Opus 5.5 ea4376eaa1
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m10s
CI / e2e (pull_request) Successful in 10m30s
M3.1: action classes and the fast path
Every execution tool call, the driver's and a helper's, gets a verdict
from code before it runs (P3, P4, D6).

- nisse_core.actions, nisse-core's first code: Action and Verdict, and
  reading shell commands statically, segment by segment (cd, wrappers,
  sh -c). What can't be read that way is unknown; a line is as bad as
  its worst command.
- nisse_dev.loop.actions, the rule table: reading, checks and tests are
  harmless; workspace edits, local git and installs on the build VM are
  contained; pushing, other hosts, uploads, publishing, the forge's own
  tools, deleting outside the workspace and wrecking the VM are
  dangerous; changing branches by hand is refused (the harness owns
  branches) with a pointer to the milestone tools.
- A dangerous call isn't run. The item waits on the operator with a
  question rendered from the action, and `allow` lets exactly that
  action run once. A helper can't ask, so it is refused and told to
  hand back. Unknown actions run, logged, until the classifier (M3.2).
- Every verdict is an action_verdict event, which no model sees, and an
  oplog line; `details` shows the counts and every action not simply
  run.
- The property walk gains dangerous_only_when_allowed (checked against
  a mutant that doesn't use the allow up); the slice e2e checks every
  call was judged and none needed an allow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 04:32:13 +00:00

976 lines
52 KiB
Markdown

# Nisse — Architecture
**Status: agreed (PR #83); revised in PR #90.** How and in what order it gets built
is in `docs/Development-Plan.md`, which also holds implementation notes too detailed
for this document. The former `docs/Architecture-Build-Spec.md` is in git history.
This document records decisions and the reasons for them: what Nisse is, how its
parts fit together, and why. Each rule is stated once, in its home section, and
referred to elsewhere by section ID.
---
## Naming
The project is **Nisse**, after the Nordic farm spirit that keeps the place running
overnight and expects to be treated well in return.
| Artifact | Name | Role |
|---|---|---|
| Shared library | `nisse-core` | What every agent is built on (Part L) |
| Dev Agent | `nisse-dev` | Designs and builds software projects (Part D) |
| Sysadmin Agent | `nisse-ops` | Operates infrastructure (Part O) |
More agents are planned (3D design work, video-file management), each another
application over `nisse-core`. One string names all of an agent's artifacts
(package, image, hostname, Matrix identity, service account), so grepping the
infrastructure for `nisse-ops` finds everything. **Sites** are named by their domain
stem: `garrett-hall`, `hartman-hut`.
Everything lives in **one repository, `nisse`**. The repository was never a trust
boundary (the hosts are, X1), and a monorepo saves a solo operator tripled admin.
---
## Part P — Principles
### P1. What Nisse is
Self-hosted agents that **do real work and talk about it.** Each is a collaborator
the operator converses with in Matrix: it explains what it is doing and why,
answers questions, asks when unsure, and summarises what it did. It is not a
command interpreter behind a menu of keywords.
The agents run **local-first** (P7) and **self-hosted**: everything that can run
inside the operator's network does, and no outside provider holds state the system
needs to resume.
### P2. Threat model
The checks exist to stop **mistakes and manipulation** from doing damage, not to
contain a model that schemes against the operator. In order of how often they
matter:
1. **Honest mistakes:** a wrong command, a misread instruction, confidently wrong
code, an action on a stale belief. They call for reversibility, visibility and
a second look before anything irreversible (P3, P5).
2. **Prompt injection:** untrusted content (issue text, repository files,
dependencies, web pages, alerts, logs) steers a well-meaning model. It calls for
hard boundaries where untrusted input meets real power (P4, L11).
3. **Crashes and environment surprises:** a process dies mid-action, a host
reboots, a workspace is corrupt. They call for durable state and idempotent
effects (L8).
No check relies on an agent's intentions: one that assumes honesty fails under
injection, and one built to contain a hostile model makes the agent useless.
### P3. Actions by their effects
Every action is **harmless**, **contained** or **dangerous**:
| Class | What it is | Examples | Rule |
|---|---|---|---|
| **Harmless** | Changes nothing outside a disposable sandbox | Reading, querying, searching, running tests in a throwaway environment | Always allowed |
| **Contained** | Stays within the agent's own area, or has a known undo | Editing the workspace, committing to the agent's branch, opening a PR, writing its own notes; for ops, an approved step with a known undo | Allowed in scope; logged and reported |
| **Dangerous** | Irreversible, visible beyond the agent's area, or outside what was agreed | Merging to a default branch, deleting data, changing a firewall, installing on a host the agent doesn't own, contacting third parties, spending beyond a budget | Needs discussion, unless an approved step sequence (O2) or the operator in conversation names the exact action |
**In scope** means within what the operator agreed to (L3). The same action can be
contained in one work item and dangerous in another. **Make actions less dangerous
wherever it is cheap** (P5).
### P4. Three layers of control
1. **Hard walls, outside any model:** credentials the agent doesn't hold, network
destinations it can't reach (X2), branches it can't push to or merge into, mounts
it doesn't have, budget caps, the kill switch, idempotent external effects (L8).
They hold even if every model is fooled.
2. **A deterministic fast path:** harmless actions, and contained ones a static rule
recognises as in scope, proceed without asking any model. Most actions go here.
3. **The action classifier** (L4) for everything else.
**Conversation sits above all three:** when anything needs discussion, the agent
asks in plain language and waits. Anything truly irreversible also sits behind a
hard wall; the classifier is a filter, not a guarantee.
### P5. Reversibility first
- Work happens on **branches**, never on what others depend on (D4).
- Work starts from a **fresh, clean workspace** (D3): a broken environment is
replaced, not diagnosed.
- **What must survive lives on the agent host and is snapshotted** (X4), and
snapshots are taken before risky changes where the platform allows. Tool VMs,
build artifacts and caches are disposable and rebuilt rather than repaired (X1).
- Where a tool offers a **dry run** or preview, the agent uses it first.
### P6. Structural, not model judgment
Anything safety or recovery depends on is enforced in code, never left to a model
noticing. A good outcome that only happened because a model behaved well is a bug.
Examples: adopting an already-open PR after a crash (L8), binding approvals to what
they were about (L3), harness-owned branches (D4).
Models are trusted outright only where a wrong choice costs time rather than
safety: choosing the next ready item (L1), judging whether a report is clear (D1),
naming a milestone (D4).
### P7. Local first, frontier on call
- **A local model drives every step** (conversation, planning, implementation,
review), on the operator's own inference host behind LiteLLM (L9).
- **Frontier models are helpers the driver calls:** a quick question
(`ask_expert`) or a scoped sub-task (delegation), L9.
- **Escalation doesn't wait for the model to notice.** The driver is prompted to
ask for help when unsure, which is its own judgement; observable triggers
(repeated failures, caps) escalate as well, even when it doesn't ask, since small
models are poorly calibrated about their own competence.
- **This is an experiment with a cheap exit:** if a local model falls short at some
kind of work, a frontier model drives it instead, as a configuration change.
---
## Part L — The shared library (`nisse-core`)
Everything in this part is used by every agent.
### L1. The work model
| Concept | What it is | `nisse-dev` | `nisse-ops` |
|---|---|---|---|
| **Duty** | A standing responsibility that produces work items | Watching for issues assigned to it (D1) | Scheduled checks, log monitoring, alert intake |
| **Work item** | One effort, opened and eventually closed | A bug, feature or new project | Diagnosing a failure; making one change |
| **Scope** | What a work item operates on; context that outlives it | A repository, or `general` | A site and service, or `general` |
- **`general` is the scope of conversation not (yet) about a project:** feasibility,
hardware, how something works. Each conversation is its own item, one per thread
(L5), so unrelated chats stay out of each other's context. It has no repository,
no build VM and no execution, so its context is the conversation and agent memory
(L6). It closes after a day idle, with a summary in its log; a later reply in its
thread starts a new item linked to it. If it turns into work, the work moves to a
project's item (D1).
- **Sources of work items:** duties, the operator (always), and other agents. A
request from another agent is an issue assigned to the receiving agent, and
carries no authority of its own: the receiver applies its own rules.
- **A duty run is read-only;** a finding that needs attention becomes a work item,
deduplicated by a stable fingerprint so a repeat attaches to the open item.
- **A work item is the unit of state:** its conversation, proposals, pending
questions, pinned roles (L9) and transcript (L7). Its locally minted ID is also
its correlation ID in logs.
- **One tracker: Gitea issues,** for every agent. An issue assigned to an agent is
a work item for it, and the issue is the item's record (D1, O5). External truth
wins on divergence (L8).
- **Work items don't nest:** a milestone is a phase of one; a delegation is an
episode in one.
**States.**
| State | Waiting for |
|---|---|
| **active** | nothing; it is being worked on |
| **ready** | its turn |
| **waiting on the operator** | an answer, a go-ahead, a review or a merge |
| **blocked** | a recorded dependency to close |
- **Blocked comes from external truth:** a Gitea issue dependency, including on an
issue in another repository, such as a request to another agent. It becomes
ready only when the dependency actually closes (P6); the operator can remove the
dependency. An agent that finds a blocker mid-work proposes recording it, and
records it only with the operator's agreement.
**One active work item per agent; the rest are parked.**
- **Switching happens only at safe points:** the active item is waiting, blocked or
closed, and no intent is unresolved (L8).
- **Conversation is not work.** The agent answers any message about any item
between turns, without switching. A reply may change an item's state (an answer
makes it ready); only the active item has tools run for it.
- **The agent switches on its own** when the active item has waited on the operator
past a grace period (so a watching operator can answer first), is blocked, or has
closed. The operator can switch at any time.
- **Next item:** the operator's order when given ("do #14 next", a priority label),
otherwise the oldest ready item. An agent may let the driver choose from
code-rendered summaries where judgement helps (ops, by severity); choosing grants
nothing.
- **Parking gives up authority:** elevated credentials are unmounted (O2) and
remounted only if the approval hasn't expired.
- **Nothing is lost while parked:** intake for it waits on a durable cursor (L8).
- **"Active" is a pointer,** so allowing more than one later (Q) is a loop change,
not a migration.
### L2. The agent loop
Each agent runs **one conversational loop**, driven by the local model: it talks
with the operator, reads and writes the work item's documents, calls tools and
helpers, and reports. It is not a fixed workflow of states.
What is fixed: which item is active (L1), pending questions (recorded durably, asked
again after a restart), permissions (L3), and work in progress as intents (L8).
- **Every wait is explainable and leavable:** for the operator (with something
they can see), for work the agent started, or for a recorded dependency. Anything
else is a bug, which the model checker (`specs/`) guards against.
- **`stop` works in every state:** it ends the current turn or delegation at the
next tool boundary, unmounts elevated credentials, and asks what next.
- **`abandon` ends a work item** from any waiting point, closing its record and
any open PRs with a note and aborting its unresolved intents.
- **Every action passes through the three layers** (P4).
### L3. Authority: scope and approvals
**Scope** is what the operator agreed to, and what actions are checked against
(P3). The loop doesn't stop at gates for their own sake.
- **Dev work:** the assigned issue and what was agreed in its conversation (D1).
Documents inform it but don't widen it (D2). The real check is the PR review.
- **Ops work:** an approved step sequence, exactly (O2).
- **Always discussed first:** any dangerous action not named by an approved step
sequence or by the operator in conversation.
- **Labels and words steer, and are never required** (D1): leaving one off is never
an error.
**Approvals bind to exactly what was approved.** This covers everything put up for
agreement: a restatement awaiting a go-ahead (D1), a design (D2), a step sequence
(O2).
- Every proposal revision carries a tag, numbered across the work item: `rev-N`
names exactly one.
- **A reply that names no revision is about the one up when it arrived.** If that
isn't the current one, the reply isn't applied and the agent says which is up. A
repeated approval, one on two channels, or an afterthought can never land on the
next proposal. (Found by the model checker.)
- **Approval messages are rendered by code from what will happen,** never from a
model's description (L11).
- **Approvals expire** where the world can change underneath them (O2).
**Approval sources:** Matrix (L5) and Gitea comments (D1, O5), each checked
against its own operator allowlist. The first valid approval wins and
is echoed to the other channels. The knowledge vault is never one (L10).
**The trust dial:** how much the operator reviews after the fact, set by the
operator per project (e.g. whether the agent may merge its own PRs once checks
pass, D5). The agent never changes it.
### L4. The action classifier
An independent model call that sorts an action the fast path doesn't cover:
**safe** (proceed), **dangerous but in scope** (proceed, logged and reported), or
**dangerous, needs discussion** (ask).
- **It judges the action, not the actor's story:** its input is the structured
action and the agreed scope (L3), never the actor's transcript or untrusted
content, which could argue past it.
- **It fails closed:** an error, timeout or unclear verdict means *needs
discussion*.
- **It is independent of the actor:** its own prompt and context, ideally a
different model.
- **Every verdict is logged.** Operator overrides become an evaluation set (L9), and
verdicts it keeps agreeing on become fast-path rules.
It is shared by all agents; each contributes its own fast-path rules (D6, O3). It
executes nothing, so it runs on the agent host.
### L5. Conversation and transport
**Matrix is the conversation channel:** a self-hosted homeserver, one identity and
room per agent, behind a `Transport` interface so another chat system is one new
class. Existing Matrix clients are the UI.
**A thread per work item.** Each item's conversation is a Matrix thread, and the
thread is how the agent knows which item a message is about.
- **When a thread starts:** with the item. For an assigned issue, the agent's
acknowledgement ("queued myproject#14") is the thread's first message. For a
conversation, the operator's own top-level message is, and the agent replies in
a thread under it. The item records its thread, so the mapping survives restarts.
- **Inside a thread,** every message and command (`go`, `stop`, `abandon`, an
answer) applies to that item, even while it is parked.
- **At top level,** `status` is about the agent and `stop` stops the active item. A
command that names its item applies to it ("abandon #14"). Any other command is
ambiguous, so the agent asks which item rather than guessing. Any other message
starts a new conversation, and the agent offers to continue an existing item if
it clearly belongs there.
- **Every conversation is a work item from its first message,** in the `general`
scope until it is about a project (L1), however vague ("I'm having trouble with
X…"). Chit-chat is simply answered in its thread; only work gets a Gitea issue
(D1). It may end with a project, an issue, or just an answer.
**What the operator says** is plain language, plus a small command vocabulary that
works in every state: `status`, `stop`, `abandon`, `approve [rev-N]`, `go`, and
work-item selection.
- **Commands are matched leniently:** case, surrounding whitespace, trailing
punctuation and a leading mention of the agent don't matter. A message addressed
to someone else isn't for this agent.
- **Questions never change state.** A question gets an answer, never an
interpretation as approval, rejection or command; a misread defaults to asking.
- **One exception: a plain-language go-ahead.** "Yep, that's it" in reply to a
restatement may be taken as `go` (D1), because a wrong reading costs only time.
The agent says so before acting ("taking that as a go-ahead on rev-2"), and it
binds to the revision up on arrival (L3). Everything else still needs explicit
`approve`.
- **Documents as input:** pasted, attached as a text or Markdown file, or a note in
the vault the operator points to (L10). The message is the instruction; the
document is material.
- **Everything the operator says is recorded** in the transcript (L7).
**What the agent says:** it explains as well as acts (a proposal with its rationale
and alternatives, a revision with what changed, finished work with what was done,
verified and left open); it says what happened to a message it can't act on yet,
rather than refusing; progress is prose at a readable rate, with raw tool activity
on request; and it asks when unsure, including mid-implementation.
**Gitea carries work and records,** read as structured JSON (never through a chat
bridge). Comments from allowlisted operators are
instructions or approvals; anything else is data (L11).
**Outages delay, never fail.** Intake retries indefinitely with capped backoff and
logs the outage; interrupted outbound effects are covered by intents (L8).
### L6. Context and memory
**1. The working context of one model call** is rendered from the log at call time
for one role, fitted to that model's window, and never stored. Fitting never drops
the system prompt, the current plan or the most recent turns; a call that still
doesn't fit fails (L9).
**2. The work-item transcript** (the log, L7) is durable and scoped to one item:
nothing crosses items, and agent-level calls (switching, duty runs) see other items
only as code-rendered summaries.
- **Every entry is attributed** (operator, driver, helper, orchestrator).
- **The orchestrator writes facts** (workspace prepared, milestone started or
finished, PR opened or merged, approval given), so models learn what happened
instead of inferring it.
- **Each role gets its own view.** The driver sees the conversation, facts and
helpers' reports; a delegated helper sees its brief, the current plan, the facts,
its own work and the driver's work in the milestone as compact narration; the
expert sees only its question. **No role sees another's turns as its own:**
whatever isn't the viewer's own arrives labelled with its origin.
**3. Scope memory** outlives work items: a repository's own documents (D2), read at
work-item start, with the repository as the authority; for infrastructure, the
world model in the vault (L10).
**4. Agent memory:** the agent's own notes (the operator's preferences, lessons,
recurring decisions and their reasons), one note per fact with an index, in its
folder under `memory/` in the vault (L10). Read at work-item start and before
decisions the notes cover. Visible to the operator, who corrects it through
conversation.
**5. Never memory:** Matrix history, provider-side session state, build artifacts,
a previous task's workspace.
**Writing memory:** at work-item close (what was done and decided, into scope memory
or agent memory as it belongs); when a fact is learned, with its source and time;
operator corrections verbatim, with attribution, never paraphrased.
**Using memory:** everything read is snapshotted into the transcript with its
provenance (source, revision or content hash, time), once per item. **Memory is a
cache, never authority:** facts an action depends on are re-checked against the live
system first, and a proposal resting on recalled facts says so. A note contradicted
by a check is corrected, not worked around.
### L7. The canonical log
Every work item's durable record is a **canonical event log** in the state store,
never a provider-shaped message array. Provider requests are rendered from it at
call time.
| Event | Payload |
|---|---|
| `user_message` | text as sent, source (channel and event ID) |
| `assistant_text` / `tool_call` / `tool_result` | the model's turns, with canonical call IDs |
| `plan_submitted` | a proposal put up for agreement, with its revision (L3) |
| `fact` | what the orchestrator observed or did |
| `delegation_started` / `delegation_completed` | brief, model, caps / handback, artifacts, cost, status |
| `classifier_verdict` | action, scope reference, verdict, model |
| `knowledge_retrieved` | text as read, source, revision or hash, time |
| `compaction` | summary, producing model, range replaced |
- **Every event carries its work item and actor** (L6).
- **Nothing provider-specific is load-bearing:** IDs are minted locally, content is
stored as bytes, and vendor-opaque reasoning is replayed only within the
delegation that produced it.
- **The log is never edited;** corrections and compactions are new events.
### L8. Durability and recovery
**The state store** (SQLite) holds each work item's position, permissions, pending
questions, cursors, pinned roles and log, never workspaces or artifacts. Every
change is committed before the loop continues, and the schema is versioned with a
migration hook.
**Intents and idempotent effects.**
- **Before any external side effect, record an intent;** after it, commit it.
- **Every external effect is idempotent against external truth:** re-running an
interrupted step detects and adopts what already happened (e.g. an open PR for
the branch). What the operator has since undone is an answer, not a gap to
fill: a PR they closed is reported, never opened again.
- **Intake is taken at most once and answered at least once:** a durable cursor per
external thread; an item being answered stays marked until done and is
redelivered after a crash; comments the agent can't act on yet aren't consumed.
**Boot reconciliation,** before accepting input:
1. Run pending migrations.
2. **Reconcile pending intents against external truth:** verified → committed;
verifiably never happened → aborted; unverifiable → ask the operator.
3. **Adopt what external truth shows** (a published PR, an approval posted while
down, a dependency closed).
4. **Resume waiting points as they were:** pending questions and open proposals are
put up again, and watches resume from their cursors.
5. **Resume interrupted work only on evidence:** a delegation when the cut is clean;
an ops step sequence when the log names the step in flight, its outcome checks
out and its approval hasn't expired (O2); anything else is reported with what is
known, and the operator chooses. Never a blind resume.
**Verification:** these rules, and the loop's (L1, L2, D1, D4, D5), are checked by
formal models (`specs/`, Quint) over interleavings of messages, comments, outcomes
and crashes. Each counterexample is
reproduced against the real code before it is fixed.
### L9. Models, roles and helpers
**LiteLLM is every agent's only LLM destination.** It holds the vendor keys (each
agent holds one LiteLLM token), enforces budget caps per key, logs, and routes
fallbacks; adding a vendor never touches an agent. **One client interface,**
`complete(events, tools, model) → events`, over the log; long sessions use vendor
passthrough so vendor features aren't lost.
**Roles** resolve to models in configuration: `driver` (drives the loop), `helper`
(delegations), `expert` (`ask_expert`), `classifier` (L4). The mapping is **pinned
when a work item opens**, so a configuration change can't split one item across
models. Each model carries a **capability profile** from benchmarks (tools, window,
strengths, weaknesses), used whenever one role is briefed about another.
**Escalation:** the driver calls a helper when it chooses to (its prompt treats
asking as normal) and on observable triggers (repeated failures, a context that
won't fit, turn or time caps).
**Delegation:** a frontier model takes over a scoped sub-task with tools.
- One harness for every vendor, not vendor agent SDKs.
- Bound by the work item's scope (L3) and the same three layers (P4).
- On its own branch; abandoning it discards the branch.
- Hard caps on turns, tokens and time; checkpointed, so it can resume (L8).
- Hands back a note for the driver (what changed, what is now true, what next):
that note and the files are the whole interface.
**`ask_expert`:** one stateless question, text answer, with a per-item budget so a
confused driver can't circle.
**Small models** get reduced tool schemas and a tool-call contract the loop
enforces itself where the server can't.
**Evaluation capture:** delegations, `ask_expert` calls and classifier verdicts are
recorded as input/output pairs with outcomes, so changing a model is a re-run, not
an argument.
### L10. Knowledge vault
Durable knowledge outside any repository (world models, agent memory, the
operator's material such as procedures) is **a folder of Markdown files on the agent
host** in the format Obsidian reads (frontmatter, `[[links]]`, tags), so the operator
browses it in Obsidian.
- **Agent memory is under `memory/`,** a folder per agent. Other folders' names and
layout grow with need.
- **Every folder has exactly one writer,** an agent or the operator, and **mounts
enforce it** (P4): each agent gets the folders it writes read-write, those it may
read read-only, and nothing else. No agent reads another's memory; `nisse-dev`
gets no infrastructure material, which would map what its builds are kept from.
- **Links point from memory to shared notes,** never back.
- **Access is through control tools** (L13); no tool VM sees the vault. The harness
writes atomically and records each note's provenance (source, work item, time).
It may also keep the vault's history in git; recovery doesn't depend on it.
- **The operator's copy** is kept by Syncthing, with direction per folder: agents'
folders, memory included, are send-only from the host; the operator's are
receive-only on it. So conflicts can't arise.
- **Never an instruction or approval channel.** Work is never read out of the vault,
and no agent writes there to make another act; agents coordinate through the
operator, Matrix or issues (L1). The operator points to a note in conversation
when it is input (L5).
- **Corrections go through conversation** (L6). Break-glass: stop the agent, edit on
the host, restart.
- **Procedures are bound to ops policy entries by path** (O2) and read whenever a
plan uses the entry; search exists in addition, never instead. A missing bound
procedure stops planning that action; other failed reads and writes never block
work.
### L11. Prompt injection
**Any model that reads untrusted content may be steered by it,** and no marker
reliably separates data from instructions. So the design limits what a steered model
can do rather than trying to prevent steering.
- **Hard walls hold against a steered model** (P4): nothing a model says grants a
permission, widens a scope or reaches a credential it doesn't hold.
- **Instruction channels are operator-scoped:** Matrix from allowlisted identities,
comments and assignments from allowlisted accounts. When someone else wrote an
issue, the operator's assignment is the instruction and the text stays data.
- **Intake is parsed into structured fields in code** where possible, and what
reaches a model is labelled by source (L6).
- **Approval messages are rendered by code;** the classifier never sees untrusted
content (L4).
- **Some injection is accepted because it can only reorder work** (an alert title
biasing which item is next).
- **The lethal trifecta** (private data, untrusted content, a way out) is checked
per agent. In `nisse-dev`, untrusted content meets command execution only on
build VMs, which hold nothing beyond their project's (D7); `nisse-ops` has all
three legs and relies on its hard walls and step sequences (O2).
### L12. Secrets, identity and logging
**Secrets.**
- **Each agent has its own set,** on the agent host, mounted only into its
container, never baked into images. An agent may structure its set (`nisse-dev`
by project, D7).
- **The harness hands secrets out per operation,** decided by code and
configuration, never by a model, and never on a command line.
- **Every value handed out is redacted** from tool output before it reaches the
transcript, a model or a log.
- **Each MCP server instance** has its own minimally scoped credentials (X3).
- **Only credentials are secrets;** every other setting is reviewable
configuration.
**Identity:** each agent is its own LLDAP principal in a tightly scoped Authelia
group, never the operator's account, with its own account and token in each
service it uses (X5).
**Logging:** structured JSON lines to the host journal and the log stack. Every
durable state change emits exactly one line, with a fixed envelope (time, agent,
event, work item, cause); every verdict, escalation and external effect is logged.
**Logs are never load-bearing:** nothing reads them to derive state.
### L13. Tools and execution
| Kind | Examples | Where it runs |
|---|---|---|
| **Control tools** | `ask_human`, `ask_expert`, `start_milestone`, `finish_milestone`, notes (L10) | In the harness; they act only on the agent's own state, conversation and records |
| **Execution tools** | Commands; reading, writing and listing files | On a tool VM, over SSH, through `execute(host, …)` |
| **MCP tools** | Proxmox and UniFi APIs | In MCP server containers beside the agent (X3) |
- **`execute(host, command, *, secrets, stdin)` is the only way to run anything.**
There is no local execution path, so **the agent host never runs a command a
model chose** (X1).
- **The harness binds the host,** not the model: for `nisse-dev`, the work item's
build VM (D3). Where a model must choose (ops: its tool VM or a device), it picks
from the hosts the harness allows for that item; others are refused before
connecting.
- **MCP tools are the one deliberate exception:** fixed code against an API, with
the model choosing only arguments. They still pass through P3, P4 and L4, and their
output is redacted (L12).
- **Tests use SSH too,** against a throwaway `sshd`, so the tested path is the
production path.
---
## Part D — `nisse-dev`
Designs and builds software projects, producing reviewed changes in Gitea.
### D1. How it works
Work arrives as Gitea issues or conversation, and leaves as reviewed PRs.
**Intake.**
- **An issue assigned to `dev-agent` is a work item;** assignment says whose job it
is. The agent acknowledges it ("queued myproject#14, 1 ahead"). Your issue is the
item's record. Only issues filed or assigned by allowlisted operators count (L11).
- **Unassigned issues are backlog,** never work, though the agent will discuss them.
- **An issue assigned again after its item closed is a new item,** which notes the
one before it; an issue whose item is still open, or whose assignment predates
the close, stays with that item.
- **Open Gitea dependencies make an issue blocked** (L1).
- **Work that starts in conversation gets a record** once it is work on a project,
not at its first message: the transcript already keeps the conversation. The
agent opens an issue, assigned to itself, that carries the work item's ID from
the moment it is created. Intake ignores any issue carrying an ID, so a record is never taken for
new work, even before the agent has stored the link. This is the only time the
agent assigns itself.
**Starting an item.** The agent reads the project's documents (D2), then
**restates its understanding** in the item's thread: what it takes the request to
mean, how it fits, its first step. Labels steer what happens next:
| Label | After the restatement |
|---|---|
| `bug` | Proceeds after a grace period (the item waits on the operator meanwhile) |
| none, or anything else | Waits for a go-ahead: `go`, or a plain-language yes (L5) |
| `discuss` | Talks it through before anything |
| `design` | Agrees the design (D2) before any code |
- **It also talks first** when it finds the report unclear (it asks instead of
restating) or can't reproduce a bug. It never fixes a bug it couldn't see.
- **It never classifies an issue itself;** it may suggest a label. A missing label
costs at most a wait.
**The flows.**
- **A bug:** reproduce (ideally as a failing test), fix, verify, PR.
- **A feature:** agree the design in conversation, write a build plan if it's longer
than about one PR (D2), work through it as milestones (D4).
- **A new project** starts as a `general` conversation, however vague. Nothing
needs Gitea before the repository exists: drafts live in the conversation (a
Markdown file in the thread if long).
1. **The proposal:** a name, what it's for, and the decisions provisioning
depends on (toolchain, so the build VM template; services; secrets; egress).
The operator agrees as to a restatement.
2. **A provisioning checklist** in the thread: a repository with protection, the
agent as a collaborator with write access, a build VM, and the project entry
(D8), given as configuration to add. The operator provides them (later
`nisse-ops`, O6).
3. **Verified, not assumed:** when the operator says done, the agent checks the
repository, a push to a branch, that protection refuses it on the default
branch (D4), and the build VM.
4. **The record:** a tracking issue ("implement the build plan") whose body is
what was decided, distilled, linking the conversation's item. The raw
conversation isn't carried into the project's context: it holds ideas that
were dropped, and the documents are what gets reviewed and what later work
reads.
5. **The architecture lands in one or more PRs,** treated as `design`, then the
plan's milestones.
`general` never gets a build VM: an experiment that needs one (does a library
parse this format?) is a reason to start the project for real.
- **Progress** goes on the record issue: facts posted by the harness, prose by the
agent when there's something to say.
- **Finishing:** the last PR closes the issue on merge ("Closes #N"; earlier ones say
"Part of #N"). If no merge closes it, the agent closes it with a note, after
checking what is left open.
**Filing issues from conversation** (later). When the operator asks or agrees, the
agent files an issue on any project where it is a collaborator: unassigned, so it
can't create work for itself; after checking for duplicates; with a note of the
conversation it came from.
### D2. Documents
A project's own documents live in its repository:
- **`ARCHITECTURE.md`:** what the project is, how it fits together, and why. Kept
current by the agent; a design change updates it.
- **Build plans** (e.g. under `docs/plans/`): how a body of work gets done, as
milestones. The agent's working documents, for staying organised across context
windows and sessions.
**Neither is a gate;** both are reviewed in PRs.
- **A small architecture change** goes in the PR with the code; the agent may raise
a larger one first, and `design` makes that mandatory. Otherwise it's the agent's
judgement, and a wrong call costs a review, not safety.
- **A build plan is written for work longer than about one PR** and committed with
the work, so every PR shows it. The first PR carries the whole plan, so a wrong
direction shows at the first review. The operator doesn't approve plans; the
agent asks for feedback when it wants it.
- **A plan specifies; it doesn't implement:** per milestone, the goal, interfaces
and behaviour, edge cases, and **tests as acceptance criteria**, with code only
where code is the clearest specification. Detail is sized to the driver's
capability profile (L9). It may note where the agent will check in, and which
milestones change no code.
- **Progress is recorded in the plan itself,** milestones marked done by each PR.
The framework never parses a plan.
### D3. The workspace
**Each project has its own build VM** (X1), where its workspace lives and its
commands run. It is kept between work items (toolchains and package caches stay
warm), collected after the project has been idle long enough, and re-created from
the template plus the project's recorded toolchains. Work pushed to Gitea survives;
an unpushed commit may be lost when a VM is wiped, and that is accepted.
**A fresh, clean workspace when a work item starts:** fetch, check out a clean tree
at the latest default branch, and **set aside**, never silently discard, anything
left over (uncommitted changes, local branches, unpushed commits), reporting it.
Build artifacts and caches in the workspace never survive into a new task.
**Toolchain preflight:** when the workspace is prepared, the agent determines what
the project needs and installs any gap **before any planning**; toolchain versions
become facts (L6).
**The build VM is the agent's to change:** installing packages and toolchains there,
cleaning caches or reinstalling dependencies to fix a broken build are contained,
reported but not asked about. Every install is recorded (as a fact and in scope
memory) so the VM can be rebuilt; recurring ones go into the template (X4).
Installing on any other host is dangerous.
### D4. Git, branches and PRs
- **The harness owns branches;** plans and models never create or switch them.
- The workspace is never on the default branch: a work item starts on a branch of
its own.
- `start_milestone(name)` makes the harness cut the milestone's branch;
`finish_milestone(summary)` makes it commit, push, open the PR and record the
fact. The model decides when, from its plan.
- **Mistakes are made harmless:** finishing without a start cuts the branch then;
a second start while one is open is refused; work outside a milestone is still
on a harness branch.
- **Wait for the merge between milestones:** the next one starts from the default
branch only after the previous PR has merged (read from Gitea). Meanwhile the item
waits on the operator and the agent does other work. A review comment makes the
item ready (D5); a PR closed without merging makes it ready with that fact, and the
agent asks what next. Redoing the milestone gets a new branch and PR, and the
merge rule still applies to it.
- **Parking pushes the item's work:** its branch, and anything uncommitted to a side
branch of its own, never onto a PR's branch, which may have merged meanwhile.
Another item on the same project starts fresh, and this one resumes where it was.
- **Publish only the work:** what the agent changed, counted from where the
milestone started, never build artifacts; anything left out is reported.
- **Pushes run on the build VM** with a push credential (a deploy key, or a
push-only account's token) the model's commands never receive (L12); PRs are
opened from the agent's container with its API token, which never reaches a
build VM. Gitea is the only copy that has to survive.
- **Publishing is idempotent** (L8). A milestone with no changes is done if it wasn't
expected to change code; otherwise it's raised with the operator.
- **The agent never owns or administers a repository:** whoever does can turn
protection off. The operator (later `nisse-ops`) creates and protects
repositories; `dev-agent` is a collaborator.
- **Protection is verified, not assumed:** the agent's token must be refused when
pushing to the default branch and when merging without the approval the trust dial
requires. A test asserts both.
- **A runnable test service's address** goes in the PR description.
### D5. Review and merge
- **Operator comments on a PR** (conversation and inline) are instructions; the
agent answers with commits to the same PR, replying where the comment was made.
Intake is as in L8.
- **The PR may have moved on meanwhile.** After pushing a fix, the agent checks the
PR: if it merged before the fix landed, the fix goes out as a new PR; if it was
closed, the agent says so and asks.
- **Merging follows the trust dial** (L3). Today the operator merges. If the dial
lets the agent merge its own PRs once checks pass, branch protection must require
an approval the agent's account can't give, so Gitea enforces the rule.
### D6. Action classes
| Class | Examples |
|---|---|
| Harmless | Reading the repository, running builds and tests on the build VM, querying Gitea |
| Contained (in scope) | Editing the workspace; milestone branches, pushes and PRs; commenting on its issues and PRs; filing agreed issues (D1); changing its build VM (D3) |
| Dangerous | Merging to the default branch (unless the trust dial allows), deleting remote branches or others' work, changing repository settings, anything outside the item's repository or build VM |
Changing branches by hand is neither: it is refused, with a pointer to the
milestone tools, since the harness owns branches (D4).
### D7. Deployment and isolation
**The agent** is a container on the agent host (X1) with its state, secrets and API
tokens. It reaches LiteLLM, Gitea, Matrix and SSH to its build VMs, nothing else.
**Build VMs** (one per project, D3) are where untrusted content meets command
execution:
- **A real Docker daemon,** since projects build and run containers.
- **Sealed:** egress only to package sources and Gitea's git endpoint, plus what
its project entry (D8) declares its tests need (e.g. LiteLLM for the `nisse`
project itself), reviewed like any allowlist (X2). Ingress only SSH from the
agent host. No other route into RFC1918, and none to the agent host or the
tailnet; no Tailscale.
- **Their own VLAN,** with test services on predictable ports, and test containers
on an internal network with no route out.
- **A headless browser** reaches only the internal test network, to exercise what
the agent built. No SSO.
**Secrets are namespaced by project.** A build VM's commands receive only their
project's, and each secret's configuration says whether the model's commands may
see it or only harness operations use it (the push credential). Anything the model's
commands can see must be scoped for testing, never production.
**Provisioning:** build VMs come from a template via Terraform; eventually at
`nisse-dev`'s request to `nisse-ops` (an issue in the infrastructure repository,
O5), until then by the operator.
### D8. Projects
**A project is an entry in the agent's configuration,** reviewable and changed by
PR like any configuration (L12). An entry is what makes a project known to the
agent, and it holds everything per-project the rest of this part refers to:
- the repository and its default branch;
- the build VM and the template it comes from (D3);
- the trust dial (L3) and label defaults (e.g. always discuss first, D1);
- the project's secrets by name, each marked as visible to the model's commands or
harness-only (D7);
- egress its tests need beyond the default (D7).
**For a new project,** the agent proposes the entry alongside its provisioning
request (D1); the operator merges it when the repository and build VM exist. The
agent may propose entries and changes, but never changes the trust dial or a
secret's visibility itself.
---
## Part O — `nisse-ops`
Operates infrastructure across both sites, driven by issues and duties, with
stricter controls because live changes have no review step before they take effect.
### O1. How it differs
| Axis | `nisse-dev` | `nisse-ops` |
|---|---|---|
| Undo | discard a branch | often none (`zfs destroy`, a firewall lockout) |
| Scope | the assigned issue and its conversation | approved step sequences (O2) |
| Dangerous actions | discuss | discuss, and exact-step approval |
| Work shape | milestones → PRs | short reactive tasks → live changes |
| Intake | issues in the project's repository, Matrix | issues in the infrastructure repository, alerts, duties, Matrix |
| Reach | sealed build VMs | Tailscale, credentials, both sites |
They are separate deployments over one library; one instance playing both would
collapse the isolation.
### O2. Approved step sequences
**Every infrastructure write needs an approved plan,** and approval covers exactly
the steps written. This is ops' scope (L3) and a hard wall in its own right.
- **Write credentials exist only in elevated MCP instances** (X3), mounted after
approval; read-only ones are always mounted, so looking around needs no plan.
- **A plan is a list of literal steps:** tool and target, full arguments (a config
change carries its exact diff), an optional **precondition** checked just before,
an optional **expected outcome** checked just after, and a retry allowance
(default one). Prose explains; it is never scope.
- **The approval message is rendered by code from the steps,** flagging anything
irreversible, anything touching a protected target, and anything that takes down
the agent host (which runs every agent, ops included).
- **Execution is matched against the steps.** Reads are always allowed; an elevated
call runs only if it exactly matches the next unfinished step (arguments
normalised by schema). Anything else halts and asks. A failed precondition or
outcome halts without retry.
- **Approvals expire** if not started in time.
- **Policy is small, conservative data:** a versioned file declaring each MCP
instance's privilege, protected targets (the NAS, gateways, the agent host, tool
VMs) and each elevated tool's target argument. Anything unclassified is elevated
and protected. Server hints can only make a tool stricter. A model may propose
entries as a PR; nothing is classified at run time.
**Parked until real friction shows the shape:** bounded matching (calls within an
approved target and action, for reversible tools), and standing approvals (a
template with a trigger, rate limit and expiry, for a routine fix).
### O3. Action classes
| Class | Examples |
|---|---|
| Harmless | Metrics, logs, status, read-only MCP calls, issues, the world model |
| Contained (in scope) | Approved steps with a known undo; its own world model; commenting on and filing issues in the infrastructure repository |
| Dangerous | Every other infrastructure write; browser actions on admin UIs (each confirmed); **anything on network gear**, the most conservative case, since a bad change can sever the path to fix it |
### O4. Interfaces
- **MCP servers** for Proxmox and UniFi at both sites: the primary path (X3).
- **A browser** for admin UIs without an API; only a fallback for network gear.
- **SSO** as `svc-nisse-ops`, whose blast radius is its Authelia scope.
- **Event intake** as structured JSON (directly or via Alertmanager); curated
updates go to `#ops`.
### O5. Issues and duties
- **One infrastructure repository for both sites,** holding ops' issues (it need
hold no code). Sites are labels (`garrett-hall`, `hartman-hut`), so cross-site
work is one issue. Intake works as in D1: an issue assigned to the ops agent is a
work item and its record, labels steer, and Gitea dependencies mark it blocked.
Matrix is the live conversation.
- **Action is operator-scoped.** Issues from duties (findings, opened by ops itself
and deduplicated by fingerprint) and requests from other agents (L1, e.g.
`nisse-dev` asking for a build VM, which then waits blocked on that issue) may be
picked up and planned, but any write needs a step sequence the operator approves
(O2). Until standing approvals exist, that means every provisioning request.
- **Comments on the issue approve its plan** (`approve rev-N`), as an alternative to
Matrix (L3).
### O6. Deployment
- **A container on the agent host** (X1), with its MCP servers beside it.
- **Its own tool VM,** long-lived, for network tools and its commands (L13), with
Tailscale and the reach they need at both sites.
- **Egress** includes the Proxmox and UniFi endpoints at both sites.
- **Provisions build VMs for `nisse-dev`** through Terraform, under O2.
---
## Part X — Deployment (all agents)
### X1. The agent host and tool VMs
**One agent host,** stable and long-lived. Each agent is a container on it with its
own volume (state, transcripts), secrets (L12) and network; no agent can reach
another's. Its MCP servers run beside it (X3). The vault lives here (L10). **It never
runs a command a model chose** (L13), which is what makes sharing it safe.
**Tool VMs** are where model-chosen commands run, reached only over SSH from the
agent host, each built from a template plus recorded installs and disposable:
- `nisse-dev`: a build VM per project (D3, D7);
- `nisse-ops`: its own tool VM (O6);
- a purely conversational agent: none.
**Per-agent attribution:** everything an agent holds is issued to its own identity,
so every upstream action traces to one agent.
### X2. Egress
- **Default-deny allowlists,** versioned next to the agent's definition, each entry
justified by what needs it.
- **Agent containers:** the union of what the agent and its MCP servers reach,
enforced by Docker networking and the agent host's firewall. The site firewall
sees the host as one address; that is acceptable because nothing on the host
executes untrusted input.
- **Tool VMs:** enforced at the VM, its Docker network and the site firewall on its
VLAN, because this is where untrusted input meets execution. They never reach the
agent host except as replies on its SSH sessions.
- **The tailnet is `100.64.0.0/10`,** not RFC1918; deny it explicitly where it isn't
needed.
### X3. MCP servers
- **One instance per agent,** beside it on the agent host: a shared process would
share a credential and lose attribution in the upstream audit log.
- **One instance per (service, site, privilege),** each with its own credentials, so
a wrong-site or wrong-privilege action needs a tool that isn't mounted.
- **Per-task tool profiles** mount only what a work item's scope needs.
- **Rejected: search-then-dispatch meta-tools** over large catalogues (extra round
trips for a local driver, opaque arguments, a tool list that defeats caching).
### X4. Images, VMs and restarts
- **Stateless images, named volumes** for state, on the agent host. Images are
built by Gitea Actions and pulled by Watchtower, which **never restarts an agent
mid-work** (an idle flag gates the restart); crashes are handled by L8.
- **Third-party MCP images are digest-pinned** and updated deliberately: they hold
root-equivalent credentials.
- **Tool VMs come from templates** provisioned by Terraform; recurring installs are
baked in. They are rebuilt, never snapshotted.
- **The agent host's state is snapshotted** (scheduled, and on each approval), and
everything restarts on boot.
### X5. Supporting services
All self-hosted: Matrix (Synapse), Gitea, LiteLLM, the local inference host,
Vaultwarden, LLDAP with Authelia, and Syncthing for the operator's copy of
the vault. Each agent has its own account, token and room in each; nothing is
shared between agents.
---
## Part Q — Open questions
- **The classifier's model,** and how its verdicts are calibrated against overrides.
- **Recognising questions:** an explicit marker or classification; a misread asks.
- **How far the local driver goes,** measured on real work (P7).
- **One local model for every agent:** preferred, since the inference host keeps
one resident; otherwise agents take turns. Measured first.
- **Vision,** for 3D design and video work: images in tools, the log and the budget.
- **More than one active work item:** needs per-item serialisation, an inference
scheduler and per-item intents. Build VMs and threads already separate workspaces
and conversations.
- **Stacked PRs:** starting a milestone before the previous one merges, after
experience with waiting (D4).
- **Build VM collection:** how long idle, and whether it needs the operator's word.
- **Where secrets come from:** by hand, synced from Vaultwarden, or Terraform.
- **How ops' MCP servers reach the second site** from a shared host (Tailscale on
the host serves every container).
- **Browser access to network gear:** standing, or handed off per task.
- **Relocating a stateful MCP server** off the agent host, if one ever justifies
losing attribution.
- **The state store's engine.** SQLite, one database per agent, for now. If
scaling or backups call for it, a server database such as PostgreSQL on the agent
host (still one database and role per agent).
- **`nisse-ops` issuing credentials** to other agents: an SSH key for a new VM, Gitea
credentials for a new project. Writing into another agent's secret set crosses
the isolation in L12, so it needs a designed channel and its own approval rule.
- **Releases.** Building and publishing artifacts at each release, usually by tag,
sometimes from release branches: what the agent may do (a release is visible
beyond its own area, so dangerous unless the trust dial says otherwise) and how a
build plan names one.
- **Recovering the agent host.** It is a single point of failure for every agent,
`nisse-ops` included, so its recovery can't depend on ops running. A recovery
plan (rebuilding the host, restoring state from snapshots) is ops work still to
be written.