CI / checks (push) Skipped
CI / checks (pull_request) Successful in 58s
Observed rather than assumed, after checking the running instance: collections carry per-user permissions in three tiers and permissions are collection-scoped, not per-document. So one collection per writing principal, with read membership for everyone else. - States precisely what that enforces: no other agent can write a given agent's world model. It does not enforce operator-versus-agent exclusion, since the operator holds manager on their own wiki; that half is procedural, via the break-glass routine that stops the agents first. Two different strengths, so the spec no longer implies one guarantee covers both - Per-user grants, not groups: Outline groups are not federated with LLDAP, so using them would put membership somewhere nothing syncs. Three principals make the manual mapping trivial - Outline API keys are per-user, so each agent's token must belong to that agent's own Outline user. A token minted from an admin account voids every boundary above while appearing to work — the same shape as the Gitea permission surprise - Collections identified by ID from config, documents by ID recorded in the state store, which also makes writes idempotent across a retry - The conditional-write requirement softens to a revision *read*: break-glass stops the agents, so compare-and-swap is unnecessary and only divergence detection is needed for A3 Also records Outline's MCP server as considered and deferred. A17's enforcement depends on this library sitting between the model and the store; MCP's purpose is direct model access, and a procedure the model may decline to fetch does not constrain it. Still a candidate for the discretionary search tool, with two things to verify first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1028 lines
56 KiB
Markdown
1028 lines
56 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).
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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: active project/task, per-project state-machine position, approvals
|
||
granted, pending questions, session continuity, (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. 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.
|
||
7. 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.
|
||
- **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`.
|
||
- 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.
|
||
- A single **bootstrap token** (the machine-account credential) is the one secret
|
||
that lives on the box: tight file perms, never in image or repo.
|
||
- (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` — correlation ID for the enclosing unit of work, minted when the
|
||
unit starts (`use <project>`, milestone start, ticket pickup) and threaded
|
||
through every event it causes; `null` only outside any unit (e.g. boot).
|
||
- `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` |
|
||
| `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:
|
||
- **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 confirm a specific fact
|
||
against the live system *before acting on it*. Hard requirement for anything in
|
||
C2's confirm-first tier; advisory for read-only and reversible actions.
|
||
|
||
**Enforcement is structural, because a prompt is not enforcement.** "Treat this as
|
||
a cache" is exactly the advisory guarantee A4 and A12 reject, and the model cannot
|
||
be prevented from *believing* a fact it has read. The enforceable goal is
|
||
narrower: **an unverified belief must not become an irreversible action.**
|
||
1. **The store decides what it serves.** Facts carry provenance and an observed-at
|
||
timestamp; retrieval marks or withholds anything past a staleness bound for
|
||
high-consequence classes.
|
||
2. **Verification as a precondition, checked against the log.** For confirm-first
|
||
actions, refuse unless a verifying observation exists *in this task's canonical
|
||
log*, recorded after the task began, naming the action's target. Inspectable
|
||
without asking the model anything — the same move as A12's observable triggers.
|
||
Approximate by nature, so build it for the confirm-first tier only.
|
||
3. **Provenance at the human gate** — the primary reliance, because C2 already
|
||
stops these actions for confirmation. The prompt states evidence quality:
|
||
"target recalled from the world model, observed 2026-08-02, not verified this
|
||
session" versus "confirmed by `zfs list` two minutes ago". The operator is
|
||
already the authority there; this makes the risk legible rather than invisible.
|
||
4. **Audit, not prevention, for the cheap tiers.** Log which facts were retrieved
|
||
and which actions followed, so a wrong fact is traceable afterwards.
|
||
|
||
Residual risk, stated plainly: a model can retype a fact from context with no
|
||
retrieval event, and no mechanism here catches that. That is why the world model
|
||
stays out of the authority path entirely.
|
||
|
||
**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.
|
||
**A3 consequence:** a task resumed across a restart carries the old snapshot, so
|
||
boot reconciliation re-fetches snapshotted knowledge and flags divergence rather
|
||
than silently continuing on a stale copy.
|
||
|
||
**Retrieval is structural first, discretionary second** — the A12/A14 pattern.
|
||
- Procedures are **bound to C2 action classes**: the policy taxonomy and the
|
||
procedure index are the same list. Binding is deterministic and auditable
|
||
("which procedure governed this action?" has an exact answer), and costs nothing
|
||
extra because the taxonomy 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 procedure bound to a confirm-first action gates.**
|
||
Proceeding is not a delayed reaction; it is acting unconstrained on precisely the
|
||
operation someone wrote a procedure for.
|
||
- Read failure for read-only or reversible actions 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 and the C2 binding list arrive with the policy taxonomy (Part H).
|
||
|
||
**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
|
||
enforcement depends on this library sitting *between* the model and the store: staleness
|
||
marking, snapshotting into the canonical log, binding procedures to action classes, and
|
||
gating a failed confirm-first 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.
|
||
|
||
|
||
---
|
||
|
||
## PART B — THE DEV AGENT / `nisse-dev` (build first)
|
||
|
||
A thin app over `nisse-core`. Plans and implements software projects; produces PRs.
|
||
|
||
### 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.** Operator selects it.
|
||
- **Operator commands** (over transport): `use <project>`, `approve`,
|
||
`reject <feedback>`, `start milestone <n>`, `status`, 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), not a bare container — the agent 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:** egress allow-list (LiteLLM, Gitea, package registries), **no
|
||
ingress**. Egress denial must include **RFC1918 except the specific LiteLLM/Gitea
|
||
IPs** — 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 | tiered **allow / deny / confirm** policy |
|
||
| 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)*
|
||
|
||
Tiered policy, evaluated on every action:
|
||
- **Read-only telemetry** (metrics, logs, status) — free.
|
||
- **Reversible service actions** (restart a container) — allowed.
|
||
- **High-consequence actions** (storage, networking, auth) — **confirm-first** over
|
||
transport before executing.
|
||
|
||
This is the sysadmin analog of the dev agent's plan gate: the human boundary sits at
|
||
irreversible / high-blast-radius actions.
|
||
|
||
Special cases fold into the tiers:
|
||
- **Browser actions against admin UIs default to confirm-first.** 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) route through confirm-first, never drive unattended.
|
||
|
||
### C3. Interfaces
|
||
|
||
- **Browser (A6):** into admin UIs. **Prefer real APIs where they exist** (Proxmox,
|
||
TrueNAS good; UniFi partial); use the browser only for genuinely UI-only gaps —
|
||
it's the least stable interface.
|
||
- **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). **Matrix =
|
||
live conversation about it** (questions, confirm-first prompts, progress).
|
||
- **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 **confirm-first** prompts (C2) 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).
|
||
- Has **Tailscale** + scoped credentials to reach infrastructure across both sites.
|
||
- 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 (both agents)
|
||
|
||
- **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.
|
||
- **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 taxonomy 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 browser access to network gear:** standing vs. per-task hand-off. The
|
||
lock-yourself-out risk argues for per-task.
|
||
- **Policy action taxonomy (C2):** the concrete list of which operations are
|
||
allow vs. confirm. Best decided with the agent half-built in front of you.
|
||
- **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.
|
||
|
||
---
|
||
|
||
## 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, tiered confirmation, scheduled checks
|
||
Vikunja ticket intake, Outline runbooks/records
|
||
browser → admin UIs (API-first, UI fallback), confirm-first gating
|
||
|
||
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).
|