Skip to content

Filesystem Architecture: Project vs. Deployed Instance ​

Date: 2026-02-16 Status: Research Author: Antony

The whole legion is a bunch of markdown files and scripts in a file tree. Caesar can version-control the entire system through git. How should we organize the project repo vs. the running instance's state?


The Insight ​

A centurio is not primarily code. A centurio is:

  • castra/centuriones/vorenus/prompt.md — personality, skills, rules
  • castra/centuriones/vorenus/tools.json — tool configuration
  • castra/centuriones/vorenus/commentarii/*.md — private memory

An edictum is castra/edicta/security-review.md. An actum is castra/acta/api-patterns.md. The legatus itself is castra/legatus/prompt.md.

The Python code (legio/) is infrastructure — plumbing that reads these files and routes them to the Claude API. The actual "intelligence" of the legion lives in the filesystem. This means:

  1. The legion is git-native. Every centurio creation, edictum publish, and prompt edit is a file change that can be committed, branched, diffed, and reverted.
  2. Caesar can fork a legion. Clone the repo, tweak prompts, deploy a second instance with different personality/rules.
  3. Prompts are code. They deserve the same review, versioning, and CI discipline as Python modules.

Current State: What's Where ​

What IS git-tracked (the project) ​

legio/                          # Python infrastructure
├── __main__.py                 # startup orchestration
├── legatus.py                  # orchestrator agent
├── session.py                  # SDK session lifecycle
├── centurio.py                 # centurio data model
├── nuntius.py                  # message model
├── praetorium.py               # SQLite message bus
├── config.py                   # config loading
├── errors.py                   # domain exceptions
├── totp.py                     # OTP verification
├── auctoritas.py               # authorization state machine
├── rendering.py                # formatting, templates
├── memoria/                    # knowledge store
│   ├── store.py
│   └── tools.py
└── telegram/                   # Telegram bot
    ├── bot.py
    ├── commands.py
    └── utils.py

templates/                      # blueprints for new centuriones
├── centurio/
│   ├── prompt.md.template
│   └── tools.json.template
└── legatus/
    └── prompt.md.template

tests/                          # 100% coverage test suite
scripts/                        # quality enforcement
dev-docs/                       # frozen documentation
.claude/rules/                  # coding standards

legio.toml                      # non-secret configuration
pyproject.toml                  # Python project config
AGENTS.md                       # shared agent instructions

What is NOT git-tracked (runtime state) ​

castra/                         # .gitignored living state
├── centuriones/                # centurio prompts + memory
│   └── vorenus/
│       ├── prompt.md           # ← the centurio's soul
│       ├── tools.json          # ← tool configuration
│       └── commentarii/        # ← private notes
│           └── api-design.md
├── edicta/                     # standing orders
│   └── code-review.md
├── acta/                       # shared knowledge
│   └── database-patterns.md
├── legatus/
│   └── prompt.md               # ← the legatus's brain
└── praetorium.db               # SQLite message history

.env                            # secrets (API keys, TOTP)

The Problem ​

castra/ is half-ignored. The .gitignore excludes centurio data, edicta, and acta (correct — these are instance state), but tracks castra/legatus/prompt.md and the directory structure. This creates ambiguity:

  • Legatus prompt is tracked, but centurio prompts are not
  • Templates exist but aren't clearly separated from instance state
  • No way to ship a "starter legion" with pre-configured centuriones
  • Database is obviously not tracked (binary), but its schema is embedded in Python code, not versioned independently

Proposed Structure: Three Layers ​

Layer 1: Code (always git-tracked) ​

The infrastructure. Python modules, tests, scripts, project config. Changes here require code review, tests, linting.

legio/                          # Python code
tests/                          # test suite
scripts/                        # quality tools
pyproject.toml                  # project metadata

Layer 2: Blueprints (always git-tracked) ​

The design of the legion. Templates, default prompts, example configurations. These define what a fresh deployment looks like.

blueprints/                     # was: templates/
├── legatus/
│   └── prompt.md               # default legatus prompt
├── centurio/
│   ├── prompt.md.template      # template for new centuriones
│   └── tools.json.template     # default tool config
├── edicta/                     # starter edicta (optional)
│   └── code-review.md          # example standing order
└── legio.toml.example          # example config with all options

Why rename templates/ to blueprints/? Two reasons:

  1. templates/ is a Python convention (Jinja, Django). Confusing.
  2. Roman vocabulary: blueprint = "exemplar" if we want Latin, but blueprints/ is clearer in English and the directory is infrastructure.

Layer 3: Castra (instance state, git-optional) ​

The living deployment. Created from blueprints at first launch, then evolves through operation. This is where Caesar's decision matters:

Option A — Castra fully gitignored (current default)

castra/                         # .gitignored
├── centuriones/*/              # created at runtime
├── edicta/*/                   # created via /edict
├── acta/*/                     # created by centuriones
├── legatus/prompt.md           # copied from blueprint at init
└── praetorium.db               # SQLite, never tracked

