From a18294fa0c3cbf3903bce94467c1c70978088faf Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Wed, 30 Sep 2026 15:50:24 +0000 Subject: [PATCH] Draft: rewrite the architecture as docs/Architecture.md A full rewrite of the architecture, following the outline agreed with the operator: a threat model of mistakes, injection and crashes rather than a malicious model; actions classed by their effects; hard walls, a deterministic fast path and an action classifier; a conversational loop driven by a local model with frontier helpers; approvals as scoped permissions with pre-approved milestones and a trust dial; context and memory, including agent memory; documents as nisse-dev's workflow. Added beside docs/Architecture-Build-Spec.md, which stays until this is agreed; the Development Plan and reference updates come after. New section letters (P, L, D, O, X, Q) avoid clashing with the old A-H references in the code meanwhile. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE --- docs/Architecture.md | 1004 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1004 insertions(+) create mode 100644 docs/Architecture.md diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..2b5dbbb --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,1004 @@ +# Nisse — Architecture + +**Status: draft for review.** This document is to replace the architecture parts of +`docs/Architecture-Build-Spec.md`. How and in what order it gets built will go in a +separate document, `docs/Development-Plan.md`, written once this one is agreed. +Until then the old spec stays in place, and code comments still cite its section +numbers (A1–A18, B, C, F). This document uses different letters (P, L, D, O, X, Q) +so the two can't be confused while the references are updated. + +This document says what Nisse is, how its parts fit together, and why. It records +decisions and the reasons for them. It doesn't record status or sequencing. + +--- + +## 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 (for example 3D design work and video-file management). +Each is another application over `nisse-core`. + +**Dev Agent** and **Sysadmin Agent** name the *roles* in discussion; `nisse-dev` and +`nisse-ops` name the *artifacts*: packages, container images, VM hostnames, Matrix +identities, service accounts. One string is used for all of an agent's artifacts, +so grepping the infrastructure for `nisse-ops` finds everything. + +**Sites** are identified by their domain stem: `garrett-hall` (hosts under +`garrett-hall.com`) and `hartman-hut` (hosts under `hartman-hut.com`). As with +agents, one string is used everywhere a site appears, including MCP server +instances (X3). + +All artifacts live as packages in **one repository, `nisse`**. The repository +boundary was never a trust boundary in this design (the VM is, X1); a monorepo +avoids tripled admin for a solo operator and keeps `nisse-core` easy to change +while its seams are still settling. + +--- + +## Part P — Principles + +### P1. What Nisse is + +Nisse is a set of self-hosted agents that **do real work and talk about it**. Each +agent is a collaborator the operator converses with in Matrix: it explains what it +is doing and why, answers questions about its plans and results, asks when it is +unsure, and summarises what it did. It is not a command interpreter behind a menu +of keywords. + +The agents run **local-first** (P7): a model on the operator's own inference +hardware does the work, and frontier models are called in when it needs help. + +The agents share one library, `nisse-core` (Part L). Each agent adds what is +specific to its job: `nisse-dev` works on software projects in a sealed VM +(Part D); `nisse-ops` operates live infrastructure across two sites (Part O). + +### P2. Threat model + +The checks in this design exist to stop **mistakes and manipulation** from doing +damage. They are not built around a model that schemes against the operator. +In order of how often they matter: + +1. **Honest mistakes.** A wrong command, a misread instruction, deleting the wrong + thing, confidently wrong code, an action taken on a stale belief. By far the + most common. +2. **Prompt injection.** An agent reads untrusted content: issue and comment text, + repository files, dependencies, web pages, alert payloads, log lines. That + content can steer a well-meaning model into acting as if it were malicious. No + model reliably tells instructions from data (L11). +3. **Crashes and environment surprises.** A process dies mid-action, a host + reboots, a workspace is stale or corrupt, a tool is missing. + +Each calls for something different: +- **Mistakes** call for reversibility, visibility and a second look before + anything irreversible (P3, P5). +- **Injection** calls for a small number of hard boundaries exactly where + untrusted input meets real power (P4): a steered model can't be trusted to ask + first. +- **Crashes** call for durable state and idempotent effects (L8). + +An agent's *intentions* are never the thing a check relies on, in either +direction: a check that assumes the model is honest fails under injection, and a +workflow built around containing a hostile model makes the agent useless. + +### P3. Actions by their effects + +Every action any agent can take falls into one of three classes: + +| Class | What it is | Examples | Rule | +|---|---|---|---| +| **1. No side effects** | Observes; changes nothing outside a disposable sandbox | Reading files, querying APIs, searching, viewing logs and metrics, running a build or tests in a throwaway environment | Always allowed | +| **2. Contained side effects** | Changes something, but the change 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 the agent's own notes, starting a test container; for ops, a step with a known undo inside an approved plan | Allowed when in scope; logged and reported | +| **3. Dangerous** | Irreversible, visible beyond the agent's own area, or outside what was agreed | Merging to a default branch, deleting data, changing a firewall, installing software on a host, contacting third parties, spending beyond a budget | Needs discussion, unless an approved plan names the exact action | + +**"In scope"** means within what the operator agreed to: the work item's task, +an approved plan, a pre-approved milestone (D2), or an approved step sequence +(O2). An action can be class 2 in one work item and class 3 in another. + +**Move actions down the classes wherever it is cheap.** Reversibility is designed +in, not hoped for (P5). + +### P4. Three layers of control + +1. **Hard walls, outside any model.** Things no model conclusion can change: + credentials the agent doesn't hold, network destinations it can't reach + (X2), branches it can't push to or merge into, budget caps, the kill switch, + and durable, idempotent external effects (L8). These hold even if every model + in the system is fooled. +2. **A deterministic fast path.** Class-1 actions, and class-2 actions a static + rule already recognises as in scope, proceed without any model being asked. + Most actions take this path. +3. **The action classifier** (L4) for everything else: an independent model call + that sorts an action into *safe*, *dangerous but in scope*, or *dangerous, + needs discussion*, judging the action against the agreed scope. + +**Conversation sits above all three.** When anything needs discussion, the agent +asks, in plain language, and waits. + +The classifier is a filter for mistakes and injection, not a guarantee. Anything +truly irreversible also sits behind a hard wall. + +### P5. Reversibility first + +- Work happens on **branches**, never directly on what others depend on. +- A task starts from a **fresh, clean workspace** (D3), so a stale or broken + environment is replaced rather than diagnosed. +- Durable state is **snapshotted** (X4), and snapshots are taken before risky + changes where the platform allows it. +- Where a tool offers a **dry run** or a preview, the agent uses it before the + real action. +- Build artifacts and caches are **disposable**; nothing depends on them. + +### P6. Structural, not model judgment + +Anything that 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 to fix. Examples from building `nisse-dev`: adopting an already-open PR after +a crash (L8), redelivering an interrupted review comment (L8), binding approvals to +the plan they were about (L3), never publishing build artifacts (D4). + +Models are trusted outright only where a wrong choice costs time rather than +safety, such as choosing which ready work item to switch to (L1). + +### P7. Local first, frontier on call + +- **A local model drives every step:** conversation, planning, implementation and + review. It runs on the operator's own inference host, behind LiteLLM (L9). +- **Frontier models are helpers the driver calls:** a quick question + (`ask_expert`), or taking over a scoped sub-task (delegation). Both are + described in L9. +- **The driver is prompted to ask for help when it isn't confident,** and told + that doing so is expected, not a failure. Because small models are poorly + calibrated about their own competence, **observable triggers** escalate too: + repeated failures and hitting caps (L9). Escalation never depends on the model's + self-assessment alone. +- **This is an experiment with a cheap exit.** The driver is a role in + configuration. If local models fall short at some kind of work, a frontier model + drives that work instead: a configuration change and prompt tuning, not a + redesign. + +### P8. Carried principles + +- **Isolation matched to blast radius.** `nisse-dev` is disposable and sealed; + `nisse-ops` holds live-infrastructure reach. They never share a running + instance, and every agent gets its own VM (X1). +- **Stateless images, stateful volumes.** Containers are replaceable; durable + state lives on volumes that survive redeploys and unexpected reboots. +- **Structured channels for machines, prose channels for humans.** Machine intake + (Gitea, Vikunja, alerts) is structured JSON, never routed through a chat bridge. +- **Own the stack.** Everything self-hostable stays inside the network where + practical. +- **Nothing remotely held is load-bearing.** No provider holds state the system + needs to resume, and Matrix room history is never a source of truth. + +--- + +## Part L — The shared library (`nisse-core`) + +Everything in this part is used by every agent. + +### L1. The work model + +Every agent's work is organised into three kinds of unit. + +| Concept | What it is | Lifetime | `nisse-dev` | `nisse-ops` | +|---|---|---|---|---| +| **Duty** | A standing responsibility that produces work items | Recurring | None yet; the operator is the only source | Scheduled checks, log monitoring, alert intake | +| **Work item** | One effort, opened and eventually closed | Opened → closed | Building a feature in a repo | Diagnosing a failure; making one change | +| **Scope** | What a work item operates on, and the context that persists across work items there | Indefinite | A repo | A site and service, e.g. `garrett-hall` Proxmox | + +- **A duty produces work items; it is not one.** A duty run is read-only and + yields findings; a finding that needs attention becomes a work item. Duties + deduplicate: a later finding for the same problem attaches to the open work item + instead of opening a second (a stable fingerprint per finding). +- **The operator is always a source** of work items. +- **A work item is the unit of state:** its conversation, plans and approvals, + pending questions, pinned role mapping (L9) and transcript (L7) all belong to it. + Its ID is minted locally when it opens and doubles as its correlation ID in logs. +- **An external record is optional and linked:** a Gitea issue for dev work, a + Vikunja ticket for ops. When one is linked, external truth wins on divergence + (L8). +- **Work items don't nest.** A milestone is a phase within a dev work item; a + delegation is an episode within one. +- **Scopes hold context shared across work items** (L6): for a repo, its documents + and conventions; for infrastructure, the world model of that site or service. + +**Park-and-switch: one active work item per agent.** +- Other work items are *parked*, with their full position held durably. +- **Switching happens only at safe points,** when the active item is waiting on + the operator or has closed, and never while intents are unresolved (L8). +- **The operator can always switch.** The agent may switch on its own to a *ready* + item (one it can act on without the operator) when the active item is waiting on + the operator, after a grace period (default 10 minutes, so a watching operator + can answer first), or has closed. +- **The driver chooses which ready item,** from code-rendered summaries (source, + age, severity, state, title), never from other items' transcripts. A failed or + invalid choice falls back to the oldest ready item. Choosing grants nothing: every + item still meets its own rules. +- **Parking gives up authority.** Elevated credentials an item's plan mounted + (O2, X3) are unmounted when it parks, and remounted only if its approval hasn't + expired. +- **Nothing is lost while parked.** Intake for a parked item is read from a + durable cursor when it becomes active again (L8). +- **The state schema doesn't assume one item.** "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 (P7). The +loop talks with the operator, reads and writes the work item's documents, calls +tools, calls frontier helpers when it needs them, and reports. It is not a fixed +workflow of states the conversation must pass through. + +What is fixed is small: +- **Which work item is active,** and which are parked (L1). +- **Pending questions:** a question the agent asked is recorded durably, so a + restart asks it again rather than losing it. +- **Approvals as permissions** (L3), checked when an action needs one, not as + states the loop must visit. +- **Work in progress,** recorded as intents (L8), so a crash lands somewhere known. + +Rules for the loop: +- **The agent never waits in a state it can't explain or leave.** Every wait is + either for the operator (with a question or proposal they can see) or for work + it has started. Anything else is a bug; the model checker (`specs/`) guards this. +- **Questions never change state** (L5). An operator's question gets an answer, + never an interpretation as approval, rejection or command. +- **`stop` works in every state,** for every agent: it ends the current turn or + delegation at the next tool boundary, unmounts elevated credentials, and asks + the operator what next. Unattended work needs an end that doesn't depend on the + model cooperating. +- **`abandon` ends a work item** from any waiting point, closing its external + record with a note and aborting unresolved intents. +- **Every action passes through the three layers** (P4) before it runs. + +### L3. Authority: approvals and permissions + +An approval grants a **permission with a scope**: "this build plan may change +this repo", "these ops steps may run", "this milestone may proceed without +discussion". Actions are checked against the permissions in force (P3); the loop +does not stop at gates for their own sake. + +**What needs discussion before it happens:** +- **Architecture documents and build plans are always discussed** with the + operator (D2). Their approval is the scope for the work that follows. +- A build plan marks each milestone **pre-approved** (proceed when reached) or + **needs discussion** (stop and talk first). +- **Any class-3 action** not named by an approved plan (P3). + +**Approvals bind to exactly what was approved.** +- Every plan revision carries a tag, numbered across the whole work item, so + `rev-N` names exactly one plan. +- **A reply that names no revision is about the one up when it arrived:** a bare + `approve` or any feedback is stamped on arrival with the revision last put up + (in Matrix, the one whose request had been sent; in Gitea, the latest plan + comment older than the reply). If that isn't the current revision, the reply is + not applied, and the agent says which revision is up. A repeated approval, an + approval on two channels, or an afterthought therefore can never land on the next + plan. (Found by the model checker.) +- **Approval messages are rendered by code from what will actually happen,** never + from a model's description of it (L11). +- **Approvals expire** where the world can change underneath them (ops, O2). + +**Approval sources.** Matrix (L5), Gitea comments (D2) and Vikunja comments (O5) +are approval sources; each authenticates the approver against its own operator +allowlist. The first valid approval wins and is echoed to the other channels. The +knowledge store (L10) is **never** an approval source or an instruction channel. + +**The trust dial.** How much the operator reviews after the fact is **data the +operator sets**, per project and per milestone: for example, whether PR review is +required before merging, or whether the agent may merge a pre-approved milestone +itself once checks pass. The operator turns it up as confidence grows. The agent +never changes it. + +### L4. The action classifier + +An independent check that sorts an action the fast path doesn't cover. + +**Verdicts:** +- **safe** — proceed. +- **dangerous but in scope** — proceed, logged and reported to the operator. +- **dangerous, needs discussion** — pause and ask, in plain language. + +**Rules:** +- **It judges the action, not the actor's story.** Its input is the structured + action (tool, command, target, a summary of the change) and the **agreed scope** + (the work item's task, the approved plan or milestone). Never the actor's + transcript or reasoning, and never untrusted content such as issue text or web + pages. Otherwise injected text, or the actor's own rationalisation, can argue past + it. +- **It fails closed.** An error, a timeout or an unclear verdict counts as *needs + discussion*. +- **It is independent of the actor:** its own prompt and context, ideally a + different model. +- **Hard walls don't depend on it** (P4). +- **Every verdict is logged** with its outcome. Operator overrides ("that was + fine" / "that should have asked") become an evaluation set (L9), and patterns it + keeps agreeing on become deterministic fast-path rules. + +The classifier is shared by all agents. Each agent contributes its own fast-path +rules and scope sources (D6, O3). + +### L5. Conversation and transport + +**Transport.** Matrix is the conversation channel: a self-hosted homeserver, one +identity and room per agent. The orchestrator depends only on a `Transport` +interface (send, ask, receive), so another chat system is one new class. +Existing Matrix clients are the UI. + +**What the operator can say.** Plain language, plus a small command vocabulary +that works in every state: `status`, `stop`, `abandon`, `approve [rev-N]`, and +work-item selection (e.g. `use `). +- **Commands are matched without regard to case, surrounding whitespace, trailing + punctuation, or a leading mention of the agent.** Messages that begin by + addressing someone else are not for this agent. +- **Questions never change state.** A question gets an answer from the agent, + drawn from the work item's context, and is never read as approval, rejection or + a command. How a question is recognised (explicit marker or classification) is + an open question (Q); a misread must default to asking, never to acting. +- **Everything the operator says is recorded** in the work item's transcript + (L7), even when no action follows. + +**What the agent says.** +- **It explains as well as acts:** a plan comes with its rationale and the main + alternatives considered; a revision says what changed and why; finished work is + summarised (what was done, what was verified, what is left open). +- **It reports what happened to the operator's message** when it can't act on it + now ("noted; I'll use that once you say which project"), rather than refusing. +- **Progress is prose, at a readable rate:** what it is working on and what it has + found. Raw tool activity is available on request. +- **It asks when unsure**, including in the middle of implementation. + +**Other channels.** Gitea (issues, PR comments, reviews) and Vikunja (tickets, +comments) carry work and records. They are polled or received as structured JSON. +Comments from allowlisted operators are instructions or approvals; comments from +anyone else are data (L11). + +### L6. Context and memory + +What each model sees, what persists, and how memory is written and used. + +**1. The working context of one model call.** Rendered from the log at call time, +for one role, fitted to that model's window. It is never stored; only the log is. +Fitting, in order: shorten old tool results to their first and last lines; drop +the oldest events (with a note saying so); never drop the system prompt, the +approved plan or the most recent turns. A call that still doesn't fit is not sent; +the caller treats it as a failure (L9). + +**2. The work-item transcript** — the canonical log (L7). +- **Durable, and scoped to one work item.** A model call for a work item renders + only that item's events. Nothing crosses items; agent-level calls (switch + decisions, duty runs) see other items only as code-rendered summaries. +- **Every entry is attributed:** the operator, the driver, a frontier helper, or + the orchestrator. +- **The orchestrator writes facts** into it: workspace prepared, milestone + executed, commit made, PR opened or merged, approval given. Models learn what + happened from these, not by inferring it. +- **Each role gets its own view:** + - the driver sees the conversation, the facts, and helpers' reports; + - a helper (delegation, `ask_expert`) sees its brief and its own work; + - no role sees another role's raw turns presented as its own. + +**3. Scope memory** — outlives work items. +- **For a repo:** the project's own documents (`ARCHITECTURE.md`, build plans, + conventions), versioned in the repo and changed by PR (D2). Read at work-item + start; the repo is the authority. +- **For infrastructure:** the world model in the knowledge store (L10): what + exists and how it is arranged. + +**4. Agent memory** — the agent's own notes, outliving work items and scopes. +- **What it holds:** the operator's preferences, lessons learned, recurring + decisions and their reasons. Much like a good collaborator's notebook. +- **Where:** the agent's private space in the knowledge store (L10), one note per + fact, with an index. +- **Written by the agent** at defined moments (below); **visible to the operator**, + who corrects it through conversation, not by editing. +- **Read at work-item start** (the index, plus notes relevant to the task) and when + the agent is about to make a decision the notes cover. + +**5. What is never memory:** Matrix room history, provider-side session state, +build artifacts, and the contents of a previous task's workspace. + +**How memory is written.** +- **At work-item close:** a summary of what was done and decided, into scope + memory where it belongs to the scope (a repo document change goes through a PR), + and into agent memory where it concerns the operator or the agent's own practice. +- **When a fact is learned:** with its source and when it was observed. +- **Operator corrections are stored verbatim, with attribution,** never + paraphrased. A model rewriting a precise fact loses precision invisibly. +- **One writer per document** (L10), so concurrent writes never happen. + +**How memory is used.** +- **Everything read is snapshotted into the transcript** with its provenance + (document, revision, when retrieved), once per work item at first use. +- **Memory is a cache, never authority.** An agent may rely on it to know what + exists and where to look; facts an action depends on are re-checked against the + live system immediately before the action (preconditions, O2). When a proposal + rests on recalled facts, it says so. +- **Stale memory is corrected, not worked around:** when a check contradicts a + note, the agent updates the note and says so. + +### L7. The canonical log + +The durable record of every work item is a **canonical event log** in the state +store, not 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` | text, actor (role) and model | +| `tool_call` | canonical call ID, actor, tool, arguments | +| `tool_result` | canonical call ID, result, success | +| `plan_submitted` | plan body, revision, authoring model | +| `fact` | what the orchestrator observed or did (PR opened, milestone executed, …) | +| `delegation_started` / `delegation_completed` | brief, target model, caps / handback note, artifacts, tokens, duration, status | +| `classifier_verdict` | action, scope reference, verdict, model | +| `knowledge_retrieved` | text as read, document, revision, retrieved-at | +| `compaction` | summary, producing model, range replaced | + +Rules: +- **Every event carries its work item and its actor.** Rendering uses both (L6). +- **Canonical IDs are minted locally.** Provider tool-call IDs are mapped at the + boundary and never used as join keys. +- **No provider-side handles for content.** Bytes or local paths are stored and + uploaded or inlined at render time. +- **Vendor-opaque reasoning artifacts** (signed thinking blocks, encrypted + reasoning items, thought signatures) are stored with the producing model and + replayed only within the session that produced them, per that vendor's rules. A + session is a delegation, including one resumed after a crash. +- **Token budgeting is a render-time concern** (L6). +- **Compaction summaries are first-class events,** so a model change never + inherits opaquely compacted history. +- **Prompt-cache breakpoints are placed by the renderer, never stored.** +- **The log is never edited.** Corrections are new events. + +### L8. Durability and recovery + +**The state store.** SQLite in WAL mode. It holds each work item's position, +permissions, pending questions, cursors, pinned roles, and the canonical log. +Build artifacts, workspaces and shipped-out logs are **not** in it. +- **Checkpoint every change,** committed and fsynced before the loop continues. +- **A schema version and migration hook** from the first commit, so a newer image + reads older state. + +**Intents and idempotent effects.** +- **Before any external side effect, record an intent** (open a PR, push, merge, + run an ops step). After it, commit the intent. +- **Every external effect is idempotent against external truth.** Re-running an + interrupted step must detect that the effect already happened and adopt it. For + example, publishing adopts an open PR for the branch instead of opening a second. +- **Intake is handled at most once and answered at least once.** A durable cursor + per external thread (Gitea issue or PR, Vikunja ticket) records what was taken + up; an item being worked on is marked until its response is complete, and + redelivered after a crash. Comments that arrive while the agent can't act on + them are not consumed. + +**Boot reconciliation.** Every start, before accepting input: +1. Open the store; run pending migrations. +2. **Reconcile pending intents against external truth.** Verified → committed. + Verifiably never happened → aborted. Unverifiable (the service is down) → ask + the operator. +3. **Adopt what external truth shows:** a published PR for a milestone that was + still executing, an approval posted while the agent was down. +4. **Resume waiting points as they were:** a pending question is asked again, an + open plan is announced again, watches on external threads resume from their + cursors. +5. **Interrupted work resumes only on evidence.** + - *A delegation* resumes automatically when the cut is clean (last event a + completed result, branch at the expected commit, caps unbreached, external + truth matching the log). Otherwise ask. + - *An ops step sequence* continues only when the log names exactly which step + was in flight and its expected outcome checks out against the live system, + and the approval hasn't expired (O2). Otherwise ask. + - *Anything else* is reported to the operator with what is known ("restarted + mid-milestone 2, branch at commit abc123"), with the options to continue, + re-plan or abandon. Never a blind resume. + +**Verification.** The recovery rules are checked by a formal model of the loop +(`specs/`, Quint): interleavings of messages, comments, outcomes and crashes at any +point. Each counterexample it finds is reproduced against the real code before it +is fixed. + +### L9. Models, roles and helpers + +**LiteLLM in front of everything.** Each agent VM has exactly one LLM destination: +LiteLLM, which fans out to the local inference host and to frontier vendors. It +holds every vendor key (the agent holds one LiteLLM token), enforces budget caps +per virtual key, logs centrally, and routes fallbacks. Adding a vendor never +touches an agent VM. + +**One client interface:** `complete(events, tools, model) → events`, over the +canonical log. Delegations use LiteLLM's passthrough route with a per-vendor +renderer, so vendor features aren't lost where long sessions live; quick calls use +the normalised route. + +**Roles.** Models are named by role and resolved in configuration: + +```yaml +roles: + driver: local/ # drives the loop: conversation, plans, implementation + helper: # delegations: takes over a scoped sub-task + expert: # ask_expert: one question, no tools + classifier: # the action classifier (L4) +``` + +The resolved mapping is **pinned when a work item opens** and recorded in its log, +so a configuration change can't split a work item across two models. + +**Capability profiles.** Each model in configuration carries a short profile, +filled in from benchmark results: its tools, window, what it does reliably and +where it is weak. Prompts that brief one role about another use the profile, never +a model name or a vague "weaker model". + +**Escalation.** The driver calls a frontier helper: +- **when it chooses to** — its prompt encourages asking when unsure, and treats + asking as normal; +- **on observable triggers** — repeated failed actions, a context that won't fit, + hitting turn or time caps. + +**Delegation.** A frontier model takes over a scoped sub-task with tools, works +autonomously, and hands back. +- **One harness, not one per vendor.** Vendor agent SDKs would bring back the + lock-in the design avoids. +- **Scoped by the agreed plan.** A delegation can't widen what the work item was + agreed to do; its actions pass through the same three layers (P4). +- **Branch at delegation start;** abandoning it discards the branch. +- **Hard caps** on turns, tokens and wall-clock time. +- **Checkpointed as it goes,** so it can resume after a crash (L8). +- **Handback is a note written for the driver:** what changed, what is now true, + what to do next. That note plus the resulting files is the whole interface back. + +**`ask_expert`.** One stateless question with a text answer, for things needing no +repository access. A per-work-item budget stops a confused driver circling. + +**Local-model accommodations.** +- **Tool-call enforcement:** `tool_choice` is requested only of models whose server + declares it can enforce it; otherwise the loop enforces the contract itself (an + explicit `finish`, prose treated as a violation). +- **A reduced tool schema** for roles on small models: fewer tools, flatter + arguments. + +**Evaluation capture.** Delegations, `ask_expert` calls and classifier verdicts +are recorded as input/output pairs from the start, with outcomes marked. Changing +a model is then a configuration change and a re-run, not a matter of benchmark +chatter. + +### L10. Knowledge store (Outline) + +Durable knowledge that outlives work items: world models, agent memory (L6), and +later operator-written procedures. + +**Layout: one writer per document.** +- **One collection per writing principal:** `nisse-ops-world`, `nisse-dev-world`, + each agent's memory, and `procedures` (the operator's). Sharing grants read + access only; concurrent writers never exist. +- **`nisse-dev` gets private space only.** Shared infrastructure documents are a + map of exactly what its sealed network keeps it from (D7). +- **Each agent authenticates as its own Outline user,** with its own API token. A + token from the operator's account would silently void every permission here. +- **Collections and documents are identified by ID** from configuration and state, + never discovered, and document IDs recorded when created make writes idempotent. + +**Never an instruction channel, never an approval channel.** Work is never read +out of the knowledge store, and an agent never writes there to make another agent +act. Coordination between agents goes through the operator, Matrix, or a ticket. + +**Corrections go through conversation.** The operator tells the agent, which +records the correction verbatim (L6). The operator keeps write permission as a +break-glass: stop the agent, edit, restart. An agent re-reads a document's +revision before writing and merges rather than overwrites if it moved. + +**Retrieval is structural first.** Procedures are bound to entries in ops policy +(O2) and fetched whenever a plan uses that entry; search exists in addition, never +instead. + +**Outages.** A failed write never blocks work (retry, or record locally and +reconcile). A failed read of a bound procedure stops planning that action; other +failed reads proceed and log the gap. + +### L11. Prompt injection + +**Any model that reads untrusted content may be steered by it.** No marker reliably +separates data from instructions. The design limits what a steered model can do +rather than trying to stop it being steered. + +Untrusted content: issue, comment and ticket text from non-operators, repository +contents, dependencies, web pages, alert payloads, log lines. + +- **Hard walls hold against a steered model** (P4): nothing a model says can grant + a permission, widen an approved scope, or reach a credential that isn't mounted. +- **Approval messages are rendered by code** from what will run, never from a + model's description. +- **The classifier never sees untrusted content** (L4). +- **Instruction channels are operator-scoped:** Matrix from allowlisted + identities, comments from allowlisted Gitea and Vikunja accounts. Everything + else is data. +- **Intake is parsed into structured fields in code** where the source allows it, + so less free text reaches a model, and what does is labelled by source. +- **Some injection is accepted because it can only reorder work:** an alert title + can bias which ready item is chosen next; the switch notice makes it visible. +- **Each agent is checked against the lethal trifecta** — private data, untrusted + content, and a way to send data out. Damage needs all three. `nisse-dev` holds + little private data and can reach only LiteLLM, Gitea and package registries; + `nisse-ops` has all three legs and relies on its hard walls and step sequences + (O2). + +### L12. Secrets, identity and logging + +**Secrets.** Fetched at run time from Vaultwarden machine accounts, never baked +into images. One minimally scoped machine account per agent and one per MCP server +instance (X3); each has a single bootstrap token on its own host, and nothing +else is stored there. Only credentials go in secret stores; every other setting +lives in reviewable configuration files. + +**Identity.** Each agent is its own LLDAP principal (`svc-nisse-dev`, +`svc-nisse-ops`) in a tightly scoped Authelia group, never the operator's account. +Headless agents get a single-factor exception scoped by source address, group and +target. + +**Logging.** Structured JSON lines to the host journal and the external log stack, +not kept in containers. Every durable state change emits exactly one line, written +by the state store right after the commit, with a fixed envelope (time, agent, +event, work item, cause). Every classifier verdict, escalation and external effect +is logged. **Logs are diagnostic, never load-bearing:** nothing reads them to +derive state. + +--- + +## Part D — `nisse-dev` + +Designs and builds software projects, producing reviewed changes in Gitea. + +### D1. How it works + +A dev work item is a task on one repository: a feature, a fix, a new project. +Through conversation, the agent and the operator agree on an architecture (for a +new project, or a significant change) and a build plan; the agent then works +through the plan's milestones, each producing a PR, and keeps the project's +documents up to date as it goes. + +`use ` opens or resumes a work item on a repository. Messages sent before +it are kept for it (L5). + +### D2. Documents are the workflow + +The project's own documents carry the agreement between the operator and the +agent, and live in its repository: + +- **`ARCHITECTURE.md`** — what the project is, how it fits together, and why. The + durable "what and why". Created with a new project; changed whenever the design + changes. +- **Build plans** — how a piece of work gets done, as a sequence of milestones. + Short-lived: one per body of work, kept in the repo for the record. + +**Both are always discussed.** The agent drafts, the operator and agent talk it +through, and the document lands as a PR the operator approves. An approved +architecture and build plan are the scope (L3) for everything that follows. + +**A build plan specifies; it doesn't implement.** Each milestone states its goal, +the interfaces and behaviour it adds or changes, edge cases, and **tests as +acceptance criteria**. It includes code only where the code *is* the clearest +specification (a tricky algorithm, an exact format). It never manages branches +(D4). How much detail it needs is informed by the driver's capability profile +(L9). + +**Each milestone is marked** pre-approved (proceed when reached) or needs discussion +(stop and talk first), and says whether it is expected to change code (a review or +investigation milestone may not). + +The Gitea issue for the work item carries the conversation's record: plans and +approvals are posted there as comments, so the agreement survives loss of the +agent's state (L8). + +### D3. The workspace + +**A fresh, clean workspace at `use `.** +- Fetch from the repository, and check out a clean tree at the latest base branch. +- Anything left in a previous workspace (uncommitted changes, local branches, + unpushed commits) is **set aside**, never silently discarded or carried over, + and reported to the operator. +- Build artifacts and caches never survive into a new task. + +The milestones of one work item share that workspace, since they build on each +other, often before earlier PRs are merged. + +**Toolchain preflight.** When the workspace is prepared, the agent determines what +the project needs (from its files, e.g. `Cargo.toml`, `pyproject.toml`, +`package.json`, or a declaration in the repo) and checks it is available. A gap is +reported **before any planning**, with the options. The environment's toolchain +versions become facts in the transcript (L6), so plans match reality. + +**Toolchains come from the environment, not from the agent improvising.** +Installing software is class 3 (P3). How toolchains are provided (project dev +containers on the VM's Docker daemon, or the VM image) is an open question (Q). + +**Repairing the environment is in scope.** Within a task, the agent may clean +caches and build artifacts or reinstall project dependencies to fix a broken +build, and reports what it did. It doesn't stop and wait for permission to delete +a corrupt `.pyc`. + +### D4. Git, branches and PRs + +- **The harness owns branches.** Each milestone works on a branch the harness + creates from the work item's current state; plans and models never create or + switch branches. +- **Only the milestone's own changes are published:** what the agent changed, + never build artifacts (standard excludes in the clone, and committing only what + the work produced). Anything left out is reported. +- **"Changes to publish" are counted from where the milestone started,** never + from a possibly stale remote branch. +- **Publishing is idempotent** (L8): an open PR for the branch is adopted and + updated, never duplicated. +- **A milestone that completes without changes is done, not failed,** when it + wasn't expected to change code. The agent reports its result and moves on as the + build plan says. An unexpected "no changes" is raised with the operator. +- **Moving past a milestone checks its PR's state** in Gitea. If it is still open + and unmerged, the agent says so and asks, rather than leaving it behind. +- **The project's default branch is protected,** and this is verified, not + assumed: the agent's own token must be refused when pushing to it, and when + merging without whatever approval the trust dial requires (L3). A test asserts + both, so relaxing protection fails loudly. +- **A runnable test service's address** goes in the PR description. + +### D5. Review and merge + +- **The review loop:** operator comments on a PR (the conversation and inline + line comments) are instructions; the agent responds with commits to the same PR + and replies in the thread where the comment was made. +- **Intake is exactly as in L8:** cursors, at-most-once taking, at-least-once + answering, redelivery after a crash. +- **Merging follows the trust dial** (L3). Today the operator merges. As confidence + grows, the dial may let the agent merge a pre-approved milestone once checks pass + and the required approvals are recorded; the branch protection must then require + an operator approval the agent's account can't satisfy, so the merge rule is + enforced by Gitea, not by the agent. +- **Finishing a work item** closes its Gitea issue with a note (completed or + abandoned), after checking what is left open. + +### D6. `nisse-dev`'s action classes + +| Class | Examples | +|---|---| +| 1 | Reading the repo, running tests and builds in the workspace or a test container, querying Gitea | +| 2 (in scope) | Editing the workspace, committing to milestone branches, pushing them, opening and updating PRs, commenting on its issues and PRs, cleaning caches, installing project dependencies from package registries | +| 3 | Merging to the default branch (unless the trust dial allows), deleting remote branches or others' work, installing system software, changing repository settings, anything outside the work item's repo | + +Its scope sources are the approved architecture, the build plan and each +milestone's marking. + +### D7. Deployment and isolation + +- **Its own VM** (X1), with a real Docker daemon, because it builds and runs + containers. The VM is the trust boundary for unattended command execution. +- **Sealed:** its egress allowlist (X2) permits only LiteLLM, Gitea and package + registries; there is no ingress. Denial covers RFC1918 (except the specific + LiteLLM and Gitea addresses) and the tailnet's `100.64.0.0/10`, so it can't reach + the NAS, Proxmox or the gateways. The inference host sits behind LiteLLM, so it is + not a second exception. +- **No Tailscale** on this VM; it would route around the firewall. +- **Its own VLAN,** and test services on predictable ports (e.g. 18000–18099), so + opening access later is a firewall change. +- **Test containers on an internal Docker network with no route out.** +- **Build storage on a separate mount** from the state volume. + +--- + +## Part O — `nisse-ops` + +Operates and maintains infrastructure across both sites, driven by tickets 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, revert a snapshot | often none (`zfs destroy`, a firewall lockout) | +| Scope source | architecture and build plan | approved step sequences (O2) | +| Default for class 3 | discuss | discuss, and exact-step approval | +| Work shape | milestones → PRs | short reactive tasks and scheduled checks → live changes | +| Driver of work | the operator in Matrix, Gitea | Vikunja tickets, alerts, duties, the operator | +| Placement | sealed, own VLAN | Tailscale, credentials, broad reach | + +The two are separate deployments over the same library; one instance playing both +roles would collapse the isolation. + +### O2. Approved step sequences + +**Every infrastructure write needs an approved plan first,** and approval covers +exactly the steps written. This is `nisse-ops`' scope source for the classifier +(L4) and a hard wall in its own right. + +- **Write credentials exist only in elevated MCP instances** (X3), mounted after + approval; read-only instances are mounted by default, so looking around needs no + plan. +- **A write plan is a list of literal steps,** each naming the tool and target, the + full arguments (a config change carries its exact diff), an optional + **precondition** checked against the live system just before the step, an optional + **expected outcome** checked just after, and a retry allowance (default one). + Prose may explain the plan; it is never scope. +- **The approval message is rendered by code from the steps,** flagging at the top + anything irreversible, anything touching a protected target, and anything that + takes down the agent's own host. +- **Execution is matched against the steps.** Read-only calls are always allowed. + An elevated call runs only if it matches the next unfinished step exactly + (arguments normalised against the tool's schema). Anything else halts and asks: + a call not in the plan, changed arguments, steps out of order, a completed step + run again. A failed precondition or expected outcome halts without retry. +- **Approvals expire.** A plan not started within the expiry window must be approved + again. +- **Policy is small, conservative, and data:** a versioned file declaring each MCP + instance's privilege, protected targets (the NAS, gateways, the agents' own + hosts), and each elevated tool's target argument. Anything unclassified is + treated as elevated and protected. Server-declared tool hints can make a tool + stricter, never looser. A model may propose entries for a new server as a PR; no + classification happens at run time. + +**Parked, to add when real friction shows the shape:** bounded matching (any call +within an approved target and action, for reversible tools, one tool at a time), +and standing approvals (a plan template with a trigger, rate limit and expiry, for +a routine fix approved by hand often enough). + +### O3. `nisse-ops`' action classes + +| Class | Examples | +|---|---| +| 1 | Metrics, logs, status, read-only MCP calls, reading tickets and the world model | +| 2 (in scope) | Approved steps with a known undo; updating its own world model and tickets | +| 3 | Every other infrastructure write; browser actions on admin UIs (each confirmed, since a click isn't a stable literal); **anything on network gear**, the most conservatively handled action in the system, since a bad change can sever the path used to fix it | + +### O4. Interfaces + +- **MCP servers** (X3) for Proxmox and UniFi at both sites, one instance per + service, site and privilege: the primary path to infrastructure. +- **Browser** (a shared Playwright tool) for admin UIs where no API exists; real APIs + are preferred. For network gear it is a fallback only. +- **SSO** as `svc-nisse-ops`, whose blast radius equals its Authelia scope. +- **Event intake** as structured JSON (directly or via Alertmanager), never through + a Matrix bridge. The agent posts curated updates to `#ops`. + +### O5. Tickets and duties + +- **A Vikunja ticket is a work item's external record** (durable, with status and + history); **Matrix is the live conversation about it.** +- **Scheduled checks and alert intake are duties** (L1). Tickets they generate are + deduplicated findings, and like any auto-generated ticket can only lead to a plan + the operator approves. +- **Flow:** ticket filed → agent picks it up → discuss over Matrix → status updates → + closed with the result. +- **Vikunja comments are an approval source** for plans on their own tickets + (`approve rev-N`). Live prompts stay in Matrix, with outcomes written back. +- **Action-triggering tickets are operator-scoped:** a ticket is an instruction + channel into an agent that changes infrastructure. + +### O6. Deployment + +- **Its own long-lived, stateful VM** (X1). +- **Tailscale** and scoped credentials to reach infrastructure at both sites. +- **Its MCP servers run on its own Docker host;** its egress allowlist therefore + includes the Proxmox and UniFi endpoints at both sites. +- **Updated in place,** with restarts gated on an idle flag (X4). + +--- + +## Part X — Deployment model (all agents) + +### X1. Every agent gets its own VM + +No agent runs as a container on a shared host, and no two agents share a VM. +- **The VM is the isolation boundary between agents:** a compromised or confused + agent can't reach another's state, credentials or processes. +- **Per-agent attribution:** everything an agent holds is issued to its own + identity, so every upstream action traces to one agent. + +### X2. Egress policy per agent instance + +- **Default-deny allowlist per agent instance,** declared as a versioned artifact + next to the agent's definition, each entry justified by the component that + needs it. +- **Declared in one place, enforced in several:** at the VM, the Docker network, + and the UniFi firewall. Network-layer enforcement survives compromise of the + agent's host. +- **The allowlist is the union** of what the agent and its MCP servers reach. +- **The tailnet is `100.64.0.0/10`,** not RFC1918; deny it explicitly. + +### X3. MCP servers + +- **One instance per agent VM,** as containers on that agent's Docker host. A shared + process would mean a shared credential, losing attribution in the upstream API's + own audit log. Duplication is nearly free for stateless servers. +- **One instance per (service, site, privilege).** The site is part of the server's + identity, so a wrong-site action needs a tool that isn't mounted, not just a wrong + argument. Read-only and elevated instances have separate credentials, each from + its own machine account. +- **Per-task tool profiles** mount only the servers a work item's scope needs. +- **Rejected: search-then-dispatch meta-tools** over large generated catalogues: + extra round trips for a local driver, no discovery of capabilities not already + sought, opaque arguments, and a dynamic tool list that defeats prompt caching. + +### X4. Containers, images and restarts + +- **Stateless images, named volumes** for state. +- **Delivery:** Gitea Actions → Gitea's OCI registry → Watchtower pulls. +- **Gate the restart, not the pull:** an idle flag; Watchtower never restarts an + agent mid-work. Unplanned interruptions are handled by recovery (L8). +- **Third-party MCP server images are digest-pinned** and updated deliberately, + never by Watchtower: they hold root-equivalent credentials. +- **Survive reboots:** VMs and Docker start on boot, containers restart unless + stopped, and the state dataset has ZFS snapshots (scheduled, and on each + approval). + +### X5. Supporting services + +All self-hosted: Matrix (Synapse), Gitea, LiteLLM, the local inference host +(llama-swap on an RTX 4090 host), Vikunja, Outline, Vaultwarden, LLDAP with +Authelia. Each agent gets its own account, scoped token and room in each service +it uses; nothing is shared between agents. + +--- + +## Part Q — Open questions + +- **The action classifier's model and placement.** A cheap frontier model, or the + local model with a different prompt; how its verdicts are calibrated against + operator overrides. +- **Recognising questions.** An explicit marker, or classifying free text; either + way a misread must default to asking (L5). +- **How far the local driver goes.** Which kinds of work it handles well and where + a frontier driver is needed, measured on real tasks, not assumed (P7). +- **Providing toolchains** for `nisse-dev`: project dev containers on its Docker + daemon, or the VM image, or both (D3). +- **One local model for every agent.** The inference host keeps one model resident; + different models per agent would reload weights on every switch. Preferred: one + model good enough for every local role. Fallback: agents take turns holding the + host. Measured before deciding. +- **Vision.** Needed for 3D design work (render and look) and video-file + management; not for ordinary code. Tools returning images, image content in the + log and renderers, and image tokens in the context budget. Measured when that work + starts. +- **More than one active work item per agent.** Park-and-switch first; concurrency + costs per-item serialisation, a scheduler for the shared inference host, strict + approval threading, separate workspaces per item, and per-item intent ownership. +- **When a work item must have an external record.** Plan-driven dev work and + ticket-driven ops work already do; whether every item with an approval must. +- **Fallback browser access to network gear:** standing, or handed off per task. +- **Relocating stateful MCP servers** off agent VMs, one at a time, if one ever + justifies losing per-agent attribution. +- **Infrastructure as code** (Terraform with `bpg/proxmox` and cloud-init) for agent + VMs. + +--- + +## Appendix — What changed from the Build Specification + +For reviewing this rewrite against `docs/Architecture-Build-Spec.md`: + +- **Threat model and control model (P2–P4).** Checks are built around mistakes, + injection and crashes, in three layers with an action classifier, rather than a + rigid state machine gating every phase. +- **The agent loop (L2)** replaces the per-agent state machines (old A4, B1). What + stays fixed is only what authority and durability need. +- **Approvals as scoped permissions (L3),** with pre-approved and needs-discussion + milestones and an operator-set trust dial. Revision binding is kept and extended + (task-wide revisions, stamping on arrival). +- **The local model drives everything (P7, L9);** frontier models are helpers. The + old "planning always escalates" phase policy is dropped. +- **Context and memory (L6)** is new: transcript scoping, attribution, orchestrator + facts, scope memory, agent memory, and how memory is written and used. +- **Documents as the dev workflow (D2),** a fresh workspace per task and toolchain + preflight (D3), and git hygiene learned from the live runs (D4). +- **Conversation (L5)** is a first-class requirement, not a command interpreter. +- **Kept, largely as they were:** the work model, durability and reconciliation, + LiteLLM and the canonical log, delegation, the knowledge store, prompt injection, + secrets and identity, `nisse-ops`' approved step sequences, and the deployment + model. +- **Moved to the Development Plan (to be written):** build order, supporting-service + status, and interim arrangements.