Architecture

July 8, 2026 · View on GitHub

The mental model that makes the rest of Corvin make sense.

The five orthogonal axes of Corvin

The mental model

Corvin is not a chatbot. It's not a single-purpose agent either. It's a runtime layer that turns an LLM-CLI engine into something closer to a per-user mini-OS — with persistent state, runtime-extensible capabilities, isolation boundaries, and a tamper-evident audit log running through everything.

The core architectural decision is this: instead of forcing every feature onto a single dimension (e.g. "what does the agent do?"), Corvin factors the runtime into five orthogonal axes. Any combination of values across the five is a valid configuration. You never have to ask "but what about agent X with feature Y in deployment Z?" — every cell of the cross-product is reachable.

Five orthogonal axes of Corvin: surface × engine × persona × scope × tenant, each with example values, all connected by the unified audit log

Underneath all five runs one thing: the unified hash-chained audit log. That's the backbone — every state transition on any axis lands there as a verifiable record.

The runtime is implemented across 44+ layers (L1–L44), each independently enable-able and auditable. L44 is the house-rules gate (EU AI Act Art. 5 + Art. 50 enforcement): acceptable-use policy with a SHA-256-pinned rule file, fail-closed on any bypass attempt.

Why five axes (and not fewer)

A naïve "agent framework" merges these axes silently. That works until the first time you need to:

  • run two engines concurrently for different chats (e.g. Claude Code for most chats, OpenCode + Ollama for a privacy-first chat) → engine must be separable from persona
  • give a Discord chat a stricter sandbox than a Slack chat with the same persona → surface must be separable from persona
  • pin a forged tool to "always available, every chat, every persona" while keeping a different forged tool task-local → scope must be separable from persona
  • offer the same software to two companies on shared infrastructure without their audit chains touching → tenant must be separable from everything

Corvin treats each axis as a first-class dimension. The cost is one extra word in every API call (tenant_id, chat_key, persona, …); the win is that no axis can silently override another.

The axes in detail

1. Tenant — the isolation boundary

Single-operator deployments live under the implicit _default tenant and notice nothing. Multi-tenant deployments provision additional tenants under <corvin_home>/tenants/<tid>/, each with:

  • its own hash-chained audit log (cross-tenant fusion is structurally impossible)
  • its own secret vault, OIDC trust store, SCIM users
  • its own forge / skill-forge / personal-tools workspace
  • its own tenant.corvin.yaml declaring engine allowlist, data residency zone, and budget caps

Any code path that accepts a tenant ID validates it against validate_tenant_id (DNS-label-shape, path-traversal-safe). The boundary is enforced at every state-store entry point, not at a single chokepoint — defense-in-depth.

2. Scope — the persistence axis

Every artifact the agent generates at runtime (forged tool, created skill, audit event, conversation state) is tagged with a scope:

ScopeLifetimePurge rule
taskone runTTL-purged daily
sessionchat lifetimewiped by /reset or 7-day timeout
projectrepo-pinned (<repo>/.corvin/)survives session reset
userevery chat, every projectnever auto-purged

The promotion ladder lifts artifacts toward longer survival when they prove useful (skill-forge: grade-gated; personal-tools: explicit /tool save; forge tools: operator force). The auto-purge enforces the inverse: an artifact that never earns relevance falls out.

This is the answer to "where does my agent's runtime work go?" — a generated tool isn't ephemeral or permanent by default; it has a measured lifetime that you can argue about.

3. Persona — the agent's role

A persona is a JSON declaration of what the agent is supposed to be for a given chat. It owns:

  • which tools / MCP servers are wired in
  • the append_system voice + scope of work
  • the sandbox policy (e.g. network: allow for research)
  • per-persona LDD discipline preset
  • routing anchors (used by the router to auto-pick the persona)

Bundle personas (coder, forge, research, inbox, orchestrator, assistant, os, homeassistant) ship with the system. User personas live under <corvin_home>/cowork/personas/ and override per name. The adapter resolves a persona per call from one of three sources:

Persona resolution priority chain: explicit pin (highest) → chat_profile override → auto-router (lowest)

4. Engine — the LLM execution layer

Layer 22 introduced the WorkerEngine Protocol:

class WorkerEngine(Protocol):
    def spawn(self, prompt: str, *, env: dict) -> Iterator[StreamEvent]: ...
    def cancel(self) -> None: ...
    capabilities: dict[str, bool]   # mid_stream_inject, hooks, skills_tool, ...

Five implementations ship today: ClaudeCodeEngine (default), CodexCliEngine, OpenCodeEngine, HermesEngine (local Ollama, zero egress, L34 CONFIDENTIAL-capable), and CopilotCliEngine (GitHub Copilot CLI, worker-only). The adapter dispatches per call based on profile.default_engine.