Best for: deployments where castra is disposable or backed up separately. Clean separation between code and state.

Option B — Castra as a tracked subtree (Caesar's legion-as-repo)

castra/                         # tracked (except db + secrets)
├── centuriones/
│   ├── vorenus/
│   │   ├── prompt.md           # ← version-controlled personality
│   │   ├── tools.json
│   │   └── commentarii/        # ← .gitignored (ephemeral)
│   └── pullo/
│       ├── prompt.md
│       └── tools.json
├── edicta/
│   └── code-review.md          # ← version-controlled policy
├── acta/
│   └── api-patterns.md         # ← version-controlled knowledge
├── legatus/
│   └── prompt.md
└── .gitignore                  # ignores praetorium.db, commentarii

Best for: Caesar who treats the legion itself as a product. Every centurio creation, prompt tweak, and edictum is a git commit. Can branch (git checkout -b experiment/aggressive-reviewer), test, and merge back.

Option C — Castra as a separate git repo (multi-instance)

legio/                          # code repo (github.com/xiaolai/legio)
castra-production/              # state repo (github.com/xiaolai/legio-castra-prod)
castra-staging/                 # state repo (github.com/xiaolai/legio-castra-staging)

Best for: running multiple legion instances from the same codebase with different centurio rosters and edicta. Each castra is its own repo with its own commit history.


Gitignore Redesign by Option ​

Option A (fully ignored — current, cleaned up) ​

gitignore
# Castra: runtime state, not source
castra/centuriones/
castra/edicta/
castra/acta/
castra/legatus/prompt.md
castra/praetorium.db*
!castra/**/.gitkeep
gitignore
# Castra: track prompts + edicta + acta, ignore runtime data
castra/praetorium.db*
castra/centuriones/*/commentarii/
castra/browser/sessions/        # encrypted auth state (future)
!castra/**/.gitkeep

Option C (separate repo) ​

The code repo's .gitignore ignores castra/ entirely. The castra repo has its own .gitignore:

gitignore
# In the castra repo
praetorium.db*
centuriones/*/commentarii/
browser/sessions/

Deployment: Init Flow ​

Regardless of option, first launch needs a bootstrap that populates castra from blueprints:

legio --init                    # or handled in __main__.py

1. Create castra/ directory structure
2. Copy blueprints/legatus/prompt.md → castra/legatus/prompt.md
3. Ensure edicta/, acta/, centuriones/ dirs exist
4. Initialize praetorium.db with schema
5. (Option B) git add castra/ && git commit -m "Initialize castra"

This already partially exists in __main__.py (castra dir creation). Extending it to copy the legatus prompt from blueprints is trivial.


Container Deployment ​

Docker image (immutable)
├── legio/          ← baked in
├── blueprints/     ← baked in
├── scripts/        ← baked in
└── pyproject.toml  ← baked in

Volume mount (persistent, per-instance)
├── castra/         ← mounted from host or named volume
├── .env            ← mounted or injected via env vars
└── legio.toml      ← mounted (or baked in with defaults)
dockerfile
FROM python:3.14-slim
WORKDIR /app
COPY legio/ legio/
COPY blueprints/ blueprints/
COPY pyproject.toml .
RUN pip install --no-cache-dir .
VOLUME ["/app/castra"]
ENV CASTRA_DIR=/app/castra
CMD ["python", "-m", "legio"]
yaml
# docker-compose.yml
services:
  legio:
    build: .
    volumes:
      - ./castra:/app/castra          # or named volume
      - ./.env:/app/.env:ro
      - ./legio.toml:/app/legio.toml:ro
    environment:
      - TELEGRAM_BOT_TOKEN
      - ANTHROPIC_API_KEY
      - LEGIO_TOTP_SECRET

For Option C (multi-instance), each instance gets its own compose file pointing to a different castra volume.


What Should Be in Blueprints vs. Castra ​

ContentLocationWhy
Legatus default promptblueprints/legatus/prompt.mdSource of truth, reviewed
Centurio prompt templateblueprints/centurio/prompt.md.templateUsed by create_centurio()
Centurio tool templateblueprints/centurio/tools.json.templateDefault MCP tool config
Example edictablueprints/edicta/*.mdOptional starter policies
Example legio.tomlblueprints/legio.toml.exampleDocuments all options
Active legatus promptcastra/legatus/prompt.mdMay diverge from blueprint
Centurio instancescastra/centuriones/*/Created at runtime
Active edictacastra/edicta/*.mdPublished via /edict
Active actacastra/acta/*.mdPublished by centuriones
Commentariicastra/centuriones/*/commentarii/Ephemeral per-session
Message historycastra/praetorium.dbBinary, never tracked
Auth sessionscastra/browser/sessions/Encrypted, never tracked

Database Location and Runtime Data ​

Current state ​

All runtime data lives in one place:

castra/praetorium.db            # SQLite — conversation history (nuntii)
castra/praetorium.db-shm        # SQLite shared memory (WAL mode)
castra/praetorium.db-wal        # SQLite write-ahead log

The Aerarium plan (cost tracking) would add a stipendia table to this same database. So praetorium.db becomes the single source of truth for all operational data.

What belongs in the database vs. filesystem ​

DataStorageWhy
Conversation history (nuntii)praetorium.dbRelational, needs queries (by sender, by time, visibility filtering)
Cost records (stipendia, future)praetorium.dbRelational, needs aggregation (SUM by sender, by day, by month)
Centurio promptscastra/centuriones/*/prompt.mdMarkdown files — human-readable, git-diffable, editable with any text editor
Edictacastra/edicta/*.mdSame — markdown, version-controlled, readable
Actacastra/acta/*.mdSame
Commentariicastra/centuriones/*/commentarii/*.mdEphemeral notes, per-session
Browser sessions (future)castra/browser/sessions/*.jsonEncrypted auth state, not queryable

Principle: structured data that needs queries → SQLite. Human-readable content that benefits from git diffs → markdown files.

Database in deployment ​

The database is never git-tracked (binary file, grows with every message). It needs special handling:

Bare metal / VM:

castra/praetorium.db            # lives on local disk
                                # backed up via cron + sqlite3 .backup

Docker container:

yaml
volumes:
  - legio-data:/app/castra      # named volume, persists across restarts
  # OR
  - ./castra:/app/castra        # bind mount, Caesar can access directly

Important: SQLite in WAL mode works fine with Docker bind mounts on Linux (same filesystem). On macOS Docker, filesystem notification delays may cause stale reads — not a concern for single-process Legio.

Backup strategy:

SQLite's .backup command creates a consistent snapshot while the database is in use. A daily cron job:

bash
sqlite3 castra/praetorium.db ".backup castra/backups/praetorium-$(date +%Y%m%d).db"

Or for Option B/C (git-tracked castra), export key data as SQL:

bash
sqlite3 castra/praetorium.db ".dump nuntii" > castra/exports/nuntii.sql
sqlite3 castra/praetorium.db ".dump stipendia" > castra/exports/stipendia.sql
git add castra/exports/ && git commit -m "Daily data export"

This gives git history for data without tracking the binary file.

Full deployed instance layout ​

/app/                           # or ~/legio/ on bare metal
├── legio/                      # Python code (immutable in container)
├── blueprints/                 # templates (immutable in container)
├── scripts/                    # tools (immutable in container)
├── pyproject.toml
├── legio.toml                  # config (mounted or baked in)
├── .env                        # secrets (mounted, never baked in)
│
└── castra/                     # ALL mutable state lives here
    ├── legatus/
    │   └── prompt.md           # the legatus brain
    ├── centuriones/
    │   ├── vorenus/
    │   │   ├── prompt.md       # personality + skills
    │   │   ├── tools.json      # MCP tool config
    │   │   └── commentarii/    # ephemeral session notes
    │   └── pullo/
    │       ├── prompt.md
    │       └── tools.json
    ├── edicta/                 # standing orders
    │   └── code-review.md
    ├── acta/                   # shared knowledge
    │   └── api-patterns.md
    ├── browser/                # future: headless browser state
    │   └── sessions/
    │       └── github.json     # encrypted storageState
    ├── backups/                # database snapshots
    │   └── praetorium-20260216.db
    ├── exports/                # optional: SQL dumps for git
    │   ├── nuntii.sql
    │   └── stipendia.sql
    ├── praetorium.db           # SQLite: nuntii + stipendia
    ├── praetorium.db-shm
    └── praetorium.db-wal

One directory to back up, one volume to mount, one path to protect. Everything mutable lives under castra/. If Caesar needs to migrate to a new server: copy castra/ + .env + legio.toml and redeploy.


Recommendation ​

Option B (tracked subtree) for Caesar's use case.

The legion IS the product. Every prompt, edictum, and knowledge file deserves version history. Caesar should be able to git log castra/centuriones/vorenus/prompt.md and see how a centurio evolved. Branching lets Caesar experiment safely. The .gitignore excludes only truly ephemeral data (database, commentarii, browser sessions).

The rename from templates/ to blueprints/ clarifies intent and avoids collision with Python template conventions.

Action items:

  1. Rename templates/ → blueprints/
  2. Update code that references templates/ path
  3. Restructure .gitignore for Option B
  4. Move legatus prompt source-of-truth to blueprints/legatus/prompt.md
  5. Add init logic to copy blueprint → castra on first launch
  6. Add Dockerfile + docker-compose.yml to repo root
  7. Document the three-layer model in AGENTS.md or a memo

Built with Roman discipline.