[vibe](../../README.md) > [PRD](../README.md) > [AI back-office](README.md) > **Agent architecture** # Agent architecture — atoms, contracts, gates > **Status:** In design > **Last Updated:** 2026-07-11 > **Up:** [AI back-office hub](README.md) > **Related:** [Task inventory](task-inventory.md) · [Model fleet](model-fleet.md) · [Challenges](challenges.md) · [ADR 0003 — sandbox state lifecycle](../../ADR/0003-sandbox-state-lifecycle.md) ## Design principles 1. **Atoms, not monoliths.** Each capability (classify, extract, validate, record, reconcile, report, remind) is one narrow agent with a strict I/O contract. Workflows are compositions of atoms with explicit gates — never one prompt that "does the accounting". 2. **The LLM proposes, code disposes.** Formats, arithmetic, checksums, dedup, and referential integrity are enforced by deterministic validators. A model output that fails validation is quarantined, never auto-corrected. 3. **Data is never instructions.** Inbound content (mails, PDFs, bank labels) flows through typed fields; extraction atoms hold zero credentials and zero action tools. 4. **Writes are rehearsed, gated, and replayable.** The only path to prod mutation is manifest → sandbox rehearsal → human approval → gated promote ([ADR 0003](../../ADR/0003-sandbox-state-lifecycle.md)). 5. **Silence is an alert.** Every standing loop heartbeats; a quiet fleet must be provably quiet, not possibly dead. 6. **Earn autonomy.** Levels ([A0–A3](README.md#the-autonomy-ladder)) are granted per-atom from measured evals and revoked on incident ([QA strategy](qa-strategy.md)). ## Atom contract Every atom is registered in a versioned YAML registry (git) with: | Field | Meaning | | --- | --- | | `name`, `version` | Identity; version bumps on any behavioral change (re-triggers evals). | | `input_schema` / `output_schema` | JSON Schema; enforced at runtime (constrained decoding where the tier supports it). | | `invariants` | Deterministic post-conditions (e.g. `HT + TVA == TTC ± 0.01`). | | `side_effect_class` | `read` · `draft` · `write-sandbox` · `write-prod` · `outbound` — drives which gates apply. | | `idempotency_key` | How a replay is recognized (e.g. supplier + `ref_supplier` + TTC). | | `autonomy` | Current earned level (A0–A3) + link to the eval evidence. | | `model_policy` | Preferred tier, fallbacks, escalation rule ([model fleet](model-fleet.md)). | | `eval_ref` | Golden set + scoring script for this atom. | The registry is the source of truth for what the fleet may do; an atom absent from the registry does not run. File layout, prompt syntax, and the full agent-facing document surface (`AGENTS.md`, `SKILL.md`, `atom.yaml`, `prompt.md`, profile files) are specified in the [agent catalog](agent-catalog.md#the-document-surface-agents-read). ## The pipeline shape Every workflow instantiates the same stage skeleton (skipping stages it doesn't need): **watch → classify → extract → validate → stage → approve → apply → verify → journal** The flagship instance — supplier invoice end-to-end ([T01](task-inventory.md#t01--mailbox-triage--routing)→[T03](task-inventory.md#t03--supplier-invoice-recording), POC-1): ```mermaid %%{init: {'theme':'base'}}%% flowchart TB mail["Zoho books@
new message"]:::src triage["T01 classify
(Pi tier, constrained)"]:::proc extract1["T02 extract A
(M4 local)"]:::proc extract2["T02 extract B
(Mistral EU)"]:::proc agree{"critical fields
agree?"}:::gate escal["escalate
(Claude tier)"]:::proc valid["deterministic validators
arithmetic · rates · SIREN · IBAN · dedupe"]:::gate quarantine["quarantine queue
(review in digest)"]:::store manifest["T03 manifest + sandbox rehearsal
predicted-delta check"]:::proc card["Telegram approval card"]:::gate promote["gated promote to prod
(human key + confirm)"]:::gate ged["attach PDF (GED)
re-read + snapshot delta"]:::proc journal["run journal
+ golden-set feedback"]:::store mail --> triage --> extract1 triage --> extract2 extract1 --> agree extract2 --> agree agree -- "no" --> escal --> valid agree -- "yes" --> valid valid -- "fail" --> quarantine valid -- "pass" --> manifest --> card --> promote --> ged --> journal quarantine --> journal classDef src fill:#2563eb,stroke:#1e40af,color:#fff classDef proc fill:#059669,stroke:#047857,color:#fff classDef store fill:#7c3aed,stroke:#6d28d9,color:#fff classDef gate fill:#b45309,stroke:#92400e,color:#fff ``` 1. A new message on `books@` is classified by the **T01 sentinel** (Pi tier, schema-constrained output). 2. The PDF is extracted **twice independently** — locally on the M4 and on the Mistral EU cloud. 3. Critical fields (amounts, IBAN, ref, dates) must **agree exactly**; disagreement escalates to the Claude tier; still-ambiguous items stop here. 4. **Deterministic validators** check arithmetic, VAT rates, SIREN/IBAN checksums, and duplicates; any failure lands in the **quarantine queue**, surfaced in the digest. 5. A **write manifest** is rehearsed on the sandbox and its result re-read and compared to the draft (predicted-delta check). 6. The human gets a **Telegram approval card**; approval triggers the **gated promote** to prod (human-held key + explicit confirm). 7. The source PDF is **attached in the GED** (Dolibarr's document store), the write is verified by re-read + snapshot delta, and the full run is **journaled** — rejections and corrections feed the golden set. ## Write safety (inherited, not reinvented) [ADR 0003](../../ADR/0003-sandbox-state-lifecycle.md) already delivers the hard part, proven live on the erp repo: - **Sandbox host-guard**: the write skill structurally refuses any host that is not `erp-sandbox` — a sandbox atom *cannot* mutate prod. - **Manifests with portable refs**: `@ref` (created earlier in the run) and `#entity:field=value` business-key lookups (aborts on 0 or >1 match — never guesses ids). - **Gated promote**: `promote-plan` (human-readable review) → `promote-apply --target prod` requiring the prod write key from ENV only (never stored) + an explicit confirm variable. - **Iso-prod checkpoints**: the sandbox is re-seedable from prod at will, so rehearsals run against *today's* real state. This PRD adds around it: idempotency keys on every write atom, predicted-delta assertions (rehearse → re-read → compare *before* asking for approval), pre/post snapshots ([T13](task-inventory.md#t13--erp-snapshot--drift-detection)), a **compliance linter** in `promote-plan` (a manifest with any operation outside the [ledger grammar](compliance.md#the-ledger-grammar-production) never reaches the approval card), and approval cards as the human interface to the gate. ### Anti-hallucination contract for agent writes No value reaches the books because a model "remembers" it. Four mechanical layers, all deterministic: 1. **No free-text critical fields.** Amounts, dates, refs, IBANs and transaction ids are *copied by code* from the validated extraction payload or the bank feed into the manifest — the orchestrating model routes and assembles; it never re-types a value it read. 2. **Provenance per critical field.** Write manifests carry a source anchor per critical field — `{source_sha256, raw_excerpt}` — and a deterministic checker re-extracts the source text (pdftotext / feed pull) and asserts the excerpt exists and parses to the same value (locale-normalized: `219,50` ≡ `219.50`, `2,147` ≡ `2147.00`). A value not literally present in its source cannot be promoted. 3. **Cross-system corroboration.** Every payment amount must equal its bank-feed movement to the cent, against a **fresh** pull at check time (never a cached copy); arithmetic (`HT + TVA = TTC ± 0.01`), checksums (SIREN, IBAN mod-97) and dedupe keys apply regardless of source. 4. **Read-back closes the loop.** Predicted-delta on the sandbox and post-write verification on prod prove that what was *written* equals what was *checked* — source → manifest → ERP, corroborated at every hop. A failed check refuses; it never repairs. Proven in practice: the 2026-07 books-regularization pack shipped with a standalone `verify-provenance` checker (36 field-level checks against the source PDFs and fresh Qonto/Wise pulls, run before the human gate) — [POC-1](poc-plan.md#poc-1--supplier-invoice-end-to-end) industrializes it as a linter stage alongside the ledger grammar. ## Environments — sandbox vs production The environment split is not an implementation detail — it is both the **safety** device (ADR-0003) and the **compliance** device ([compliance](compliance.md)): the sandbox may host any experiment because its state is disposable; production is held to append-only ledger discipline because it *is* the books. | | **Production** (`erp.arcodange.lab`) | **Sandbox** (`erp-sandbox.arcodange.lab`) | | --- | --- | --- | | Role | the ledger — book of record | rehearsal, POCs, evals, drills | | State | permanent, append-shaped only | disposable; re-seeded **iso-prod** on demand (`arcodange sandbox checkpoint refresh`) | | Credentials | read-only `ai_agent`; prod write key human-held, ENV-only at promote time | write-scoped `ai_agent_sandbox`, host-guarded (structurally cannot reach prod) | | Ledger grammar | **enforced** (linter + locking + snapshot detection) | exempt — but manifests destined for prod are linted *before* rehearsal | | Third parties | real (Qonto/PA, Zoho, Telegram) | **never wired to production externals**: no PA emission, no outbound mail — side channels are stubbed or blackholed | Every atom's `side_effect_class` maps to an environment posture: | `side_effect_class` | Runs against | Credential | | --- | --- | --- | | `read` | prod (and sandbox for evals) | read-only `ai_agent` | | `draft` | no ERP at all | none | | `write-sandbox` | sandbox only | `ai_agent_sandbox` (host-guarded) | | `write-prod` | prod, **only** through the promote gate | human-held key + explicit confirm | | `outbound` | production channels | allowlisted recipients, human-gated | Standing rules: **every POC's write legs run on the sandbox** and enter prod only through the gate with a real approval; ERP-dependent **eval runs target a fresh checkpoint** (iso-prod refresh = a reproducible fixture); restore drills and game-days land on the sandbox by construction ([T14](task-inventory.md#t14--backup--restore-verification), [QA strategy](qa-strategy.md#ops-qa)); anything designed to be irreversible in prod (e.g. Dolibarr's BlockedLog module) is trialed on a checkpoint first, because the sandbox provides exactly the reversibility production denies. ## Security model - **Least privilege per atom.** Extraction and classification atoms hold no credentials at all. Read atoms use the read-only `ai_agent` key. Sandbox writes use the sandbox-only agent. The prod write key exists only in the human's hands at promote time. - **Ephemeral scoped ERP workers.** For orchestrated batches, the orchestrator mints short-lived Dolibarr users scoped to the subtask (`supplier-ingest`, `bank-reconciler`, `readonly` — the `PERMISSION_SCOPES` pattern prototyped in erp `test/orchestratorExample.ts` + `test/scripts/admin/permissions.ts`), and deletes them when the batch ends. A leaked worker key is narrow and already dead. - **Secrets discipline.** All standing credentials live in Vault (house pattern, VSO-injected); skill `.env` files are mode-600 and gitignored; agents never echo credentials into journals or prompts. - **Blast-radius honesty.** Bank access is read-only by construction (no payment-initiation scopes are ever requested). The mailbox OAuth is read-only. The single irreversible surface is prod ERP writes — hence the gate. ## Prompt-injection defenses Inbound documents are adversarial by default — an invoice PDF or a mail body can contain text addressed to an LLM. Defense in depth: 1. **No-tool extraction**: atoms that read untrusted content can only emit schema-constrained JSON — there is nothing to hijack. 2. **Typed handoffs**: downstream atoms receive extracted *fields*, never raw document text; the raw source travels as an opaque attachment (hash-addressed) for human eyes. 3. **Instruction-shaped content is a finding**: validators flag imperative/LLM-addressed text in extracted fields; such items are quarantined and surfaced verbatim to the human. 4. **Action allowlists**: outbound mail only to allowlisted recipients; calendar mutations sourced from mail content require human confirmation ([T11](task-inventory.md#t11--compliance-calendar--reminders)). 5. **Injection fixtures in evals**: every extraction atom's golden set includes adversarial documents; a regression here blocks autonomy promotion ([QA strategy](qa-strategy.md)). ## Runtimes & scheduling | Runtime | Runs | Scheduling | Notes | | --- | --- | --- | --- | | **k3s cluster (Pis)** | T01 sentinel inference, T11 reminders, T13/T14 verifications, queue + gateway | CronJobs + long-running Deployments (ArgoCD apps per the lab's `` join-key convention) | Proven pattern: the erp backup CronJob. No LLM heavier than the Pi tier. | | **M4 MacBook** | T02/T16 local extraction, T09 report, T17 vault capture/retrieval, interactive Claude Code sessions (the atom factory) | **hermes cron ticker** (already driving the vault jobs) + on-wake queue drain | **Not a server**: availability model in [model fleet](model-fleet.md); time-critical work must not depend on it. hermes = the local agent runtime (skills, cron, the Ornith model). | | **Cloud APIs** | Mistral extraction/OCR; Claude reasoning steps (headless `claude -p` / Agent SDK) | invoked by pipeline stages | Budget-capped; degraded modes defined. | | **telegram-gateway** | digests, approval cards, human commands | webhook-driven | Roadmapped phases (durable Postgres queue, async handlers) are exactly what the fleet needs — see open decisions. | **Work queue.** Pipeline stages communicate through a durable queue with dead-letter semantics (an item that fails N times parks in the DLQ and appears in the digest). Start minimal; the queue technology is an open decision below. **Graduation path.** New atoms are prototyped as Claude Code skills (fast iteration, human in the loop), then frozen into deterministic scripts + tests once stable — the house already does this (`.claude/skills/` scripts wrapped by `bin/arcodange`). Claude-tier involvement in a mature atom shrinks to escalation handling. ## Human channel The channel is **hermes's telegram-gateway whenever it is available** (operator direction, 2026-07): the gateway runs on the cluster, so digests and approval cards are served 24/7 without depending on the laptop being awake — the M4-side hermes runtime consumes the same gateway for its own jobs. When the gateway is down, the fleet keeps queueing, the digest falls back to plain email, and the [degraded-modes table](model-fleet.md#degraded-modes) applies. - **One daily digest** (Telegram, morning): items awaiting approval, quarantined items, aging unresolved work, heartbeat summary, upcoming deadlines (D-30/D-7/D-1). An empty day still sends "all green" — silence must be distinguishable from failure. - **Approval cards**: one decision per card (approve / edit / reject-with-reason); rejection reasons are first-class data feeding golden sets. - **Escape hatch**: every automated lane has a documented manual runbook fallback (the fleet augments the operator; it never becomes the only way to run the company). ## Knowledge layer — the second brain The operator's second brain is already in place and already agent-integrated: a **PARA Obsidian vault** (`00-Inbox` … `06-Zettel`), git-synced to the forge ([arcodange/SecondBrain](https://gitea.arcodange.lab/arcodange/SecondBrain)) via obsidian-git, exposed to agents through `mcp-obsidian` (local REST API), and automated by `.automation/sb.py` (weekly digest, inbox triage, daily prefill, idempotent Gitea→Inbox ingest) scheduled on the **hermes cron ticker** — with **Ornith**, hermes's local reasoning model (`127.0.0.1:18080`), as the confidential/offline lane. The vault even declares its own AI routing doctrine — *Claude by default, Mistral for well-defined tasks, Ornith/hermes for the confidential* — which is precisely the policy the [model fleet](model-fleet.md) generalizes. The integration contract ([T17](task-inventory.md#t17--knowledge-capture--retrieval-second-brain)): - **Division of truth:** the ERP is the *book of record*; the vault is *context and institutional memory* (contract nuances, client history, decisions, REX). No accounting fact is authoritative in the vault. - **Capture:** fleet outputs worth remembering land as **append-only inbox/area notes with idempotent frontmatter** — the pattern the Gitea ingest already proves; human-authored notes are never edited in place. - **Retrieval:** context-hungry atoms query the vault and carry facts *with their note dates* — notes are **trusted-but-stale**: anything contradicting the ERP, or older than its subject's last change, triggers re-verification rather than belief. - **Rails reused, not rebuilt:** M4-side access is direct filesystem + `mcp-obsidian`; the weekly digest and the human's PARA filing ritual remain the curation loop; cluster-side access is open decision [D7](#open-decisions). - **Client dossiers — the junction of the agent families.** `01-Projects/Clients//` is where this fleet meets the sales chain (prospection → proposal → contract) and the future **delivery agents** working on client projects (the KM architecture notes already live there). Deposits are **agent-owned files** (e.g. a regenerated billing-snapshot note with `ai_generated` frontmatter), never edits of human notes; each family both feeds and reads the dossier — the back-office deposits billing state and contract facts and retrieves dunning tone; delivery agents deposit decisions, meeting notes and **new-business sightings** (contract clauses like the KM 4 % settlement make this a billing input) and retrieve contract scope and billing state. Client-project content is confidential by default — the vault's own routing doctrine applies (Ornith/local first). ## Open decisions To be settled by POC evidence, each closing with a short ADR: | # | Decision | Options (leaning) | | --- | --- | --- | | D1 | Work queue | telegram-gateway's planned Postgres durable queue (**leaning, operator-endorsed 2026-07** — already roadmapped, transactional, one less system) vs. flat files in git vs. Redis | | D2 | Orchestration runtime | Claude Agent SDK headless for cluster-triggered jobs + **hermes** for M4-side lanes (**leaning** — hermes already runs skills + cron there) vs. bespoke TS orchestrator (erp `test/` Deno codebase) vs. pure CronJobs + scripts | | D3 | KM monthly invoice firing | enable Dolibarr template auto-fire (`frequency>0`) vs. agent-fired via sandbox+promote (**leaning** — keeps the gate + mention audit in-line) | | D4 | PA — e-invoicing platform (*plateforme agréée*, ex-PDP) | **Leaning: Qonto** (operator direction, 2026-07 — the capital-deposit bank, DGFiP-registered PA, e-invoicing included in every plan, and the fleet's richest existing API integration); POC-6 validates reception + API pull before the ADR — **must close before 2026-09-01** ([C12](challenges.md#c12--e-invoicing-reform-unknowns)) | | D5 | OCR provider for scanned docs | Mistral OCR (EU cloud) vs. local vision model on M4 vs. Tesseract baseline | | D6 | Pi inference serving | llama.cpp server vs. Ollama on arm64, resource limits, node pinning ([C5](challenges.md#c5--slm-capability-ceiling-on-pi-hardware)) | | D7 | Cluster↔vault access | git clone/pull of the SecondBrain remote (**leaning** — the Gitea remote exists, offline-friendly, reviewable) vs. tunneled Obsidian REST API (M4-only today) vs. keeping vault access M4-exclusive | | D8 | Fleet code home | **Settled 2026-07-15: erp repo `fleet/`** next to the skills — landed with [erp#62](https://gitea.arcodange.lab/arcodange-org/erp/pulls/62) (registry, 7 class skeletons, worked example, AGENTS.md Fleet section; locate-test passed by an independent cold reader). The atoms are ERP-domain today — revisit into a dedicated repo when a second domain joins | | D9 | Meeting capture tool (client-project notes lane) — **parked 2026-07-12, nice-to-have** (first client calls happen on the iPhone: a phone call precedes the Meet, which desktop capture doesn't cover; revisit when desktop meetings become routine) | **Leaning: Hyprnote free tier** (rebranded "Char" — local capture + transcription with **speaker diarization and Google Calendar sync both on the free plan**; manual trigger only, auto-record stays off by consent stance) vs. **Meetily** as OSS fallback (MIT, diarization in the community core, no calendar sync — `sb.py` can compensate by matching recording timestamps to the calendar ICS) vs. bare Whisper-class + Ornith | D4–D6 close with their mapped POCs ([POC-6](poc-plan.md#poc-6--e-invoicing-readiness-spike), [POC-5](poc-plan.md#poc-5--model-routing-bench), [POC-2](poc-plan.md#poc-2--pi-sentinel)); D1–D2 are settled while building phase 3's standing fleet (the queue and scheduler *are* its skeleton); D3 lands with phase 4's money loops; D7 closes when the first cluster-side atom needs vault context (phase 3 at the earliest); D8 settled with the scaffold landing (erp#62, 2026-07-15); D9 is **parked** (nice-to-have; calls are iPhone-first today) — erp#49 carries the wake-up steps (gate: diarization quality on a real bilingual call).