EAOS — engine-agnostic guarantees: Every engine now receives L10 path-gate, L16 audit, and L33 artifact registration via the Tool Execution Broker (TEB) in the Forge MCP server. Engine Command Interface (ECI) adds EngineCommandManifest/btw routes to live inject (CC) or, on engines without live mid-stream inject (Hermes / Codex / OpenCode), queues into the /btw buffer and is drained into the next spawn. An engine-agnostic active-turn marker lets /btw tell a running task from a truly idle chat regardless of which engine serves the turn. MCP tool-calling reaches Hermes via the Function-Call Bridge (FCB).

Crucially, this means you can run the same chat against different backends per persona — a coder chat hits Claude Code while an engine-pinned chat hits OpenCode + Ollama, side by side, in the same bridge process.

5. Surface — where the user lives

Each channel ships its own daemon (whatsapp/, telegram/, discord/, slack/, email/) plus a Voice TTS pipeline and the multi-tenant REST/SSE/gRPC gateway. The surface layer is purely about reaching the user — the daemon writes a unified-shape envelope into the inbox, and the adapter reads from that single inbox.

Adding a sixth channel is a one-file change: write a daemon that reads the channel's API, writes inbox-shaped JSON envelopes, and reads outbox-shaped JSON envelopes back. The adapter doesn't need to know.

The backbone: unified audit hash chain

Every state transition on any axis lands in the same hash-chained log at <corvin_home>/global/forge/audit.jsonl. Per-tenant deployments get one chain per tenant.

event[N-1] {hash: 9a3c…}   ←   event[N] {prev_hash: 9a3c…, hash: 7b1f…}   ←   event[N+1] …

Tampering with any field of any record breaks the chain at that point; voice-audit verify reports the offset. The chain is offline- verifiable — you don't need Corvin running to audit it.

This is the substrate that makes the four structural compliance mechanisms work: bot-disclosure, per-user consent, compliance-zone routing, and engine-policy enforcement all emit into this chain. The chain is the integration surface for compliance — see docs/audit-and-compliance.md.

The on-disk shape

A single-operator deployment looks like this:

~/.corvin/                                  # CORVIN_HOME
├── tenants/
│   └── _default/                            # implicit tenant
│       ├── global/                          # user-scope
│       │   ├── forge/
│       │   │   ├── audit.jsonl              # ← THE backbone
│       │   │   ├── registry.json            # tools manifest
│       │   │   └── tools/me.*.py            # personal tools (Layer 27)
│       │   ├── skill-forge/skills/
│       │   ├── user_style/                  # auto-learned bullets (Layer 26)
│       │   ├── consent/ disclosure/ roles/ quota/
│       │   ├── tenant.corvin.yaml          # per-tenant policy
│       │   └── auth/ (gateway tokens, OIDC trust, SCIM users)
│       └── sessions/<bridge>:<chat>/        # per-chat state
├── global → tenants/_default/global         # symlinks (back-compat)
├── sessions → tenants/_default/sessions
└── …

The legacy paths (global/, sessions/, …) at the top are symlinks into the _default tenant tree. They exist so single-operator code paths keep working byte-identically. Phase 7 of the rebrand removes them.

How a single message flows through all five axes

1. User types in Discord                 →  SURFACE: discord daemon
2. Daemon writes inbox envelope          →  SURFACE → ADAPTER boundary
3. Adapter resolves persona for chat     →  PERSONA axis
4. Adapter picks engine via profile      →  ENGINE axis
5. Adapter resolves tenant from env      →  TENANT axis
6. Adapter loads scoped artifacts        →  SCOPE axis
   (skill_inject, user_style, me.*)
7. Adapter spawns engine.spawn()         →  ENGINE executes
8. Every step writes to audit.jsonl      →  BACKBONE
9. Engine emits StreamEvents             →  ENGINE → ADAPTER
10. Adapter writes outbox envelope       →  ADAPTER → SURFACE
11. Daemon delivers to Discord           →  SURFACE

Each step is a single function call with explicit axis parameters. There is no global state that secretly couples two axes — the explicit-parameter discipline is what keeps the cross-product reasoning honest.

Where to look in the code

AxisResolverState store(s)
Tenantforge/tenants.py::current_tenant()<corvin_home>/tenants/<tid>/
Scopeforge/scope.py::detect_scope()per-scope forge/, skill-forge/ dirs
Personacowork/lib/resolver.py::resolve()cowork/personas/*.json (bundle) + user overrides
Enginebridges/shared/agents/__init__.py (Protocol)bridges/shared/agents/{claude_code,codex_cli,opencode_cli}.py
Surfacebridges/<channel>/daemon.jsbridges/<channel>/settings.json

For the dispatch glue see operator/bridges/shared/adapter.py (process_one, _resolve_spawn_inputs, call_claude_streaming).

Adjacent docs

  • Runtime generation — how the agent extends itself with new tools and skills mid-conversation
  • Memory model — the user's persistent shell loadout: knowledge + auto-learned style + personal tools
  • Data and compute — handling large data and long-running optimization without burning the LLM context
  • Audit and compliance — the hash-chain substrate and the EU AI Act / GDPR design constraints
  • Engine layer — backend-agnostic LLM execution, local-first via Ollama