Filesystem Architecture
Legio separates immutable blueprints from mutable runtime state. All file operations go through validated paths with name regex + symlink rejection.
Directory Layout
legio/ # Python package (code)
centurio.py # Agent identity and validation
nuntius.py # Immutable message type
legatus.py # Orchestrator agent
session.py # SDK session lifecycle
praetorium.py # SQLite message bus
config.py # Configuration loading
errors.py # Exception hierarchy (7 classes)
rendering.py # XML/template rendering
auctoritas.py # TOTP authorization store
totp.py # TOTP verification
telegram/ # Telegram infrastructure
bot.py # Bot handlers and filters
commands.py # Slash command handlers
utils.py # Shared helpers
markdown_render.py # Markdown → Telegram HTML
memoria/ # Memory layer
store.py # Filesystem CRUD operations
tools.py # MCP tool wrappers
blueprints/ # Default templates (committed)
centurio/
prompt.md.template # {{name}} and {{specialization}}
tools.json.template
legatus/
prompt.md.template
castra/ # Runtime workspace (partially tracked)
legatus/
prompt.md # Legatus prompt (customizable)
centuriones/
vorenus/
prompt.md # Centurio-specific prompt
tools.json # Centurio tool config
commentarii/ # Private notes (gitignored)
edicta/ # Standing orders (tracked)
acta/ # Shared knowledge (tracked)
praetorium.db # Message history (gitignored)Tracking Policy
The castra subtree is partially tracked in git:
| Content | Tracked? | Reason |
|---|---|---|
legatus/prompt.md | Yes | Legatus prompt is the core personality — version-controlled |
Centurio prompt.md | Yes | Prompts are code — version-controlled |
Centurio tools.json | Yes | Tool config defines capabilities |
Edicta (.xml files) | Yes | Standing orders define system behavior |
Acta (.xml files) | Yes | Shared knowledge is team state |
| Commentarii | No | Ephemeral per-session scratch |
praetorium.db | No | Binary runtime data |
praetorium.db-wal | No | WAL journal (runtime) |
praetorium.db-shm | No | Shared memory (runtime) |
Init Behavior
ensure_castra() runs at startup via __main__.py:
Key behaviors:
- Creates all subdirectories with
exist_ok=True(idempotent) - Copies legatus prompt from
blueprints/only if missing (copy-if-missing) - Centurio blueprints are consumed at creation time, not at init
- The praetorium database is created later when
Praetorium.open()runs
Centurio Creation
When the Legatus creates a centurio via the create_centurio MCP tool:
Template Rendering
Templates use and placeholders:
| Source | Destination | Placeholders |
|---|---|---|
blueprints/centurio/prompt.md.template | castra/centuriones/<name>/prompt.md | , |
blueprints/centurio/tools.json.template | castra/centuriones/<name>/tools.json | None |
If the blueprint file is missing on disk, an inline fallback template is used (defined in rendering.py).
Centurio Removal
When the Legatus removes a centurio:
- Verify centurio exists in registry
- Disconnect SDK session via
SessionManager - Remove from in-memory registry
- Delete directory tree via
shutil.rmtree(centuria)
Memoria Layers
The Memoria system has three layers, all stored as XML files:
Layer 1: Edicta (Standing Orders)
| Property | Value |
|---|---|
| Path | castra/edicta/<name>.xml |
| Semantics | Mutable (create/overwrite/delete) |
| Name regex | ^[a-z0-9][a-z0-9_-]*$ |
| Scope | Global — all agents read these before tasks |
| TOTP gated | Revoke only (/revoke command) |
XML schema:
<edictum name="code-style" author="caesar" timestamp="2025-01-15T10:30:00+00:00">
All Python code must use type annotations and pass ruff checks.
</edictum>Layer 2: Acta (Shared Knowledge)
| Property | Value |
|---|---|
| Path | castra/acta/<name>.xml |
| Semantics | Mutable (create/overwrite) |
| Name regex | ^[a-z0-9][a-z0-9_-]*$ |
| Scope | Global — any agent can read or publish |
| Author | Auto-set to centurio name (scoped server) or explicit (full server) |
XML schema:
<actum name="quantum-research" author="vorenus" timestamp="2025-01-15T11:00:00+00:00">
Latest advances in quantum computing include superconducting qubits...
</actum>Layer 3: Commentarii (Private Notes)
| Property | Value |
|---|---|
| Path | castra/centuriones/<owner>/commentarii/<name>.xml |
| Semantics | Append-only (create new files only, no overwrite) |
| Name regex | ^[a-z0-9][a-z0-9_-]*$ |
| Scope | Per-centurio — only the owning centurio can read/write |
| Enforcement | Closure-scoped MCP server captures centurio_name at creation |
XML schema (no author attribute — scoped to owner):
<commentarium name="research-notes" timestamp="2025-01-15T11:15:00+00:00">
Found three key papers on quantum error correction...
</commentarium>XML Escaping
All XML values are escaped to prevent injection:
| Field | Escape Function | Library |
|---|---|---|
| Tag names | Hardcoded strings | — |
name attribute | quoteattr() | xml.sax.saxutils |
author attribute | quoteattr() | xml.sax.saxutils |
timestamp attribute | ISO format string (no user input) | — |
| Content body | escape() | xml.sax.saxutils |
MCP Tool Access
Two different MCP server configurations control tool access:
Full Server (Legatus) — 17 tools
The full server requires explicit centurio_name parameter for commentarii operations. The Legatus can access any centurio's private notes.
Scoped Server (Centurio) — 8 tools
Scoping is enforced via Python closures:
def build_memoria_centurio_server(store, centurio_name):
# centurio_name captured in closure — impossible to access another's data
tool("list_commentarii", ...,
lambda _a: store.list_commentarii(centurio_name))
tool("write_commentarium", ...,
lambda a: store.write_commentarium(centurio_name, a["name"], a["content"]))Access Matrix
| Tool | Legatus | Centurio |
|---|---|---|
list_edicta | ✅ | ✅ (read-only) |
read_edictum | ✅ | ✅ (read-only) |
publish_edictum | ✅ | ❌ |
revoke_edictum | ✅ | ❌ |
list_acta | ✅ | ✅ |
read_actum | ✅ | ✅ |
publish_actum | ✅ (explicit author) | ✅ (author auto-set) |
list_commentarii | ✅ (any centurio) | ✅ (own only) |
read_commentarium | ✅ (any centurio) | ✅ (own only) |
write_commentarium | ✅ (any centurio) | ✅ (own only) |
Security Properties
Every file operation has defense-in-depth protections:
| Threat | Defense | Module |
|---|---|---|
| Path traversal | Name regex ^[a-z0-9][a-z0-9_-]*$ rejects /, .., spaces | memoria/store.py |
| Symlink attacks | path.is_symlink() rejection before every read/write/delete | memoria/store.py |
| Centurio name collision | Reserved names {caesar, legatus, all, praetorium} rejected | centurio.py |
| XML injection | escape() + quoteattr() on all user-supplied content | memoria/store.py |
| Overwrite of private notes | Append-only semantics — write_commentarium raises if file exists | memoria/store.py |
| Cross-centurio access | Closure scoping in MCP server captures centurio_name at build time | memoria/tools.py |
File Naming Conventions
| Category | Pattern | Extension | Example |
|---|---|---|---|
| Centurio name | ^[a-z][a-z0-9_-]*$ | — (directory) | vorenus/ |
| Memoria entry | ^[a-z0-9][a-z0-9_-]*$ | .xml | code-style.xml |
| Blueprint template | descriptive | .template | prompt.md.template |
| Prompt file | prompt | .md | prompt.md |
| Tool config | tools | .json | tools.json |
| Database | praetorium | .db | praetorium.db |