Files
nisse/docs/Architecture-Build-Spec.md
T
dongarandClaude Opus 5.5 97863c1939
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m16s
Bind approvals and feedback to the revision they were about
Fixes counterexamples 1-4 from specs/orchestrator.qnt: a repeated
'approve', 'approve rev-1' meant for the architecture, approving on both
channels, and 'approve' plus an afterthought all landed on the next
plan, one the operator had not seen.

- Revisions are numbered across the work item (A4 already required
  monotonic tags; the code numbered per gate), so rev-N names one plan.
- Each reply is stamped on arrival with the revision that was up: in
  Matrix the one whose approval request had been sent (a plain value
  the transport thread reads, restored on boot), in Gitea the latest
  plan comment older than the reply (board.posts).
- Gate.evaluate applies a reply that names no revision only if its stamp
  is the current revision. Otherwise an approval is not applied and
  feedback rejects nothing (it is still in the transcript), and the
  agent says which revision is up. 'approve rev-N' binds by its number.
- Spec A4 states both rules. The model applies them; scenarios 1-4 now
  expect the properties to hold, and both held over 200,000 random
  traces and Apalache's exhaustive check to depth 8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-09-29 19:13:20 +00:00

1508 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 two cases,**
when other work items are ready:
- *The active item is waiting on the operator* (an open gate, a pending question,
or a halt in `AWAIT_INPUT`). An alert must not sit for hours behind an
unanswered prompt. The item it left stays waiting; switching answers nothing
on its behalf.
- *The active item has closed* (completed or abandoned). Ready work should not
sit behind an idle agent until the operator notices.
Either way, it posts a notice naming both items and why it chose that one. The
agent never switches away from an item that is working.
- **A grace period comes before switching away from a waiting item.** After the
active item posts a plan or a question, the agent waits a configurable period
(default 10 minutes) before switching. An operator who is watching answers in
that time and the item carries on without a switch. Otherwise their approval
could sit behind a long milestone on the other item: once parked, an item only
becomes active again when the active one waits or closes. Closing an item needs
no grace period.
- **A parked item is ready when the agent can act on it without the operator:** it
has not started yet (a duty finding, a ticket), or intake arrived while it was
parked that answers what it was waiting for (an approval, an answer, a review
comment). An item still waiting on the operator, with nothing new, is not ready.
- **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`), numbered across
the whole work item rather than per gate, so a tag names exactly one plan. 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.
- **A reply that names no revision is about the one up when it arrived.** A bare
`approve`, or any feedback, is stamped on arrival with the revision last put up
for sign-off: in Matrix, the one whose approval request had been sent; in Gitea,
the latest plan comment older than the reply. If that is not the current
revision the reply is ambiguous: an approval is not applied, feedback is recorded
in the transcript but rejects nothing, and the agent says which revision is up.
So a repeated `approve`, an approval given on both channels, or an afterthought
sent while one plan was up can never land on the next. (Found by the Quint model,
`specs/orchestrator.qnt`.)
- **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, in the sense above, otherwise the
agent asks; reply relations are not implemented yet); 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 the agent's config file, **nothing hardcoded**: base URL, auth token,
and the role mapping (A11). Secrets in it are references to environment
variables, never values. Changing any of it is a config edit + 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` (parked or closed item, or `null`), `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.** Requested with `tool_choice`
(`required`), and only of models whose LiteLLM entry declares
`supports_tool_choice`: enforcement is the model server's, and asking one that
can't honour it can be worse than not asking. Where it isn't available, the
loop enforces the contract itself: an explicit `finish` tool ends the work, a
prose reply is a violation, and a call to a tool not offered counts as a
failure (A12).
- **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
any waiting state (a sign-off gate, AWAIT_INPUT, PR_OPEN)
→ [abandon] close the plan issue and any open PR, → IDLE
```
### B2. Requirements
- **One active project at a time** (park-and-switch, 0.2). The operator selects it,
or the agent switches on its own under 0.2's rules; 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), `abandon`,
plus free text during planning (forwarded to the agent as answers).
- `abandon` ends the current work item from any waiting state: the "abandon"
among A4's continue / re-plan / abandon, and a close in 0.2's terms. It closes
the plan issue and any open milestone PR with a note, keeps the branches, and
marks any unresolved intent aborted so boot reconciliation does not gate on
work dropped on purpose. While work is running, `stop` comes first. Like
`status`, it is a command in every state, never read as an answer or feedback.
- **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.
- **Open: sharing the local inference host between agents.** Each agent keeps
one active work item (0.2), but `nisse-dev` and `nisse-ops` are separate agents,
and both send their local roles through LiteLLM to the one 24GB host. llama-swap
there keeps one model resident at a time. If the two agents' local roles map to
different models, interleaved requests reload weights on every switch (10–60 s
each), and each agent waits on the other's loads. LiteLLM's per-key rate limits
don't address this: they cap one key's throughput rather than share the host,
and know nothing about which model is loaded. (Measured 2026-09-28: they only
throttled the benchmark, and the token limiter leaked until LiteLLM restarted.)
- **Preferred: one local model for every local role.** No swaps at all. It
needs one model good enough as the dev executor and for ops' Home Assistant
tool calls. The executor benchmark and the ops evaluation decide whether one
exists. More agents are planned for other jobs, which makes one shared model
more valuable still, and OpenSCAD and video work would add vision (next
item).
- **Fallback: agents tag in and out.** If no single model fits, the agents
coordinate their use of the host rather than interleave: an agent holds the
host for a stretch of work on its model, then hands it over, so a swap happens
per hand-over rather than per request. Two models co-resident (llama-swap
groups), where VRAM allows, can reduce how often that's needed. How the
hand-over works (a lease, a queue, or priority for ops alerts) is only worth
designing if the preferred option fails.
- **Parked: vision.** OpenSCAD development needs it: the language describes 3D
parts, and the only way to check a change is to render it and look. That work
may belong to `nisse-dev` or, being different enough from ordinary code, to an
agent of its own; undecided. Video-file management, planned for a future
agent, needs it too. Ordinary code work doesn't. The local candidates on
the 4090 host already accept images, and video as sampled frames, but the agent
code is text-only. Building it will need tools that return images, image content
in canonical events (A10) and both renderers, storage that keeps the durable log
(A2) small, and image tokens in the context budget. How well a model sees is
measured when that work starts, not before.
---
## 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).