CI / checks (push) Skipped
CI / checks (pull_request) Successful in 50s
Letting the model choose which ready item to switch to adds a model call that belongs to no work item. Duty runs before a finding already had the same gap. A10 now defines an agent-level scope for these calls, kept apart from work item transcripts in both directions. The switch call sees only code-rendered summaries of the ready items, runs as the executor, and falls back to the oldest item if it fails. A9 gains a work_item.switch event, and A18 accepts that an injected alert can at worst reorder work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1432 lines
81 KiB
Markdown
1432 lines
81 KiB
Markdown
# Nisse — Build Specification
|
||
|
||
A build-oriented spec for two self-hosted AI agents sharing a common library. This
|
||
document states **requirements per component** so major pieces can be broken out and
|
||
built independently. It is deliberately lighter on the supporting services (Matrix,
|
||
Gitea, Vikunja, Outline, LiteLLM) and heaviest on the **agent library** and the
|
||
**two agents**, which is where the real design work lives.
|
||
|
||
**Document history.** The Tiered Inference amendment has been folded into this
|
||
document and no longer exists separately: its A-series components appear as A10–A16,
|
||
its network and credential revisions in A5 and B3, and its revised build order as
|
||
Part G. The inference-hardware question it raised has since been resolved in
|
||
principle (Part H: a 4090-class host). The MCP Deployment amendment has likewise been
|
||
folded in: the every-agent-gets-a-VM rule, per-instance egress policy, and MCP server
|
||
placement now form Part F1–F3; its consequences appear in Part 0 (site identifiers),
|
||
A7, B3, C2, C3, C5, the Part F4 Watchtower carve-out, and Part H.
|
||
|
||
---
|
||
|
||
## 0. Naming
|
||
|
||
The project is **Nisse**, after the Nordic farm spirit that keeps the place running
|
||
overnight and expects to be treated well in return. Three artifacts:
|
||
|
||
| Artifact | Name | Role |
|
||
|---|---|---|
|
||
| Shared library | `nisse-core` | The spine both agents sit on (Part A) |
|
||
| Dev Agent | `nisse-dev` | Plans and implements software projects (Part B) |
|
||
| Sysadmin Agent | `nisse-ops` | Operates infrastructure (Part C) |
|
||
|
||
Throughout this document, **Dev Agent** and **Sysadmin Agent** name the *roles* in
|
||
conceptual discussion; `nisse-dev` and `nisse-ops` name the *artifacts* — packages,
|
||
container images, VM hostnames, Matrix identities, and service accounts. Use the
|
||
same string across all of those: the VM, the container, the Caddy host, the Matrix
|
||
account, and the Bitwarden machine account for a given agent all share one name, so
|
||
that grepping the infrastructure for `nisse-ops` finds everything.
|
||
|
||
**Sites** are identified by their domain stem: `garrett-hall` (Arlington, hosts under
|
||
`garrett-hall.com`) and `hartman-hut` (Cross Plains, hosts under `hartman-hut.com`).
|
||
As with agent names, one string is used everywhere a site appears. For an MCP server
|
||
instance (F3), that means its container name, its Vaultwarden machine account, and
|
||
its MCP tool prefix.
|
||
|
||
The three artifacts live as three packages in **one repository, `nisse`** (decision
|
||
recorded in Part H). Directory and package names carry the identity. If mirrored to
|
||
GitHub, it goes under a personal account rather than an org — `github.com/nisse` is
|
||
squatted and GitHub does not reclaim inactive usernames. `nisse-core` is free on
|
||
PyPI if packages are ever published.
|
||
|
||
---
|
||
|
||
## 0.1 What We're Building
|
||
|
||
Two agents:
|
||
|
||
1. **Dev Agent** — `nisse-dev` (build first) — plans and implements software
|
||
projects, produces Gitea PRs for human review, reacts to review comments and
|
||
commits.
|
||
2. **Sysadmin Agent** — `nisse-ops` (follow-on) — operates and maintains
|
||
infrastructure, driven by tickets, gated on high-consequence actions.
|
||
|
||
Both are thin applications over a shared **`nisse-core`** library. The library is
|
||
*extracted from the Dev Agent after it works* — not designed up front for two
|
||
consumers that don't exist yet. Build the Dev Agent as if the library were internal,
|
||
then lift the seams that proved real.
|
||
|
||
There is one deliberate, bounded exception: the **work model's identity scheme**
|
||
(0.2) is settled for both agents ahead of extraction. Identifiers end up in the state
|
||
schema, branch names, the operational log (A9), the transcript (A10), and external
|
||
records. Changing those after extraction would mean migrating two agents and their
|
||
stored state. Only identity is designed ahead. Behaviour is still built by each
|
||
agent as it needs it, so nothing in 0.2 requires the Dev Agent to implement duties,
|
||
deduplication, or concurrency.
|
||
|
||
The three artifacts live in **one repository** (decided — Part H), so the
|
||
extraction step in the build order is a package split within one tree, not a repo
|
||
split.
|
||
|
||
### Guiding principles (apply to everything below)
|
||
|
||
- **Human-gated autonomy.** Agents work independently once told to start, but never
|
||
cross key boundaries (start implementation, apply infrastructure change) without
|
||
explicit sign-off.
|
||
- **No per-command approval.** Within an approved unit of work, execution is
|
||
unattended. Control lives at the *boundaries*, not per action.
|
||
- **Isolation matched to blast radius.** Dev Agent is disposable and sealed; Sysadmin
|
||
Agent is trusted with live-infrastructure reach. They never share a running
|
||
instance.
|
||
- **Stateless image, stateful volumes.** Containers are replaceable; durable state
|
||
lives on named volumes and must survive both redeploys and unexpected reboots.
|
||
- **Structured channels for machines, prose channels for humans.** Never route
|
||
machine event intake through human-facing formatters (chat bridges).
|
||
- **Own the stack.** Everything self-hostable stays inside the network where practical.
|
||
|
||
---
|
||
|
||
## 0.2 The Work Model: Duties, Work Items, and Scopes
|
||
|
||
Every agent's work is organised into three kinds of unit. They define what state
|
||
belongs where, how work is identified, and what the operator is resuming when they
|
||
come back to something. Components throughout this document refer to them.
|
||
|
||
| Concept | What it is | Lifetime | Dev Agent | Sysadmin Agent |
|
||
|---|---|---|---|---|
|
||
| **Duty** | A standing responsibility that produces work items | Indefinite, recurring | None yet; the operator is the only source | Scheduled checks, log monitoring, alert intake |
|
||
| **Work item** | One effort with a lifecycle: opened, worked (often through gates), closed | Opened → closed | Building a feature through arch plan → milestones → PRs | Diagnosing one failure; answering one question |
|
||
| **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 |
|
||
|
||
### Duty
|
||
|
||
- **A duty produces work items. It is not one itself.** A run of a duty (one log
|
||
analysis, one scheduled check) is read-only and yields findings. A finding that
|
||
needs attention becomes a work item.
|
||
- **The operator is always an implicit source** of work items, for every agent.
|
||
- **Deduplication is a duty's responsibility.** When a later run finds the same
|
||
failure, it attaches to the existing open work item instead of opening a second
|
||
one. That needs a stable fingerprint per finding, checked against open work items.
|
||
This is the same idempotency concern A2/A3 address for side effects.
|
||
|
||
### Work item
|
||
|
||
- **The unit of state.** A work item owns its state-machine position, gates and plan
|
||
revisions, pending questions, pinned role mapping (A11), and its transcript (A10).
|
||
- **Identity is minted locally.** Every work item gets a local ID when it opens.
|
||
This is the `task_id` of A9: a work item's ID and its correlation ID are one value.
|
||
- **An external record is optional, and linked.** A work item *may* be tied to a
|
||
durable external record: a Gitea issue for the Dev Agent, a Vikunja ticket for the
|
||
Sysadmin Agent. Many will not be; a general question to either agent is a work
|
||
item with no record. A work item can also gain a record partway through, for
|
||
example a question that turns into a change to make.
|
||
- **Where the spec requires a record, it has one.** Dev plan gates are externalized
|
||
to Gitea (Part H), and ticket-driven ops work has its ticket (C4). When a record is
|
||
linked, external truth wins on divergence (A2), and gate state can be re-derived
|
||
from it after state loss. Whether *every* gated work item must have a record is
|
||
open (Part H).
|
||
- **Work items are not nested.** A milestone is a phase *within* a dev work item, and
|
||
a delegation (A13) is an episode within one. Neither is a work item of its own.
|
||
|
||
### Scope
|
||
|
||
- **A scope holds context shared by the work items that operate on it:** for a repo,
|
||
the clone and the project's conventions; for infrastructure, the A17 world model
|
||
of that site or service.
|
||
- **Many work items may share one scope.** A feature waiting on review and a bug fix
|
||
in the same repo are two work items on one scope. So are two failures found in the
|
||
same log run.
|
||
- **A work item has zero or more scopes.** A dev work item has exactly one repo for
|
||
now. A general question may have none.
|
||
- **Scope identifiers follow existing naming.** Repos use `owner/repo`. Sites use the
|
||
site identifier (Part 0), combined with the service.
|
||
|
||
### Park-and-switch: one active work item per agent
|
||
|
||
- **Each agent has at most one active work item.** Others are *parked*, with their
|
||
full position held in the state store (A2).
|
||
- **Switching happens only at safe points,** where the loop is waiting on a human
|
||
(an open gate, a pending question, a PR in review) or is idle. It never happens
|
||
mid-turn. Because turns run to completion, every point at which a command can
|
||
arrive is a safe point, with one exception: switching is refused while intents
|
||
are unresolved (A2). That keeps every pending intent owned by the active work
|
||
item, so boot reconciliation (A3) stays single-item.
|
||
- **The operator can always switch; the agent may switch on its own in one case.**
|
||
When the active work item is waiting on the operator (an open gate, a pending
|
||
question, or a halt in `AWAIT_INPUT`) and other work items are ready, the agent
|
||
switches to one of them and posts a notice naming both items and why it chose
|
||
that one. An alert must not sit for hours behind an unanswered prompt. The agent
|
||
never switches away from an item that is working. The item it left stays
|
||
waiting; switching answers nothing on its behalf.
|
||
- **The model chooses which ready item to switch to.** This is one of the few
|
||
judgments it is trusted with outright, because choosing grants nothing: every
|
||
item still meets its own gates, and a poor choice costs time, not safety.
|
||
- *It is an agent-level call,* belonging to no work item (A10), made by the
|
||
`executor` role at the current config (A11 pins roles per item, and this call
|
||
has none).
|
||
- *It sees summaries rendered by code from the state store,* one per ready item:
|
||
source, age, severity, current state, title. It never sees another item's
|
||
transcript, so work items stay isolated (A10).
|
||
- *It falls back to the oldest ready item* if the call fails or its answer does
|
||
not name one of them. Switching never waits on a model.
|
||
- *The switch is one state change* (A9 `work_item.switch`), naming both items,
|
||
the reason, and whether the model, the fallback, or the operator chose.
|
||
- **Parking gives up authority.** Parking a work item unmounts any elevated MCP
|
||
instances (F3) its approved plan mounted. They are remounted when it becomes
|
||
active again, if its approval has not expired (C2). An approved plan's
|
||
credentials never serve another item.
|
||
- **Nothing is lost while parked.** Intake for a parked work item, such as comments
|
||
on its Gitea issue or PR, is read from a durable per-thread cursor when it becomes
|
||
active again. It is not dropped, and it is not replayed from the beginning.
|
||
- **The state schema must not assume one item.** Work items live in their own table,
|
||
and "active" is a pointer to one of them. Allowing more than one active item later
|
||
(Part H) is then a control-loop change, not a migration of every stored item.
|
||
|
||
---
|
||
|
||
## PART A — THE AGENT LIBRARY (`nisse-core`)
|
||
|
||
The shared spine. Every component here is consumed by both agents. This is the part
|
||
to get right.
|
||
|
||
### A1. Transport
|
||
|
||
**Purpose:** all human⇄agent conversation — commands in, questions/approvals/updates
|
||
out.
|
||
|
||
Requirements:
|
||
- A `Transport` interface the orchestrator depends on; **no platform SDK imported
|
||
directly** anywhere else. Methods (at least): `send_message`,
|
||
`send_approval_request`, `ask_question`, `on_message` (inbound callback/stream).
|
||
- **`MatrixTransport`** is the first and primary implementation. Self-hosted
|
||
homeserver (Synapse or Conduit) keeps the control plane in-network. Must support
|
||
threaded conversations (for plan discussion) and per-agent identities.
|
||
- Interface must make adding `TelegramTransport` / `SlackTransport` later a matter of
|
||
one new class + config, no orchestrator changes.
|
||
- Both agents run as **distinct identities** (`@nisse-dev`, `@nisse-ops`) in separate
|
||
rooms on the same homeserver.
|
||
|
||
Explicitly *not* required: rich message formatting, custom clients. Existing Matrix
|
||
clients (Element) are the UI.
|
||
|
||
**Matrix is strictly a UI channel.** Although the homeserver is self-hosted and
|
||
therefore not "remote," room history must not become load-bearing context. The
|
||
canonical log (A10) is the sole source of truth.
|
||
|
||
### A2. State Store and Durability *(critical path — build first)*
|
||
|
||
**Purpose:** durable, crash-safe persistence of everything that must outlive a
|
||
process restart. This is what makes the whole system safe to redeploy and reboot; it
|
||
blocks everything else.
|
||
|
||
Must persist: the active work item and each work item's state-machine position
|
||
(0.2), approvals granted, pending questions, session continuity, and scope state such
|
||
as (dev) git clone locations.
|
||
|
||
Disposable (never on the state volume): agent code, build artifacts/test containers,
|
||
shipped-out logs.
|
||
|
||
Requirements:
|
||
- **Crash-atomic writes.** SQLite in **WAL mode** (default choice) or atomic
|
||
file writes (`write-temp → fsync → rename`). A power cut mid-write must never
|
||
produce torn state.
|
||
- **Checkpoint after every state transition**, with `fsync`. No "flush later" —
|
||
in-memory state that hasn't hit disk is assumed lost.
|
||
- **Durable intent log.** Before a unit of work starts, record intent, so a restart
|
||
knows what was in flight.
|
||
- **Idempotent transitions reconciled against external truth.** On any operation with
|
||
an external side effect (open PR, push branch), restart must detect the effect
|
||
already happened (query Gitea/git) rather than repeat it.
|
||
- **`schema_version`** field from the first commit, with a migration hook, so a newer
|
||
image can read older state.
|
||
|
||
### A3. Boot-Time Reconciliation
|
||
|
||
**Purpose:** turn an ambiguous crash into a safe, known state.
|
||
|
||
Every startup (clean or post-crash), before accepting operator commands:
|
||
1. Open state store (WAL recovery is automatic).
|
||
2. Run any pending schema migration.
|
||
3. Read intent log; look for a transition that began but never committed.
|
||
4. Reconcile against external truth (Gitea/git for the dev agent; relevant services
|
||
for the sysadmin agent).
|
||
5. **Interrupted mid-delegation (A13) — apply the deterministic resume rule.**
|
||
**Auto-resume** the delegation — re-render its transcript from the canonical log
|
||
and continue the loop, posting a notice (not a question) to transport — when the
|
||
cut is clean: the last durable event is a completed `tool_result` or assistant
|
||
turn, the delegation branch sits at the expected commit, no cap is breached, and
|
||
external truth matches the log. **Gate to `AWAIT_INPUT`** when the last event is a
|
||
`tool_call` with no recorded result whose effect cannot be verified by inspection
|
||
(tree diff for edits, Gitea query for pushes), or on any branch/log mismatch. The
|
||
judgment is an observable check, never model self-assessment — the same reasoning
|
||
as A12.
|
||
6. **Interrupted mid-sequence (C2) — resume only on evidence.** An approved step
|
||
sequence may interrupt itself: rebooting the host the agent runs on is a
|
||
legitimate step. On restart, **continue** the sequence only when the intent log
|
||
names exactly which step was in flight and that step's expected outcome checks
|
||
out against the live system, and the approval has not expired. Anything else
|
||
(no recorded outcome check, a failed check, or more than one step
|
||
possibly in flight) gates to `AWAIT_INPUT`. The step counts as completed only
|
||
by that check, never by assuming it ran.
|
||
7. Any other interruption → move to **`AWAIT_INPUT`** and post to transport
|
||
(e.g. "restarted mid-milestone-2, branch at commit abc123, continue or re-plan?").
|
||
Never blind-resume.
|
||
8. Otherwise resume the last committed state.
|
||
|
||
### A4. Control Loop, State Machine, and Gating Primitives
|
||
|
||
**Purpose:** the generic engine that drives an agent through gated work. Each agent
|
||
supplies its own concrete states; the primitives are shared.
|
||
|
||
Requirements:
|
||
- A **state machine** framework: named states, transitions, and human gates. Each
|
||
gate blocks until an explicit operator action arrives via transport.
|
||
- **Gating is structural, not advisory.** The mechanism (used by both agents):
|
||
- Expose a **`submit_plan(type, content)`** tool to the model. Calling it posts the
|
||
plan for approval and **ends the agent's turn.** The agent cannot self-advance.
|
||
- Expose an **`ask_human(question)`** tool — non-terminal: pause, post question,
|
||
block until answered, resume.
|
||
- **Do not** expose any "proceed" / "start" tool. Advancement past a gate happens
|
||
only when the orchestrator receives operator approval and hands over the next
|
||
task. The agent has no path to approve itself.
|
||
- **Approval sources.** A gate waits on an approval event from any registered
|
||
**`ApprovalSource`** — the Matrix transport (A1), the Gitea comment poller (B2), or
|
||
the Vikunja comment stream (C4). **The knowledge store (A17) is never an approval
|
||
source**, and never an instruction channel: a wiki page has neither the identity
|
||
guarantees nor the revision binding an approval requires. First valid approval wins and is consumed; the
|
||
gate records which channel satisfied it, and the orchestrator echoes the outcome to
|
||
the other channels so the records converge. Each source authenticates the approver
|
||
against a per-channel operator allowlist (Matrix MXID, Gitea username, Vikunja
|
||
username).
|
||
- **Approvals bind to a plan revision, never to "the current gate."** Every submitted
|
||
plan revision carries a monotonically-increasing tag (`rev-N`). An approval naming
|
||
a stale revision is rejected with a notice, never applied to a newer one — this is
|
||
what makes multi-channel approval race-free.
|
||
- **One consistent approval UI across all channels.** Approval is always the typed
|
||
word `approve`, attached to a specific revision: in Matrix, a reply to the plan
|
||
message (the reply relation carries the binding; a bare `approve` in the room binds
|
||
to the latest revision only when unambiguous, otherwise the agent asks); in Gitea
|
||
and Vikunja, a comment `approve rev-N`, with the tag printed in the plan comment
|
||
for copy-paste. Any other reply/comment is `reject <feedback>`. Reactions/emoji are
|
||
never an approval path. Approvals are consumed at first sight; later edits to an
|
||
approval comment are ignored and logged.
|
||
- **Approvals bind to a work item, too (0.2).** A bare `approve` binds to the
|
||
*active* work item's latest revision, under the unambiguity rule above. An
|
||
approval arriving on a *parked* item's external record (a Gitea or Vikunja
|
||
comment) is delivered when that item next becomes active. So is a Matrix reply to
|
||
a parked item's plan or prompt: the reply relation binds it to that item. Neither
|
||
is ever applied to the active item.
|
||
- **Autonomy inside an approved unit of work:** all tool/command use auto-approved
|
||
(Claude Agent SDK `bypassPermissions` or an allow-all `canUseTool`). Escalation to
|
||
the human happens *only* via `ask_human`. For the Sysadmin Agent, "approved"
|
||
means the step list of C2. Only calls that match an approved step are
|
||
auto-approved.
|
||
- **Kill switch.** The operator command `stop` works in every state, for every
|
||
agent. It ends the current turn or delegation at the next tool boundary, unmounts
|
||
elevated MCP instances (F3), and moves to `AWAIT_INPUT`. The unfinished delegation
|
||
or step sequence is then handled like any other halt: continue, re-plan, or
|
||
abandon, on the operator's word. Unattended work needs a way to end it that does
|
||
not depend on the model cooperating.
|
||
- The loop must survive restart mid-work (relies on A2/A3).
|
||
|
||
### A5. LLM Access *(tiered)*
|
||
|
||
**Purpose:** reach both a locally-hosted model and frontier models through one
|
||
interface, with the frontier vendor swappable by config.
|
||
|
||
A locally-hosted model drives the control loop and performs mechanical work; frontier
|
||
models are engaged for work that exceeds local capability. Two requirements drive the
|
||
tiering:
|
||
|
||
1. **No remotely-held state.** Conversation and task state lives on the agent VM. No
|
||
provider holds anything the system depends on to resume.
|
||
2. **Vendor fluidity.** The frontier tier should be swappable — Anthropic, OpenAI,
|
||
Google, or a vendor that does not exist yet — as a configuration change, not a
|
||
refactor.
|
||
|
||
These two are only in tension if portability is required *within* a session. Making
|
||
frontier engagements **ephemeral and self-contained** (A13) allows vendor-specific
|
||
session data to be used freely inside a delegation while keeping the durable record
|
||
vendor-neutral.
|
||
|
||
Requirements:
|
||
- **One client interface**, `complete(canonical_log, tools, model) -> canonical_events`,
|
||
present at the **first real LLM call** — not introduced as a later cutover. The same
|
||
reasoning that produced the `Transport` abstraction.
|
||
- **LiteLLM in front of everything, from the start.** The agent VM gets exactly one
|
||
LLM destination; LiteLLM fans out to the local inference host and to frontier
|
||
vendors behind it. This also resolves the RFC1918 conflict in B3 — a LAN-resident
|
||
inference host would otherwise need a second hole punched in the egress allowlist.
|
||
- **Passthrough for the vendor driving a given session** (`/v1/messages` for
|
||
Anthropic), so vendor-specific features are not lost on the path where they matter —
|
||
delegations (A13) always take this path. **Normalized routing for everything else**;
|
||
the canonical log (A10) reconciles the difference.
|
||
- **Fallback chains configured at LiteLLM.** Rate limits and outages differ per
|
||
vendor; a frontier failure should not kill a task.
|
||
- **No vendor credentials on the agent VM.** LiteLLM holds every key, sourced from
|
||
Bitwarden machine accounts (A7); the agent holds one LiteLLM token. Adding a vendor
|
||
does not touch the agent VM, and a compromised agent leaks no provider keys.
|
||
- Config from env/`.env`, **nothing hardcoded**: base URL, auth token, and the role
|
||
mapping (A11). Changing any of it is an env change + restart, nothing more.
|
||
- **Budget caps and spend alerts live on the LiteLLM virtual key**, not in agent code.
|
||
|
||
### A6. Browser Tool
|
||
|
||
**Purpose:** drive web UIs. Shared implementation; each agent points it at a
|
||
different network with different trust.
|
||
|
||
Requirements:
|
||
- **Playwright** (headless Chromium), exposed to the model as tool actions:
|
||
navigate, click, type, read DOM, screenshot. Screenshot + accessibility-tree loop
|
||
so the model can operate UIs it wasn't trained on.
|
||
- Runs **inside the agent's VM**. Network reachability is set by the agent's
|
||
deployment, not the tool.
|
||
- Must support authenticated sessions (for the sysadmin agent's admin UIs) while
|
||
being usable without auth (dev agent's test apps).
|
||
|
||
### A7. Secrets Client
|
||
|
||
**Purpose:** fetch credentials at runtime instead of baking them into images.
|
||
|
||
Requirements:
|
||
- Client for **Bitwarden/Vaultwarden** via **machine account / Secrets Manager** (or
|
||
`bw` CLI + API key). Not the personal vault.
|
||
- Reads only from a **dedicated, minimally-scoped machine account** per agent, **plus
|
||
one per MCP server instance** (F3). Each (service, site, privilege) instance holds
|
||
its own upstream credential, so it gets its own machine account, and the agent's
|
||
account cannot read an instance's credential.
|
||
- Each machine account has a single **bootstrap token**. These are the only secrets
|
||
that live on the box: tight file perms, never in image or repo. Each token is
|
||
available only to its own container.
|
||
- (Deferred, not first cut: dynamic/short-lived secrets via Vault/OpenBao. Leave the
|
||
interface able to grow toward this; don't build it yet.)
|
||
|
||
### A8. SSO Identity
|
||
|
||
**Purpose:** each agent authenticates as itself.
|
||
|
||
Requirements:
|
||
- Each agent is a dedicated **LLDAP** principal (`svc-nisse-dev`, `svc-nisse-ops`) in
|
||
a tightly-scoped **Authelia** group. Never the operator's account.
|
||
- Support an **unattended-auth path**: because interactive MFA is impossible for a
|
||
headless agent, allow a narrow **IP-scoped, group-scoped, target-scoped**
|
||
single-factor exception. Keep it specific on all three axes.
|
||
|
||
### A9. Logging
|
||
|
||
**Purpose:** observability without bloating state.
|
||
|
||
Requirements: structured logs to journald / external log stack, **not** kept
|
||
in-container. Every gate crossing, tool call, and escalation is logged.
|
||
|
||
**State-change log conventions.** Every durable mutation of agent state emits
|
||
exactly one structured log event, so problems can be diagnosed after the fact
|
||
by replaying the operational trail:
|
||
|
||
- **Emission is owned by the state store.** The log line is written by the same
|
||
code path that performs the durable write, immediately after the commit
|
||
returns — a state-change line exists iff the change hit disk. Callers supply
|
||
the *cause*; no other layer logs durable mutations, so the trail cannot drift
|
||
from what was persisted.
|
||
- **Format: one JSON object per line**, with a fixed envelope on every event:
|
||
- `ts` — UTC ISO-8601 timestamp
|
||
- `agent` — `nisse-dev` | `nisse-ops`
|
||
- `event` — dotted type from the taxonomy below
|
||
- `task_id` — the enclosing work item's ID (0.2), minted when the work item
|
||
opens (`use <project>`, ticket pickup, an operator question, a duty's finding)
|
||
and threaded through every event it causes; `null` only outside any work item
|
||
(e.g. boot, a duty run before it yields a finding, or choosing which item to
|
||
switch to). A milestone is a phase
|
||
within a work item, so starting one does not mint a new ID.
|
||
- `cause` — what triggered the mutation: operator command, approval
|
||
(`rev-N` + channel), escalation trigger, reconciliation verdict, or crash
|
||
recovery.
|
||
- **Event taxonomy** (extended as components are built; names are never reused
|
||
or renamed):
|
||
|
||
| Event | Event-specific fields |
|
||
|---|---|
|
||
| `state.transition` | `from`, `to`, `project` |
|
||
| `intent.begin` / `intent.commit` / `intent.abort` | `intent_id`, `kind` |
|
||
| `work_item.switch` | `from`, `to`, `reason`, `chosen_by` (`model` / `fallback` / `operator`) |
|
||
| `gate.open` / `gate.approve` / `gate.reject` | `gate`, `rev`, `channel` |
|
||
| `boot.reconcile` | `action`, per-intent verdicts |
|
||
| `schema.migrate` | `from_version`, `to_version` |
|
||
|
||
- **Logs are diagnostic, never load-bearing.** Nothing reads logs back to
|
||
derive state — the state store (A2) is the sole source of truth, the same
|
||
rule that keeps Matrix room history (A1) non-authoritative.
|
||
|
||
### A10. Canonical Transcript Schema
|
||
|
||
The durable record is a **canonical event log** in SQLite, not a provider-shaped
|
||
`messages` array. Provider request bodies are rendered at call time from this log.
|
||
|
||
| Event | Payload |
|
||
|---|---|
|
||
| `user_message` | text, source (Matrix event ID) |
|
||
| `assistant_text` | text, model that produced it |
|
||
| `tool_call` | canonical call ID, tool name, arguments |
|
||
| `tool_result` | canonical call ID, result, success/failure |
|
||
| `plan_submitted` | plan body, authoring model |
|
||
| `gate_opened` | gate type, reason |
|
||
| `delegation_started` | brief, target role, resolved model, caps |
|
||
| `delegation_completed` | handback note, artifacts, tokens, duration, exit status |
|
||
| `compaction` | summary, producing model, range of events replaced |
|
||
| `knowledge_retrieved` | document text as read, document ID, revision, retrieved-at (A17) |
|
||
|
||
Rules:
|
||
- **The log is scoped to a work item (0.2).** Every event carries its work item's ID,
|
||
and a model call for one work item renders only that item's events. Evaluation
|
||
capture (A16) may read across items.
|
||
- **Some calls belong to no work item,** and their events go to an **agent-level
|
||
scope**, with no work item ID: a duty run until it yields a finding, and the
|
||
choice of which item to switch to (0.2). Each such call renders only its own
|
||
events plus what code gives it. Agent-level events are never rendered into a
|
||
work item's call, and a work item's events reach an agent-level call only as a
|
||
code-rendered summary.
|
||
- **Canonical tool-call IDs are minted locally.** Provider IDs are mapped at the
|
||
boundary and never used as a join key. Providers differ in both ID format and in
|
||
where tool calls sit structurally (content blocks vs. a separate array on the
|
||
assistant message).
|
||
- **No provider-side handles for content.** If a Files API is used, store the bytes or
|
||
a content-addressed local path; upload or inline at render time. A provider file ID
|
||
must never be the only reference to something.
|
||
- **Vendor-opaque reasoning artifacts are stored, tagged with the producing model, and
|
||
never replayed outside the session that produced them.** This covers Anthropic
|
||
signed thinking blocks, OpenAI encrypted reasoning items, and Gemini thought
|
||
signatures. Inside a delegation, replay per that vendor's rules. On delegation exit,
|
||
the artifacts become archival only. A "session" here is the **delegation**, not the
|
||
process lifetime: a delegation resumed after a crash (A3) is the same session and
|
||
may replay its own artifacts.
|
||
- **Token budgeting is a render-time concern.** Context window and tokenizer are
|
||
properties of the model being called. Compute budget when building the request, not
|
||
when writing the log.
|
||
- **Compaction summaries are first-class log events** with the producing model
|
||
recorded, so a tier change does not silently inherit opaquely-compacted history.
|
||
- **Prompt cache breakpoints are placed by the renderer and never stored.** A tier
|
||
change means a cold cache — a cost event, not a correctness event.
|
||
|
||
### A11. Role Configuration
|
||
|
||
Model identity is expressed as **roles**, resolved to concrete model strings in config:
|
||
|
||
```yaml
|
||
roles:
|
||
executor: local/<model> # drives the loop, mechanical work
|
||
planner: <frontier> # authors plans
|
||
delegate: <frontier> # scoped sub-task execution
|
||
consultant: <frontier> # advisory, no repo access
|
||
```
|
||
|
||
"Best model this week" is a config edit. **The resolved mapping is pinned at task
|
||
start** and recorded in the log, so a mid-task config change cannot split a task
|
||
across two planners.
|
||
|
||
### A12. Escalation Triggers
|
||
|
||
The local model does **not** decide when it needs help. Small models are poorly
|
||
calibrated about their own competence and will escalate either constantly or never.
|
||
Triggers are observable from outside the model:
|
||
|
||
1. **Phase policy.** Planning and architecture escalate unconditionally. Mechanical
|
||
edits stay local.
|
||
2. **Failure count.** Local attempts; tests fail twice; escalate with the failures
|
||
attached to the brief.
|
||
|
||
A model-invoked `ask_expert` (A14) exists in addition to these, never as a
|
||
replacement, and carries a per-task budget so a confused executor cannot spend twenty
|
||
consults circling.
|
||
|
||
### A13. Delegation
|
||
|
||
Escalation is **delegation**: a frontier model receives a scoped sub-task, works
|
||
autonomously with full tool access, and hands back. It is not a consult, and the local
|
||
model does not interpret or re-derive the frontier model's reasoning.
|
||
|
||
**Harness — one, not one per vendor.** Do not adopt each vendor's agent SDK to obtain
|
||
their best agentic behavior; that recreates the lock-in this design avoids, relocated.
|
||
One loop, one cap regime, one handback contract.
|
||
|
||
**Normalization lives in the A10 renderer, not on the wire.** Vendors disagree
|
||
structurally about tool calling (content blocks vs. `tool_calls` arrays, tool-result
|
||
placement, streaming deltas) and about reasoning artifacts (signed thinking blocks,
|
||
encrypted reasoning items, thought signatures) — and a normalizing middlebox is lossy
|
||
exactly where a long-running delegation lives. So:
|
||
|
||
- **Delegations run over LiteLLM's passthrough route** (`/v1/messages` for Anthropic,
|
||
per A5), with the A10 renderer producing the vendor-native request body. The
|
||
renderer is a per-vendor adapter by definition — it already has to know each wire
|
||
shape to render the canonical log.
|
||
- **LiteLLM's normalized route is reserved for calls where fidelity is cheap:**
|
||
`ask_expert` (A14), fallback chains, and trialing a new vendor before its renderer
|
||
adapter exists.
|
||
- **Adding a vendor** therefore means writing one renderer adapter — or provisionally
|
||
accepting LiteLLM's normalization for that vendor and validating it against the A16
|
||
eval set before trusting it with delegations. A day-one build needs exactly one
|
||
adapter: the vendor actually in use.
|
||
|
||
**Scoping and authority.** Delegation sits **inside** the plan gate, not around it. A
|
||
frontier sub-agent that acts freely would bypass `submit_plan` (A4), which is the
|
||
structural control the design rests on.
|
||
|
||
- **Planning is itself a delegation** that produces a plan and stops. Human approves.
|
||
- **Execution delegations are scoped to a sub-task the approved plan already
|
||
contains.**
|
||
|
||
The frontier model is smart twice; neither time is it authorized to expand the work.
|
||
|
||
Because plans may be executed by a materially weaker model, the planner system prompt
|
||
must require **explicit, low-inference plans** — the executor cannot reconstruct
|
||
intent.
|
||
|
||
**Isolation and caps.**
|
||
- **Branch at delegation start.** Abort discards the branch. This gives delegations the
|
||
same atomicity property that boot-time reconciliation (A3) gives interrupted state
|
||
transitions.
|
||
- **Hard caps on turns, tokens, and wall clock.**
|
||
- **Checkpoint delegation events as they arrive.** Every `tool_call` / `tool_result` /
|
||
assistant turn inside a delegation is durably written to the canonical log when it
|
||
happens (per A2), never batched at handback. This is what makes a delegation
|
||
resumable after a crash — the deterministic resume rule lives in A3.
|
||
- **Defined failure return.** On cap breach or explicit failure, the local loop gates
|
||
to `AWAIT_INPUT` rather than resuming on top of a half-edited tree.
|
||
|
||
**Handback.** The final delegation action is a **structured handback note written for a
|
||
weaker reader**, not a human-facing summary:
|
||
|
||
- what changed
|
||
- what is now true that was not before
|
||
- what to do next
|
||
|
||
That note plus the resulting file state is the entire interface back to the local loop.
|
||
The delegation's internal transcript is stored alongside the composite event,
|
||
vendor-tagged, and never replayed into anything.
|
||
|
||
#### Rejected alternative: ask-and-apply
|
||
|
||
An alternative was considered in which the local model asks a frontier model for advice
|
||
and applies the answer itself. **Rejected.**
|
||
|
||
Its principal advantage was blast radius — the frontier model never touches the repo,
|
||
and everything flows through a gated local loop. That was explicitly not a concern
|
||
here.
|
||
|
||
Without it, the comparison is lopsided:
|
||
|
||
- Delegation lets the frontier model read the code, run the tests, and see the
|
||
failures. Ask-and-apply forces it to reason blind from whatever the local model
|
||
thought to include in the brief, and small models write poor briefs.
|
||
- The cost advantage is illusory: one delegation that succeeds beats three consult
|
||
round-trips that do not.
|
||
- Any variant where the local model *interprets* rather than applies verbatim
|
||
reintroduces the capability gap downstream of the expensive call.
|
||
|
||
The diff-application machinery, verbatim-fidelity rules, and local-model-as-verifier
|
||
design are all dropped.
|
||
|
||
### A14. `ask_expert` (retained, minimal)
|
||
|
||
One narrow case survives delegation: **questions requiring no repo access.** "Which of
|
||
these two approaches is better," "why does this library behave this way," "is this API
|
||
deprecated."
|
||
|
||
- Single stateless call, text answer.
|
||
- No diff application, no fidelity concerns, no session state.
|
||
- Reuses the delegation brief format.
|
||
- Structured output contracted **at the prompt level, not the API level** — vendors
|
||
differ on JSON mode and schema enforcement. Parse, validate against a local schema,
|
||
retry once.
|
||
|
||
Expected implementation size: small. Do not let it grow into a second escalation
|
||
subsystem.
|
||
|
||
### A15. Local Model Accommodations
|
||
|
||
- **Constrained decoding for tool-call JSON.**
|
||
- **Reduced tool schema for the executor role:** fewer tools, flatter arguments, no
|
||
deeply nested optionals. Maintain **two tool manifests keyed on role**, rendered at
|
||
request time like everything else.
|
||
|
||
### A16. Delegation Evaluation Capture
|
||
|
||
Capture a **consult/delegation eval set** from day one. Each `delegation_started` /
|
||
`delegation_completed` pair (A10) is already a clean input-output record; the
|
||
additional cost is marking outcomes as good or bad.
|
||
|
||
Without this, "best model this week" is decided by benchmark chatter, and swapping the
|
||
frontier tier becomes something to avoid because the effect is unmeasurable. With it, a
|
||
new model is a config change and a re-run.
|
||
### A17. Knowledge Store (Outline)
|
||
|
||
**Purpose:** durable knowledge that outlives a single task — an agent-maintained
|
||
**world model** (what infrastructure exists, how it is arranged) and, later,
|
||
operator-authored **procedures**. Distinct from A2, which holds *task* state: A2
|
||
is the agent's memory of what it is doing, A17 is its memory of what the world
|
||
looks like. Neither is a fallback for the other.
|
||
|
||
Part D describes the service; this defines the contract.
|
||
|
||
**Layout — sharing is read access, never write access.**
|
||
- Every document has **exactly one writer**: one agent, or the operator. Sharing
|
||
governs who may *read*. Concurrent writers therefore never exist, which
|
||
disposes of the last-write-wins objection Part D raises, without needing
|
||
distributed-write machinery.
|
||
- **Agent-private** space per agent: its own world model.
|
||
- **Shared** space: readable by the operator and designated agents. Default
|
||
membership is **operator + `nisse-ops`**. `nisse-dev` gets private space only —
|
||
B3 seals it from RFC1918 precisely so it cannot reach the NAS, Proxmox, or the
|
||
UDM, and a shared network-architecture document is a map of exactly those
|
||
things. Cross-agent read access is justified per collection, never assumed.
|
||
- The dev agent's project conventions live in the **repo it is working on**, not
|
||
here: versioned with the code, reviewed in the PR, and impossible to
|
||
desynchronise from the project.
|
||
|
||
**Agent-owned knowledge is always a cache, never authority.** An agent may rely on
|
||
it to know *what exists and where to look*. It must not rely on it for the facts an
|
||
action depends on. A prompt cannot enforce that: the model cannot be prevented from
|
||
*believing* a fact it has read. The enforcement is elsewhere, and it is structural:
|
||
- **Step preconditions (C2) carry the weight.** Every elevated write is an approved
|
||
step, and a step that depends on a fact states it as a precondition, checked
|
||
against the live system immediately before the step runs. A stale world model
|
||
can produce a wrong plan, but it cannot turn that plan into an action on a
|
||
system that no longer matches it.
|
||
- **Provenance at the human gate.** When a plan is drafted from recalled facts, the
|
||
approval message says so: "target recalled from the world model, observed
|
||
2026-08-02" versus "confirmed by `zfs list` two minutes ago". The operator is
|
||
the authority there; this makes the evidence quality visible.
|
||
- **Audit for the rest.** Retrievals are logged (A10), so a wrong fact can be traced
|
||
to the actions that followed it.
|
||
|
||
**Dropped:** withholding facts past a staleness bound at retrieval, and refusing
|
||
confirm-first actions without a verifying observation in the log. Both were
|
||
approximations of what step preconditions (C2) now check directly.
|
||
|
||
**Never an instruction channel, and never an approval channel.**
|
||
- **Task instructions never come from the knowledge store.** Work is driven by
|
||
Vikunja tickets (C4) and operator commands (B2). An agent must never read the
|
||
store looking for work to do.
|
||
- **Approvals never come from the knowledge store.** The registered
|
||
`ApprovalSource`s are Matrix (A1), Gitea (B2), and Vikunja (C4). A wiki page is
|
||
not one and must not become one.
|
||
- An agent must never write here intending to cause another agent to act. This
|
||
extends Part D's rejection of the store as a message bus: the erosion path is
|
||
one well-meaning "note for the other agent" at a time.
|
||
|
||
**Corrections flow through transport, not through direct edits.** The operator
|
||
corrects a fact by telling the agent, which is strictly better than editing the
|
||
page by hand: a hand edit is invisible to the agent's record, whereas an
|
||
instruction arrives on an authenticated channel from an allowlisted identity, is
|
||
recorded in the canonical log, and can be reconciled against what the agent
|
||
believed. Corrections are stored **verbatim with attribution**, never paraphrased —
|
||
a model rewriting a precise fact loses precision invisibly.
|
||
- **Break-glass:** the operator keeps write permission. The procedure reuses Part
|
||
F's idle flag — wait for idle, stop the agent, edit freely, restart. Single
|
||
writer holds trivially while the agent is down, and A2/A3 make stopping safe.
|
||
- Because that permission exists, an agent re-reads and compares the revision
|
||
immediately before writing, and re-merges rather than clobbering if it moved.
|
||
A safety net for the rare edit, not a designed workflow.
|
||
|
||
**Canonical log representation (A10).** Retrieved content is **snapshotted into the
|
||
log** — the text, plus document id and revision for provenance — never referenced
|
||
by handle alone: A10 forbids a provider handle being the only reference to content,
|
||
and these documents are mutable. Snapshot once per task at first use, not per turn.
|
||
A task resumed across a restart carries the old snapshot; step preconditions, not
|
||
a re-fetch, are what stop it acting on a stale copy.
|
||
|
||
**Retrieval is structural first, discretionary second** — the A12/A14 pattern.
|
||
- Procedures are **bound to entries in the C2 policy file** (a tool, or a protected
|
||
target), and fetched when a plan that uses that entry is drafted. Binding is
|
||
deterministic and auditable ("which procedure governed this action?" has an
|
||
exact answer), and costs little because the policy file must exist anyway.
|
||
- A model-invoked **search** exists *in addition* for discovery, never as a
|
||
replacement for the bound retrieval — a procedure the model may skip does not
|
||
constrain it.
|
||
|
||
**Outage posture is asymmetric.** Unlike B2's poller, "never fails a task" does not
|
||
generalise here.
|
||
- **Write failure never blocks:** retry, or record locally and reconcile.
|
||
- **Read failure for a bound procedure gates the plan.** Planning without it is not
|
||
a delayed reaction; it is acting unconstrained on precisely the operation someone
|
||
wrote a procedure for.
|
||
- Read failure for anything else proceeds, logging the gap.
|
||
|
||
**Accepted single point of failure.** Once the agent is the sole author and Outline
|
||
the only home, losing Outline loses the world model. No local mirror: infrastructure
|
||
facts are re-derivable by observation, which is the same premise the cache rule
|
||
rests on.
|
||
|
||
**Deferred:** the operator-writable procedures collection. The interface is defined
|
||
here; the content arrives when there are procedures worth writing, and binds to the
|
||
C2 policy file.
|
||
|
||
**Concrete mapping onto Outline.** Observed in the running instance: a **collection**
|
||
carries per-user permissions with three tiers — visible-to (read), read-write, and
|
||
manager. Permissions are collection-scoped, not per-document.
|
||
- Therefore **one collection per writing principal**, with read membership granted to
|
||
everyone else who needs it: `nisse-ops-world` (write: `nisse-ops`),
|
||
`nisse-dev-world` (write: `nisse-dev`), `procedures` (write: operator).
|
||
- **What this enforces, precisely:** no *other agent* can write a given agent's world
|
||
model — the collision that would otherwise be silent and unattributable. It does not
|
||
enforce operator-versus-agent exclusion, because the operator holds manager on their
|
||
own wiki and can always grant themselves write. That half is procedural: the
|
||
break-glass routine stops the agents first, so concurrent writers do not exist while
|
||
editing.
|
||
- **Assign per user, not per group.** Outline groups appear to be internal to Outline
|
||
rather than federated with LLDAP (A8), so using them would put membership in a second
|
||
place that nothing syncs. With three principals the manual mapping is trivial and
|
||
obvious; revisit only if the cast grows.
|
||
- **Each agent's API token must belong to that agent's own Outline user.** Outline API
|
||
keys are per-user, so the token *is* the identity every permission above evaluates
|
||
against. A token minted from the operator's account — or an admin's — silently voids
|
||
the entire boundary while appearing to work.
|
||
- **Identify collections and documents by ID, from config and state.** Collection IDs
|
||
come from env (`NISSE_OUTLINE_*_COLLECTION`), never discovered from "whatever the
|
||
token can write": a permission change must not silently redefine what an agent
|
||
considers its own. Document IDs are recorded in the state store (A2) when created and
|
||
used for updates, which also makes writing idempotent — a retry after a crash updates
|
||
the document instead of creating a second one.
|
||
- **Revision reads, not conditional writes.** Because break-glass stops the agents,
|
||
compare-and-swap is unnecessary; what is needed is the ability to *observe* that a
|
||
document changed since the agent last read it, feeding the A3 divergence check.
|
||
Whether Outline exposes a revision identifier or only a modified timestamp is
|
||
unverified — either satisfies this.
|
||
|
||
#### Considered and deferred: Outline's MCP server
|
||
|
||
Outline can expose search, read, create and edit over MCP (remote, Streamable HTTP,
|
||
enabled workspace-wide). **Not adopted for the retrieval path**, because A17's
|
||
contract depends on this library sitting *between* the model and the store:
|
||
snapshotting into the canonical log, binding procedures to C2 policy entries, and
|
||
gating a failed bound read all happen to a retrieval before the model sees it.
|
||
MCP's purpose is the opposite — direct model access — and a procedure the model may
|
||
decline to fetch does not constrain it (A12).
|
||
|
||
It remains a reasonable candidate for the *discretionary* search tool later, and unlike
|
||
the vendor agent SDKs A13 rejects, MCP is a neutral standard rather than lock-in: one
|
||
client would serve many servers if others are ever added. Before adopting it, verify
|
||
that it honours the key owner's **collection** permissions rather than mere workspace
|
||
membership, and that the workspace-level toggle does not force broader exposure than
|
||
intended.
|
||
|
||
|
||
### A18. Prompt Injection
|
||
|
||
**Assume any model that reads untrusted content may be compromised.** There is no
|
||
robust way to tell a model "this is data, not instruction". Instructions and data
|
||
reach it as one token stream, and every marker meant to separate them is text that
|
||
the data can imitate or argue against. Role separation, delimiters and detection
|
||
classifiers lower the success rate of injection. None of them bring it to zero, and
|
||
an attacker gets to try repeatedly. So the design does not depend on the model
|
||
telling them apart. It limits what a compromised model can do.
|
||
|
||
Untrusted content, for these agents: alert payloads, log lines, ticket and comment
|
||
text, web pages the browser reads, repository contents, and downloaded packages.
|
||
|
||
Requirements:
|
||
- **Gates hold against a compromised model.** Nothing a model says can approve a
|
||
plan (A4), widen what a plan authorizes (C2), or reach a credential that was not
|
||
mounted (F3). Every one of those rules is enforced in code, outside the model.
|
||
- **Approval messages are rendered by code from what will actually run** (C2),
|
||
never from a model's description of it. Otherwise a compromised model can write
|
||
"restart Gitea" above a step that destroys a dataset.
|
||
- **Instruction channels are operator-scoped.** Only allowlisted identities can
|
||
drive work: operator commands (A1), PR review comments (B2), action-triggering
|
||
tickets (C4). Content from anyone else is data.
|
||
- **Parse intake into structured fields in code** wherever the source allows it,
|
||
e.g. alert name, severity, host, from Alertmanager's JSON. Less free text then
|
||
reaches the model at all, and what reaches it is labelled by where it came from.
|
||
- **Some injection is accepted because it can only reorder work.** An alert title
|
||
can tell the switch decision (0.2) to pick it first. The worst case is ordering,
|
||
and the switch notice makes it visible.
|
||
- **Check each agent against the lethal trifecta:** access to private data, exposure
|
||
to untrusted content, and a channel to send data out. Damage needs all three.
|
||
- `nisse-dev` reads untrusted content and holds little private data: its Gitea
|
||
token and the repos it works on. Its only ways out are Gitea and package
|
||
registries (B3). The registries are an accepted, narrow residual channel.
|
||
Routing them through a local mirror would close it, if that ever becomes worth
|
||
doing.
|
||
- `nisse-ops` has all three: it reads alerts and logs, holds infrastructure
|
||
credentials, and has broad egress. It relies on the gates above, not on
|
||
removing a leg.
|
||
|
||
---
|
||
|
||
## PART B — THE DEV AGENT / `nisse-dev` (build first)
|
||
|
||
A thin app over `nisse-core`. Plans and implements software projects; produces PRs.
|
||
|
||
In 0.2's terms, a **project** below is a dev work item on a repo scope. `use
|
||
<project>` opens or resumes a work item on a repo, and `project complete` closes the
|
||
work item. The repo, its clone, and any other work items on it continue.
|
||
|
||
### B1. Workflow (state machine)
|
||
|
||
```
|
||
IDLE
|
||
→ [use <project>] clone repo, begin architecture planning
|
||
PLANNING_ARCH
|
||
→ agent drafts architecture + implementation plan → submit_plan
|
||
AWAIT_ARCH_SIGNOFF ← human gate (approve / reject+feedback)
|
||
→
|
||
MILESTONE_PLANNING
|
||
→ agent drafts plan for a specific milestone → submit_plan
|
||
AWAIT_MILESTONE_SIGNOFF ← human gate
|
||
→ [start milestone <n>]
|
||
EXECUTING autonomous; no per-command approval
|
||
→ may enter AWAIT_INPUT (ask_human) any time it's unsure
|
||
PR_OPEN
|
||
→ open PR; react to review comments and commits (review loop)
|
||
→ on acceptance → next milestone, or project complete
|
||
```
|
||
|
||
### B2. Requirements
|
||
|
||
- **One active project at a time** (park-and-switch, 0.2). Operator selects it;
|
||
other projects stay parked at their last position and resume where they left off.
|
||
- **Operator commands** (over transport): `use <project>`, `approve`,
|
||
`reject <feedback>`, `start milestone <n>`, `status`, `stop` (A4), plus free
|
||
text during planning (forwarded to the agent as answers).
|
||
- **Plan gating** per A4: architecture plan and each milestone plan require sign-off
|
||
before the next phase. Enforced structurally. Plans and approvals are externalized
|
||
to Gitea issue comments (Part H, decided); Matrix and Gitea are both approval
|
||
channels, with revision binding and the uniform `approve` UI per A4.
|
||
- **Autonomous execution** within a started milestone: builds, runs, tests without
|
||
per-command approval; asks questions any time via `ask_human`.
|
||
- **Gitea integration:**
|
||
- *Outbound:* `git` + Gitea API to push branches and open PRs, via a dedicated
|
||
Gitea **service account** with a scoped token. Record any runnable test service's
|
||
URL/port in the PR description.
|
||
- **The project repo's default branch must be protected, and this is what makes
|
||
the PR gate real.** The service account needs write access to push branches —
|
||
and in Gitea, *write access includes merging pull requests*. Repository
|
||
ownership does not help: an account that merely collaborates can still merge,
|
||
and one that owns the repo can merge and delete it. So without branch
|
||
protection the agent can merge its own PR, and the review gate holds only
|
||
because the agent's code does not call the merge endpoint. That is precisely
|
||
the advisory guarantee A4 exists to rule out.
|
||
- Required on the default branch: **block direct pushes** by the agent
|
||
account, and **restrict merging** to the operator (a merge whitelist, or
|
||
required approvals that the agent's own account cannot satisfy).
|
||
- Feature-branch pushes are unaffected, so the agent's normal workflow is
|
||
unchanged.
|
||
- **Verify it rather than assume it.** The property is observable from
|
||
outside: the agent's own token must be refused when it attempts to merge
|
||
its own PR and when it pushes to the default branch. Assert both, so that
|
||
relaxing the protection later fails a test instead of silently removing
|
||
the gate. (Discovered the hard way — before protection was configured, the
|
||
agent's token merged its own PR successfully.)
|
||
- *Inbound:* react to PR comments, commits, pushes. **Poll** the Gitea API
|
||
(15–30s) rather than take webhooks, to keep the VM outbound-only. Intake is
|
||
structured JSON — never via a chat bridge.
|
||
- *On Gitea unavailability:* the poller backs off (exponential, capped at a few
|
||
minutes) and retries indefinitely, logging the outage. Polling is pure intake —
|
||
an outage delays reactions but never fails a task or forces a state transition.
|
||
Outbound Gitea operations that fail mid-flight are already covered by the
|
||
intent log and boot-time reconciliation (A2/A3).
|
||
- **Browser (A6)** scoped to the **internal test network only** — used to exercise
|
||
web apps it built and reproduce bugs. **No SSO needed** for this.
|
||
|
||
### B3. Deployment & Isolation
|
||
|
||
- **Dedicated VM** (Proxmox), as for every agent (F1). The Dev Agent has an
|
||
additional reason: it builds and runs Docker containers, so it needs its own real
|
||
Docker daemon. (Docker-in-a-container via socket-mount ≈ host root; via dind =
|
||
nested-storage pain. The VM boundary is the correct trust boundary for unattended
|
||
arbitrary command execution.)
|
||
- **Sealed to start:** `nisse-dev`'s egress allowlist (F2) permits LiteLLM, Gitea,
|
||
and package registries, and there is **no ingress**. Egress denial must include
|
||
**RFC1918 except the specific LiteLLM/Gitea IPs**, and also the tailnet's
|
||
**`100.64.0.0/10`**, which is not RFC1918. Sealed from the internet must also mean
|
||
no reach to NAS/Proxmox/UDM.
|
||
- **The LAN-resident inference host is not a second exception.** It sits behind
|
||
LiteLLM (A5), so the allowlist keeps exactly one LLM destination regardless of how
|
||
many models or vendors are in play.
|
||
- **No Tailscale** on this VM (it routes around firewall policy). Manage via Proxmox
|
||
console / jump host.
|
||
- **Own VLAN from day one** (even a VLAN of one) and test services on **predictable
|
||
ports** (e.g. 18000–18099), so opening access later is a firewall edit, not a
|
||
rebuild. (Sealed for now; may relax later.)
|
||
- Test containers on a dedicated **internal Docker network with no route out**.
|
||
- Build-daemon storage on a **separate mount** from the state volume, so
|
||
`docker system prune` between milestones can't touch agent state.
|
||
|
||
---
|
||
|
||
## PART C — THE SYSADMIN AGENT / `nisse-ops` (follow-on)
|
||
|
||
A thin app over the extracted `nisse-core`. Operates infrastructure; driven by
|
||
tickets; gated on consequences. Built after the Dev Agent works.
|
||
|
||
### C1. How it differs (not reducible to config)
|
||
|
||
| Axis | Dev Agent | Sysadmin Agent |
|
||
|---|---|---|
|
||
| Rollback | revert snapshot / discard branch | none for `zfs destroy` or a firewall lockout |
|
||
| Trust model | allow-all in sandbox | **approved step sequences**, checked on every call |
|
||
| Work shape | long milestones → a PR | short reactive tasks + scheduled checks → live changes |
|
||
| Driver | Gitea projects | **Vikunja tickets** |
|
||
| Placement | sealed, own VLAN, no Tailscale | Tailscale, credentials, broad reach |
|
||
|
||
Running both roles in one instance would collapse isolation — build them as separate
|
||
deployments over the same library.
|
||
|
||
### C2. Permission Policy Engine *(the defining addition)*
|
||
|
||
**Every write requires an approved plan first.** Write credentials exist only in the
|
||
elevated MCP server instances (F3), and those are mounted only after plan approval.
|
||
Read-only telemetry (metrics, logs, status) goes through the read-only instances,
|
||
which are mounted by default, so it needs no plan.
|
||
|
||
This is the sysadmin analog of the dev agent's plan gate. The dev agent has a second
|
||
gate after the work (the PR); the sysadmin agent does not, because a live change has
|
||
no review step before it takes effect. So its plan gate has to be exact.
|
||
|
||
**An ops plan is a list of literal steps, and approval covers exactly those steps.**
|
||
A write plan is submitted through `submit_plan` (A4) as structured data, not prose.
|
||
Each step names:
|
||
- the **tool and target**, e.g. the elevated Proxmox instance for `garrett-hall`,
|
||
node and VM ID;
|
||
- the **arguments**, in full. A config change carries the exact diff;
|
||
- an optional **precondition**, checked against the live system just before the
|
||
step runs;
|
||
- an optional **expected outcome**, checked just after, e.g. host reachable again,
|
||
config hash matches;
|
||
- a **retry allowance**, default one.
|
||
|
||
One approval can cover a sequence with several high-consequence steps: "reboot the
|
||
host, change the config, reboot again" is one plan and one `approve`. The plan may
|
||
also carry prose explaining it, which is context for the operator, never scope.
|
||
|
||
**The approval message is rendered by code, from the steps.** What the operator reads
|
||
is what will run; a model's description of a step is never the thing approved (see
|
||
A18). Steps that are irreversible, that touch a protected target, or that will take
|
||
down the agent's own host are flagged at the top of the message.
|
||
|
||
**Execution is checked against the approved steps.** Every tool call passes through a
|
||
matcher before it reaches an MCP instance:
|
||
- **A read-only call is always allowed.** The agent can look around between steps.
|
||
- **An elevated call runs only if it exactly matches the next unfinished approved
|
||
step.** Arguments are normalized against the tool's JSON Schema before comparison
|
||
(types, key order), so a model's formatting cannot cause a false mismatch.
|
||
- **Anything else halts:** a call not in the plan, different arguments, steps out of
|
||
order, or a step run again after it completed. The agent moves to `AWAIT_INPUT`,
|
||
and the operator decides whether to continue, re-plan, or abandon.
|
||
- **A failed step may be retried** up to its allowance. A failed precondition or
|
||
expected-outcome check halts, without retry: the world does not match the plan,
|
||
and improvising around that is exactly what the gate exists to prevent.
|
||
- **A step that may have interrupted the agent** (a reboot of its own host) resumes
|
||
under A3's sequence rule.
|
||
|
||
**Approvals expire.** An approved plan that has not started within the expiry window
|
||
must be approved again. An approval is a judgment about the world as it was when the
|
||
operator read the plan. The window is policy data (Part H).
|
||
|
||
**Classification is small, conservative, and data.** The policy is a declared,
|
||
versioned file, like the egress allowlist (F2). It holds:
|
||
- **Instance privilege.** Read-only or elevated, per MCP instance (F3). This alone
|
||
decides whether a call needs an approved step.
|
||
- **Protected targets.** The NAS, the gateways, the hosts the agents run on, and
|
||
anything else whose loss is expensive. Protection changes how a step is
|
||
presented, not whether it is allowed.
|
||
- **Each elevated tool's target argument** (`vmid`, `node`, a device MAC), so the
|
||
matcher and the renderer can tell which target a call touches. An elevated tool
|
||
with no mapping is treated as touching a protected target.
|
||
|
||
Defaults fail safe. Anything unclassified is treated as elevated. MCP tool annotations
|
||
(`readOnlyHint`, `destructiveHint`) are declared by the server itself, so they may
|
||
make a tool stricter, never looser. A model may propose entries for a new server's
|
||
tools offline, as a PR the operator reviews. Nothing classifies at run time.
|
||
|
||
**Deferred, with the reason:**
|
||
- **Bounded matching** for reversible tools: allowing any call within an approved
|
||
target and action, not only the exact arguments. Added one tool at a time, where
|
||
exact matching causes real friction. It needs a per-tool reversibility
|
||
classification, which is the up-front cost exact matching avoids.
|
||
- **Standing approvals:** a plan template with a trigger, a rate limit and an
|
||
expiry, e.g. "restart Gitea on sustained 502, at most twice an hour, notify
|
||
after". The same matcher, run without a fresh `approve`. Added when a routine fix
|
||
has been approved by hand often enough to show its shape.
|
||
|
||
Special cases:
|
||
- **Browser actions against admin UIs cannot be matched as steps,** since a click is
|
||
not a stable literal. Each one is confirm-first over transport, even within an
|
||
approved plan. Changes to **network gear (UniFi) are the most conservatively
|
||
gated action in the system**: a bad remote UI click can sever the path used to fix
|
||
it.
|
||
- **Action-triggering tickets are operator-scoped** (C4). Auto-generated tickets
|
||
(e.g. from a monitoring alert) can only lead to a plan, which the operator
|
||
approves like any other. They never drive unattended.
|
||
|
||
### C3. Interfaces
|
||
|
||
- **MCP servers (F3):** Proxmox and UniFi, at both sites, through per-site,
|
||
per-privilege server instances on the agent's own Docker host. This is the primary
|
||
path to that infrastructure.
|
||
- **Browser (A6):** into admin UIs. **Prefer real APIs where they exist** (Proxmox,
|
||
TrueNAS good; UniFi now reachable over MCP); use the browser only for genuinely
|
||
UI-only gaps. It's the least stable interface. For network gear, it is a fallback
|
||
for API gaps, not the primary path.
|
||
- **SSO (A8):** logs into admin UIs as `svc-nisse-ops`. This account is a high-value
|
||
credential; its blast radius equals its Authelia scope, so scope it minimally.
|
||
- **Secrets (A7):** UI/SSO logins and service API tokens from Bitwarden.
|
||
- **Event intake:** monitoring feeds the orchestrator as **structured JSON**
|
||
(directly or via Alertmanager), never through a Matrix bridge. The agent posts
|
||
curated, state-aware updates to `#ops` ("Gitea 502ing, restarted, back up").
|
||
|
||
### C4. Ticket-Driven Control (Vikunja)
|
||
|
||
The sysadmin analog of "dev agent driven by Gitea."
|
||
- **Vikunja ticket = task of record** (durable, has status/queue/history): the
|
||
external record of a work item (0.2). **Matrix = live conversation about it**
|
||
(questions, halts, progress).
|
||
- **Scheduled checks and alert intake are duties (0.2).** Tickets they generate are
|
||
duty findings, deduplicated against open tickets, and like any auto-generated
|
||
ticket they can only lead to a plan the operator approves (C2).
|
||
- **Flow:** ticket filed → agent picks up → discuss/confirm over Matrix → update
|
||
status as it progresses → close with result.
|
||
- **Mechanics:** Vikunja REST API + webhooks (same two-direction, JSON-only pattern
|
||
as Gitea); dedicated service account, scoped token from Bitwarden. Verify the
|
||
installed version's webhooks fire on comment creation; if not, poll comments as
|
||
B2 polls Gitea.
|
||
- **Approvals:** Vikunja is an `ApprovalSource` (A4) for plan gates on its own
|
||
tickets, via `approve rev-N` comments. Live prompts (C2's halts and browser
|
||
confirmations) stay Matrix-primary — polling latency makes a ticket comment a poor
|
||
interactive channel — with the outcome written back to the ticket as the record.
|
||
- **Trust:** a driving ticket is an instruction channel into an infrastructure-
|
||
changing agent → action-triggering ticket creation is operator-scoped (C2).
|
||
|
||
### C5. Deployment & Isolation
|
||
|
||
- Longer-lived, **stateful VM** (not disposable), dedicated to this agent (F1).
|
||
- Has **Tailscale** + scoped credentials to reach infrastructure across both sites.
|
||
- **Its MCP servers (F3) run on its own Docker host.** Its egress allowlist (F2)
|
||
therefore includes the Proxmox and UniFi endpoints at both sites, even though the
|
||
agent process never opens those connections itself.
|
||
- Updated **in place** via the container pipeline, **restart gated on an idle flag**.
|
||
|
||
---
|
||
|
||
## PART D — NOTES / KNOWLEDGE (Outline)
|
||
|
||
Shared knowledge store for both agents (sysadmin-primary). Not a message bus — see
|
||
note below.
|
||
|
||
Requirements:
|
||
- **Read — runbooks as context.** Procedures written in Outline are fetched before
|
||
acting, constraining the agent toward *your* procedures over improvised ones.
|
||
- **Write — durable record.** Agent writes change logs / incident notes / findings
|
||
back to Outline: searchable, off-box, and an extra externalized record that
|
||
survives volume loss.
|
||
- Access via Outline **REST API**, dedicated service account, token from Bitwarden.
|
||
- **The library contract is A17**, which settles what this section left implicit:
|
||
per-agent private space plus a shared space, one writer per document, agent-owned
|
||
knowledge treated as a cache rather than authority, and the structural means of
|
||
keeping it that way.
|
||
|
||
**Deliberately rejected:** using a shared notes store (Outline *or* an Obsidian vault)
|
||
as an **agent-to-agent message bus**. It's unstructured, race-prone (last-write-wins,
|
||
no delivery semantics), and a shared read-write store both agents touch would
|
||
undermine the dev/sysadmin isolation boundary. Agent coordination, if ever needed,
|
||
goes through the **human**, **Matrix**, or a **Vikunja ticket** handed from one agent
|
||
to another — or a real message queue if genuine multi-agent async ever arrives.
|
||
|
||
---
|
||
|
||
## PART E — SUPPORTING SERVICES (installed)
|
||
|
||
**Status: all supporting services are installed and running.** What remains for each
|
||
is per-agent *configuration* (service accounts, scoped tokens, rooms), created as
|
||
each agent is built — not standing up the service itself.
|
||
|
||
- **Matrix homeserver** (Synapse/Conduit): ✅ installed. *Remaining per-agent:* create
|
||
the two bot identities (`@nisse-dev`, `@nisse-ops`), one room per agent + `#ops`.
|
||
- **Gitea:** ✅ installed. *Remaining per-agent:* `nisse-dev` service account + scoped
|
||
token; enable native-JSON webhooks or confirm API polling access.
|
||
- **LiteLLM proxy:** ✅ installed, and **on the path from the first LLM call** (A5) —
|
||
it is the agent VM's single LLM destination, providing per-agent virtual keys with
|
||
budget caps, central logging, vendor swap, fallback routing, and the Anthropic
|
||
passthrough. *Remaining per-agent:* mint a virtual key; configure routes for the
|
||
local inference host and each frontier vendor, with keys sourced from Bitwarden.
|
||
- **Local inference host:** 🔜 **decided in principle, hardware pending** — a
|
||
4090-class server (Part H). Interim: a Jetson Nano running Ollama validates the
|
||
plumbing, and the `executor` role maps to a Haiku-class frontier model via LiteLLM
|
||
until the server arrives. Everything in A5, A12, and A15 assumes the eventual host
|
||
sits on the LAN behind LiteLLM.
|
||
- **Vikunja:** ✅ installed. *Remaining (sysadmin):* service account + scoped token;
|
||
webhook/poll config (Part C4).
|
||
- **Outline:** ✅ installed. *Remaining per-agent:* service account + API token
|
||
(Part D).
|
||
- **Bitwarden/Vaultwarden:** ✅ installed. *Remaining per-agent:* dedicated
|
||
minimally-scoped machine account + bootstrap token (A7).
|
||
- **LLDAP + Authelia:** ✅ installed. *Remaining per-agent:* create `svc-nisse-dev` /
|
||
`svc-nisse-ops` principals, scoped groups, and the narrow unattended-auth exception
|
||
(A8).
|
||
|
||
---
|
||
|
||
## PART F — DEPLOYMENT MODEL (all agents)
|
||
|
||
These rules apply to every agent, current and future. Parts B3 and C5 record how each
|
||
existing agent instantiates them.
|
||
|
||
### F1. Agent hosts: every agent gets its own VM
|
||
|
||
- **Every agent gets its own VM** (Proxmox). No agent runs as a container on a shared
|
||
host, and no two agents share a VM.
|
||
- **Why, in general:**
|
||
- *Isolation boundary between agents.* The VM is the trust boundary (Part H). A
|
||
compromised or misbehaving agent must not reach another agent's state,
|
||
credentials, or processes.
|
||
- *Per-agent credential attribution.* Everything an agent holds lives on its own
|
||
host and is issued to its own identity, so every upstream action traces back to
|
||
exactly one agent.
|
||
- **Agent-specific reasons add to this; they are not its foundation.** The Dev Agent
|
||
also needs a real Docker daemon, because it builds and runs containers (B3). An
|
||
agent that builds no code still gets a VM, for the two reasons above.
|
||
|
||
### F2. Egress policy: per agent instance
|
||
|
||
- **Every agent instance has its own default-deny allowlist.** Requirements differ by
|
||
agent type. No agent inherits another agent's policy.
|
||
- **The allowlist is a declared artifact,** versioned alongside the agent's
|
||
definition. Each entry is justified by the component that needs it.
|
||
- **The declaration point is fixed. The enforcement point is not.** Policy may be
|
||
enforced at the VM, at the Docker network, at the UniFi firewall, or at several of
|
||
these at once. Layering is deliberate: enforcement at the network layer survives
|
||
compromise of the agent's own host.
|
||
- **The allowlist is the union** of what the agent reaches directly and what its MCP
|
||
servers (F3) reach on its behalf. `nisse-ops` must permit Proxmox and UniFi
|
||
endpoints at both sites, even though the agent never opens those connections
|
||
itself.
|
||
- **The tailnet is `100.64.0.0/10`** (CGNAT shared address space), not RFC1918. A
|
||
rule written around RFC1918 denial does not cover it. Deny it explicitly.
|
||
|
||
### F3. MCP servers
|
||
|
||
**One instance per agent VM.** MCP servers run as containers on the owning agent's
|
||
Docker host. Images are shared through the OCI registry. Processes are not shared
|
||
between agents.
|
||
|
||
- *A shared process means a shared credential.* The upstream API's own audit log
|
||
then cannot attribute a call to a specific agent. Separate instances with separate
|
||
tokens give attribution at the API layer, instead of depending on our own
|
||
transcripts. This matters most for a read-only auditing agent, such as a future
|
||
`nisse-security`.
|
||
- *An agent should not host the tooling used to inspect it.*
|
||
- *Duplication is nearly free.* These servers wrap remote HTTP APIs and hold no
|
||
state. (Stateful servers are the exception that could justify relocation. That
|
||
item is parked in Part H.)
|
||
|
||
**One instance per (service, site, privilege level).**
|
||
|
||
- **Site belongs in the server's identity,** using the site identifier (Part 0), not
|
||
in a `site` tool argument. A wrong-house action should require a tool that isn't
|
||
mounted, not just a wrong parameter value.
|
||
- **Read-only and elevated access are separate instances** with separate upstream
|
||
credentials, each from its own machine account (A7). The read-only instance is
|
||
mounted by default. The elevated instance is mounted only after plan approval (C2).
|
||
This puts the capability gate at the credential layer, instead of a flag inside a
|
||
third-party server.
|
||
- **Per-task tool profiles** mount only the servers that a given task's scope
|
||
requires.
|
||
|
||
#### Rejected alternative: routed meta-tools / lazy tool registration
|
||
|
||
Several community Proxmox and UniFi MCP servers use search-then-dispatch over a large
|
||
generated tool catalog. This is **rejected in favor of per-task tool profiles**:
|
||
|
||
- *Two extra round-trips per call,* which is worse with a local model driving the
|
||
control loop.
|
||
- *The model can no longer discover capabilities it wasn't already looking for.*
|
||
That is backwards for exploratory diagnostic work.
|
||
- *Arguments arrive as an opaque blob,* instead of being constrained by per-tool JSON
|
||
Schema.
|
||
- *A dynamic tool list defeats prompt prefix caching,* and caching is much of what
|
||
makes a large static list affordable.
|
||
|
||
Acceptable only when consuming a third-party server whose surface we can't trim.
|
||
|
||
### F4. Containers, images, and restarts
|
||
|
||
- **Containers + persistent volumes.** Stateless image; named volumes carry state
|
||
across redeploys (and allow optional persistence between deployments).
|
||
- **Image delivery:** Gitea Actions → Gitea OCI registry → Watchtower for the *pull*.
|
||
- **Gate the restart, not the pull:** an idle/"safe-to-restart" flag; Watchtower must
|
||
never restart mid-work. *Unplanned* interruptions (reboot/crash) are handled by the
|
||
durability layers (A2/A3), not by restart timing.
|
||
- **Carve-out: third-party MCP server images** (F3) are excluded from Watchtower.
|
||
They hold root-equivalent credentials to Proxmox and UniFi, so they are
|
||
digest-pinned and updated deliberately after review.
|
||
- **Survive reboots:** VM + Docker service set to start on boot; `restart:
|
||
unless-stopped`; ZFS snapshots of the state dataset (scheduled + on each approval).
|
||
- **Terraform + cloud-init:** deferred. When adopted, `bpg/proxmox` + cloud-init,
|
||
VM definitions as code, clean `destroy`/`apply`; the dev agent becomes a candidate
|
||
for immutable rebuilds.
|
||
|
||
---
|
||
|
||
## PART G — BUILD ORDER
|
||
|
||
1. **State store + durability (A2/A3)** — critical path; everything sits on it.
|
||
2. **Transport (A1, Matrix) + control loop/gating (A4)** with a **stub agent** that
|
||
just echoes plans — get the approval loop feeling right before any real LLM.
|
||
3. **LiteLLM + LLM client interface (A5).** The interface must exist at the **first
|
||
real LLM call**, not as a later cutover. Mint a virtual key with a budget cap;
|
||
verify prompt caching survives the passthrough.
|
||
4. **Canonical log schema and renderer (A10).** Before any transcript accumulates in a
|
||
provider-shaped form that would have to be migrated later.
|
||
5. **Local executor loop (A11/A12/A15)** — role resolution pinned at task start, phase
|
||
policy, failure-count escalation, reduced tool manifest, constrained decoding.
|
||
6. **Gitea layer** — polling intake, PR creation, comment/commit handling.
|
||
7. **Delegation harness + plan-gate integration (A13/A14).** Begin capturing the eval
|
||
set here (A16); the records are free once the composite events exist.
|
||
8. **Browser tool (A6)** for `nisse-dev` — test apps on the sealed network.
|
||
9. First **genuinely unattended milestone** (budget caps + ZFS rollback earn their keep
|
||
here). Crash-atomic writes and reconcile-on-boot must be in place first.
|
||
10. **Extract `nisse-core`**; build the **Sysadmin Agent** (`nisse-ops`): policy
|
||
engine (C2), SSO + scoped browser (A8/A6), secrets (A7), Vikunja control (C4),
|
||
knowledge store (A17/D) — world model first, operator procedures when the
|
||
C2 policy file they bind to exists — scheduled checks, alert intake.
|
||
- With the monorepo layout (Part H, decided), extraction is a **package split**
|
||
within one tree: move code behind a package boundary, no release machinery
|
||
required.
|
||
|
||
---
|
||
|
||
## PART H — OPEN DECISIONS
|
||
|
||
- **Inference hardware — resolved in principle, no longer blocking.** Target: a
|
||
**4090-class host (24GB VRAM)** running a **24–32B open model** at 4–5-bit
|
||
quantization (candidates: Devstral Small 24B, Qwen3-32B / Qwen3-30B-A3B,
|
||
GPT-OSS-20B). Models of this class are competent at tool calling and mechanical
|
||
edits, which puts the system in "tiering genuinely load-bearing" territory rather
|
||
than "local model as dispatcher." Consequences:
|
||
- **Context, not weights, is the VRAM pressure.** A 32B at 4-bit leaves roughly
|
||
5GB for KV cache, and agent loops render long contexts. Model size vs. context
|
||
length vs. KV-cache quantization is an empirical trade — tune it, and the
|
||
thresholds that depend on local capability (A12 failure count, A14 consult
|
||
budget, A15 manifest size), at build-order step 5 on the real hardware.
|
||
- **Interim posture:** until the server exists, the `executor` role points at a
|
||
cheap frontier model (Haiku-class) through LiteLLM — a config entry under A11,
|
||
nothing structural, so the build order does not stall. The Jetson Nano validates
|
||
plumbing (Ollama → LiteLLM → agent) only; nothing learned from models it can run
|
||
transfers to threshold tuning.
|
||
- If the 24GB class proves insufficient in practice, additional hardware is the
|
||
fallback — the role indirection (A11) makes that swap invisible to the agents.
|
||
|
||
The rest resolve when reached, not now.
|
||
|
||
- **Repository layout — decided: one monorepo, three packages.** For a solo
|
||
operator, three repos tripled the admin surface (tokens, CI, webhooks,
|
||
permissions, backups) and imposed a version-pinning contract on `nisse-core` —
|
||
coordinated bumps every time the shared interface moves — while buying nothing:
|
||
the repo boundary was never a trust boundary in this design (the VM is), and
|
||
independent release cadence is irrelevant solo. A monorepo is also less friction
|
||
while `nisse-core` is being extracted from a working `nisse-dev` and its seams
|
||
are still moving. One repo can still build both agent images via path-filtered
|
||
CI. Splitting later stays easy if a real need appears; merging later would not
|
||
have been.
|
||
- **Sysadmin fallback browser access to network gear:** standing vs. per-task
|
||
hand-off. With UniFi reachable over MCP (F3), the browser is no longer the primary
|
||
path to network gear. It is a fallback for operations the UniFi API does not
|
||
expose. The open question is whether that *fallback* access is standing or handed
|
||
off per task. The lock-yourself-out risk still argues for per-task.
|
||
- **Ops gating model (C2) — decided: approved step sequences, exact matching.** A
|
||
per-tool allow/confirm taxonomy was the original plan. It would have to be
|
||
written in advance for a tool catalog that will grow large, and every mistake in
|
||
it is either friction or a hole. Approving literal step sequences makes most of
|
||
it unnecessary: every elevated write has to be an approved step anyway, and
|
||
classification is left to decide only how an approval is *presented* (protected
|
||
targets, irreversible steps). What remains open is data, best filled in with the
|
||
agent half-built in front of you: the protected-target list, each elevated
|
||
tool's target argument, and the approval expiry window.
|
||
- **Parked: bounded matching and standing approvals (C2).** Both loosen exact
|
||
matching and reuse the same matcher. Add bounded matching one tool at a time,
|
||
where exact matching causes real friction. Add standing approvals when a routine
|
||
fix has been approved by hand often enough to show its shape.
|
||
- **Approved-plan externalization — decided: Gitea-backed from the start.** Plans are
|
||
posted as issue comments on the project repo; the approval comment (author,
|
||
timestamp, revision tag) is the durable off-box record, authenticated by Gitea's
|
||
own auth. The state store keeps only a pointer ("gate 3 satisfied by comment 8841"),
|
||
so boot-time reconciliation (A3) can re-derive gate state from Gitea even after
|
||
state-volume loss — external truth wins (A2). Approval mechanics and revision
|
||
binding are specified in A4.
|
||
- **Parked: relocating stateful MCP servers off the owning agent's VM.** The trigger
|
||
is **statefulness** (a browser pool, a vector index, a warm cache), not sharing:
|
||
duplicating a stateful server duplicates its state. Relocating has a cost. A shared
|
||
server can't hold per-agent tokens, so per-agent identity has to move into the
|
||
request, through caller auth plus per-caller upstream credentials. That loses the
|
||
attribution property that makes instance-per-agent-VM (F3) clean.
|
||
Instance-per-agent-VM is the starting position. Migrate one server at a time, when
|
||
a specific server justifies it.
|
||
- **Parked: more than one active work item (0.2).** Park-and-switch is the starting
|
||
position. The pressure to relax it will come first from the Sysadmin Agent: an
|
||
alert arriving mid-diagnosis, or a duty's scheduled run coming due while a work
|
||
item is active, has to wait for a safe point. The agent's own switch (0.2) takes
|
||
the edge off: an alert waits for an item that is working, not for an operator
|
||
who is away. Allowing concurrent items would cost:
|
||
- *Serialization in the state store and control loop.* Transitions for different
|
||
items interleave, and the single orchestrator lock becomes per-item.
|
||
- *A scheduler.* Something has to decide which item's turn runs, especially with
|
||
one local inference host serving all of them.
|
||
- *Approval binding.* Two plans or halts open at once make a bare `approve`
|
||
ambiguous everywhere, so per-item rooms or strict reply threading become
|
||
mandatory rather than convenient.
|
||
- *Shared-scope conflicts.* Two active work items on one repo need separate
|
||
working trees. Two on one infrastructure scope could make changes that
|
||
interfere with each other.
|
||
- *Intent ownership.* Boot reconciliation has to sort pending intents by item, and
|
||
refusing to switch no longer keeps them single-item.
|
||
- **Open: when a work item must have an external record (0.2).** Plan-gated dev work
|
||
and ticket-driven ops work already require one. Whether every gated work item
|
||
needs one (for instance, an ops question that turns into a change to make)
|
||
is best settled once C2's step sequences are in use.
|
||
|
||
---
|
||
|
||
## Repository Layout
|
||
|
||
```
|
||
nisse-core/ # shared library (extracted after nisse-dev works)
|
||
transport/ # matrix | telegram | slack (A1)
|
||
state/ # persistence, reconcile, migration (A2/A3)
|
||
control/ # loop, state machine, gating, ask_human (A4)
|
||
llm/ # client interface, role resolution (A5)
|
||
log/ # canonical event log + renderer (A10)
|
||
delegation/ # delegation harness, ask_expert (C1/C3)
|
||
browser/ # Playwright tool (A6)
|
||
secrets/ # Bitwarden machine-account client (A7)
|
||
sso/ # LLDAP identity + auth path (A8)
|
||
logging/ # structured operational logs (A9)
|
||
knowledge/ # Outline client, world model, procedures (A17)
|
||
|
||
nisse-dev/ # app: sealed VM (Part B)
|
||
workflow (arch/milestone gating), Gitea PR layer, allow-all sandbox
|
||
browser → internal test network only
|
||
|
||
nisse-ops/ # app: separate host, Tailscale (Part C)
|
||
policy engine (approved step sequences, C2), scheduled checks
|
||
Vikunja ticket intake, Outline runbooks/records
|
||
MCP → Proxmox/UniFi per site and privilege (F3); runs on its own VM
|
||
browser → admin UIs (API-first, UI fallback), confirm-first per action
|
||
|
||
infra/ # (future) Terraform + cloud-init (Part F)
|
||
nisse-dev.tf | nisse-ops.tf | templates/
|
||
```
|
||
|
||
These are three packages under one repository root (`nisse`), plus `docs/` for this
|
||
specification (Part H, decided). The module boundaries are what matter; the package
|
||
boundary is where the step-10 extraction lands.
|
||
|
||
`log/` carries the canonical event log and its renderer (A10); `delegation/` carries
|
||
the delegation harness and `ask_expert` (A13/A14).
|