Files
wild-pc/docs/design.md
Paul Payne 7314b5cddb refactor(env): explicit defaults.env with placeholders; drop auto-injection
A service/job's env is now exactly its defaults.env — castle injects no hidden
convention vars. Values support ${port}/${data_dir}/${name} placeholders
(resolved at deploy, alongside ${secret:…}), so a program's own env var names
map to castle's computed values without hardcoding.

Why: the auto-injected <PREFIX>_PORT/<PREFIX>_DATA_DIR were a guess at the
program's env names — right for castle-scaffolded services, dead weight for
adopted ones (lakehouse carried two dead vars; notification-bridge/backup jobs
too). They also weren't visible in the config editor (computed at deploy), which
was the source of the 'four env vars but the UI shows none' mystery.

- core: resolve_env_vars gains a context (${port}/${data_dir}/${name});
  deploy builds env from defaults.env only — no <PREFIX>_* injection, no
  port_env. Removed the port_env field and the dead _env_prefix helper.
- cli: 'service/job create' gains repeatable --env KEY=VALUE (replaces
  --port-env); 'program create' scaffolds <PREFIX>_PORT/_DATA_DIR: ${…} for new
  daemons.
- app: removed the 'Port env' field; the Environment editor (defaults.env) is
  the single place, with a placeholder hint.
- live migration: central-context/castle-api/power-graph/protonmail mapped their
  real vars explicitly; lakehouse → just LAKEHOUSED_DAEMON_PORT: ${port}, data
  stays in ~/.lakehoused. Verified all services healthy on their ports, dead
  vars gone, zero failed units.
- docs: registry.md/design.md/stack guides + findings updated to the explicit
  model.

core 94 / cli 24 / api 52 green; ruff + app build clean.
2026-06-14 17:06:46 -07:00

25 KiB

Castle Design

Castle is a personal software platform. It manages independent services, tools, and frontends on a Linux machine using standard Unix primitives — systemd for process supervision, Caddy for HTTP routing, the filesystem for storage, and env vars for configuration. The castle CLI and API provide a registry and coordination layer on top.

The long-term goal: multiple Castle nodes (machines) that discover each other and coordinate, forming a personal infrastructure mesh. Each node is self-sufficient. The mesh is optional.

Principles

  1. Unix-native. Use the OS. systemd, journald, filesystem, signals, env vars, DNS. Don't reimplement what Linux already provides.

  2. Independence. Components never depend on Castle. They accept standard configuration (ports, data dirs, URLs) via env vars. A Castle service is just a well-behaved Unix daemon that happens to be registered in a manifest.

  3. Stack and behavior. Each program has a stack (development toolchain: python-fastapi, python-cli, react-vite) and a behavior (runtime role: daemon, tool, frontend). Scheduling, systemd management, and proxying are orthogonal operations — not behaviors.

  4. Language-agnostic above the build line. Below the build line, every language is different (uv, pnpm, cargo, go). Above it, everything is just processes, ports, files, and signals. Castle operates above the line.

  5. Separate source from runtime. The repo is for development. The runtime lives in standard Unix locations ($CASTLE_HOME, default ~/.castle/, plus systemd units). Nothing running should point into the source tree.

  6. AI-manageable. The CLI and API exist so that AI assistants can discover, create, and manage programs programmatically. Humans use the dashboard. Agents use the CLI and API.

  7. Simple until proven otherwise. Filesystem over databases. HTTP over custom protocols. Shell commands over plugin systems. Add complexity only when the simple thing actually fails.

Architecture Layers

┌─────────────────────────────────────────────┐
│  Coordination                               │
│  Node discovery, global registry, messaging │
├─────────────────────────────────────────────┤
│  Registry                                   │
│  Component spec, node config, CLI, API      │
├─────────────────────────────────────────────┤
│  Runtime                                    │
│  systemd, Caddy, filesystem, journald       │
├─────────────────────────────────────────────┤
│  Build                                      │
│  uv, pnpm, cargo, go build, etc.           │
└─────────────────────────────────────────────┘

The critical boundary is between Build and Runtime. Below it, each language has its own toolchain. Above it, everything is uniform — a process that reads env vars, listens on a port, logs to stdout, and responds to SIGTERM.

Build Layer

Transforms source code into runnable artifacts. Castle does not abstract over language toolchains — it just records the build commands and their outputs.

Language Toolchain Artifact
Python uv Entry point in venv
Node/TS pnpm Static bundle (frontends) or node script
Rust cargo Binary
Go go build Binary

Castle's build spec is intentionally minimal: a list of shell commands and a list of output paths. This works for any language without Castle needing to understand the toolchain.

For interpreted languages (Python, Node), Castle also needs to know the runtime wrapper — how to invoke the artifact. This is what the run spec's runner variants handle:

  • python — Python (sync via uv, deploy resolves installed binary)
  • node — Node.js (sync via pnpm/npm)
  • command — Direct execution (compiled binaries, shell scripts)
  • container — Docker/Podman
  • remote — External service (no local process)

Compiled languages (Rust, Go) use command — once built, they're just binaries. No Castle-specific runner needed.

Runtime Layer

Manages running processes using standard Linux infrastructure.

systemd handles process supervision:

  • Start/stop/restart services
  • Restart-on-failure policies (OTP's "let it crash")
  • Dependency ordering via After= / Wants=
  • Scheduled execution via .timer units
  • Logging via journald (stdout/stderr capture)

Caddy handles HTTP routing:

  • Reverse proxy on port 9000
  • Path-based routing to services (/api → port 9020)
  • Static file serving for frontends
  • TLS termination

Filesystem handles storage:

  • Service data: $CASTLE_DATA_DIR/<name>/ (default /data/castle/, on a dedicated volume)
  • Secrets: $CASTLE_HOME/secrets/ (default ~/.castle/secrets/)
  • Generated config: $CASTLE_HOME/artifacts/specs/ (Caddyfile, registry.yaml)

Castle generates systemd unit files and Caddyfile entries from the registry. It doesn't run a daemon itself — it configures OS-level infrastructure and gets out of the way.

Systemd units point to installed binaries (on PATH or in ~/.local/bin/), not to repo subdirectories. Frontends are the deliberate exception: rather than stage a copy, Caddy serves their built assets in place from the repo (<source>/<dist>/), with the serve prefix baked into the build via VITE_BASE.

Registry Layer

The registry is the central concept in Castle. It tracks what programs exist, what they can do, and how they're configured. But it's not a single thing — it's three distinct concepts:

1. Component spec — what a program is. Description, capabilities, build instructions, default configuration. This is source-level information, version-controlled in the repo. It answers: "what programs could exist?"

2. Node config — what's deployed on this machine, with what concrete ports, data paths, and env vars. This is per-machine. Two Castle nodes might run different subsets of programs with different parameters. It answers: "what's running here, and how?"

3. Runtime state — what's actually happening. PIDs, health, uptime, logs. This is ephemeral, owned by systemd and queried on demand. It answers: "is it working?"

Source vs. runtime split

These map to two files:

castle.yaml (in the repo, version-controlled) — Three sections:

programs:
  central-context:
    description: Content storage API
    source: /data/repos/central-context

services:
  central-context:
    program: central-context
    run:
      runner: python
      tool: central-context
    expose:
      http:
        internal: { port: 9001 }
        health_path: /health
    proxy:
      caddy:
        path_prefix: /central-context
    manage:
      systemd: {}

jobs:
  backup-collect:
    program: backup-collect
    run:
      runner: command
      argv: [backup-collect]
    schedule: "0 2 * * *"
    manage:
      systemd: {}

Programs define what software exists (identity, source, install, tools). Services define how daemons run (run config, expose, proxy, systemd). Jobs define how scheduled tasks run (run config, cron schedule, systemd).

Services and jobs can reference a program via program: for description fallthrough and source code linking. They can also exist independently (e.g., castle-gateway runs Caddy — not our software).

A service's env is exactly its defaults.env — castle injects nothing implicitly. Values may use ${port}/${data_dir}/${name}/${secret:…} placeholders, which deploy resolves into the registry's flat env.

$CASTLE_HOME/artifacts/specs/registry.yaml (per-node, not in the repo, generated by castle deploy) — Node config:

node:
  hostname: tower
  castle_root: /data/repos/castle
  gateway_port: 9000
deployed:
  central-context:
    runner: python
    run_cmd: [/home/user/.local/bin/central-context]
    env:
      CENTRAL_CONTEXT_DATA_DIR: /home/user/.castle/data/central-context
      CENTRAL_CONTEXT_PORT: "9001"
    behavior: daemon
    stack: python-fastapi
    port: 9001
    health_path: /health
    proxy_path: /central-context
    managed: true

The node config says what's deployed here and with what concrete values. castle deploy reads the spec from the repo, resolves the defaults.env placeholders and secrets, resolves binary paths, and writes the registry. Systemd units and Caddyfile are then generated from the registry — never from the spec directly.

This separation means:

  • The repo is just a repo. git pull doesn't affect running services.
  • Multi-node works: sync the spec + deploy on each node, no repo needed.
  • The spec is portable and version-controlled. The node config is local.
  • AI agents read the node registry to know what's deployed and running.

Interfaces

Three interfaces expose the registry:

  • CLI (castle) — For AI agents and terminal users. Structured output via --json. Commands for listing, inspecting, creating, and managing programs.
  • API (castle-api) — For programmatic access over HTTP. Used by the dashboard, other nodes, and remote agents.
  • Dashboard (castle-app) — For human discoverability. Visual overview of what's running, health status, logs.

Coordination Layer

Coordination handles discovery and communication — both between programs on a single node and across multiple Castle nodes.

Intra-node coordination:

  • Components find each other through the gateway (path-based routing) or direct port access via env vars.
  • The registry (CLI/API) provides discoverability.
  • No service mesh or message broker required for basic operation.

Inter-node coordination:

  • Each Castle node runs the API, which exposes its program registry.
  • Nodes discover each other via MQTT retained messages and mDNS/DNS-SD (python-zeroconf) for LAN environments.
  • The gateway on each node can proxy to services on other nodes, preserving path-based routing. Components don't know which node they're talking to.
  • MQTT provides pub/sub messaging for events, status, and coordination across nodes.
  • All mesh features are opt-in: CASTLE_API_MQTT_ENABLED=true and CASTLE_API_MDNS_ENABLED=true. Single-node works without them.

MQTT topics:

  • castle/{hostname}/registry — retained JSON, full NodeRegistry. Published on connect and after castle deploy.
  • castle/{hostname}/status"online" (retained) / "offline" (LWT). LWT ensures nodes are marked offline if they disconnect unexpectedly.

MeshStateManager (castle_api.mesh) holds remote NodeRegistry instances in memory, indexed by hostname. 5-minute staleness TTL. Updated by the MQTT client on incoming messages. Read by API endpoints to serve cross-node data.

mDNS (castle_api.mdns) advertises _castle._tcp and browses for peers and _mqtt._tcp broker. Uses python-zeroconf. Properties include hostname, gateway_port, api_port.

Caddyfile generation supports remote_registries — cross-node routes are added with reverse_proxy {hostname}:{port} entries. Local paths always take precedence.

Why MQTT over custom gossip:

  • Standard protocol, every language has a client library.
  • Retained messages give new nodes an immediate view of the network.
  • Topic-based routing maps naturally to castle/{node}/{program}.
  • Works across networks (not just LAN like mDNS).
  • Mosquitto is a single binary, simple to run as a Castle program.

Why mDNS/DNS-SD as a complement:

  • Zero-config LAN discovery via python-zeroconf.
  • Each node advertises _castle._tcp — standard tooling works (avahi-browse, dns-sd).
  • Good for bootstrapping: find the MQTT broker without hardcoding its address.

Dashboard

The web dashboard (castle-app) is a React SPA served by Caddy in place from its repo build output (<source>/dist/) at the root /. It talks to castle-api via the gateway proxy at /api.

Layout:

Castle
Personal software platform

[tower] [devbox (3)]               ← NodeBar (hidden in single-node)

┌─────────────────────────────────────────────────────────┐
│ Gateway · tower · port 9000 · 4 routes    [Reload] [Caddyfile] │
│                                                         │
│ Path              Component           Port  Node  Health│
│ /api              castle-api          9020  tower  ● up │
│ /central-context  central-context     9001  tower  ● up │
│ /notifications    notification-bridge 9002  tower  ● up │
│ /devbox-api       devbox-api          9020  devbox ● up │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ Mesh  ● connected     mqtt://localhost:1883   0 peers   │
└─────────────────────────────────────────────────────────┘

Daemons · Long-running processes that expose ports
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ castle-api   │ │ central-ctx  │ │ notif-bridge │
│ ● up 5ms     │ │ ● up 12ms    │ │ ● up 8ms     │
│ :9020        │ │ :9001        │ │ :9002        │
└──────────────┘ └──────────────┘ └──────────────┘

Components · Software catalog
  Name             Stack            Behavior  Schedule     Status
  pdf2md           Python / CLI     tool      —            installed
  protonmail       Python / CLI     tool      */5 * * * *  installed
  castle-app       React / Vite     frontend  —            —
  backup-collect   Python / CLI     tool      0 2 * * *    —

Key programs:

  • GatewayPanel — Route table with live health badges, reload button, collapsible Caddyfile viewer. Node column appears when multi-node.
  • MeshPanel — MQTT connection status (connected/disconnected badge), broker address, mDNS status, peer count with links. Hidden when mesh is disabled.
  • NodeBar — Horizontal list of discovered nodes. Hidden in single-node mode. Each node links to /node/{hostname}.
  • ServiceSection — Daemon cards in a responsive grid.
  • ComponentTable — Unified sortable table for all non-daemon programs (tools, frontends) with Stack, Behavior, Schedule, and Status columns.

Real-time updates:

  • SSE stream at /stream pushes health, service-action, and mesh events. React Query caches are updated or invalidated on each event.
  • Health polling runs every 10s server-side; SSE delivers updates to all connected dashboard clients.

Multi-node behavior:

  • NodeBar appears when GET /nodes returns >1 node.
  • GatewayPanel shows a "Node" column when routes span multiple nodes.
  • /node/{hostname} page shows a specific node's deployed programs.

Component Contract

Every Castle program, regardless of language, must satisfy a minimal contract. This is what makes the system uniform above the build line.

Services (long-running daemons)

Requirement Mechanism
Accept configuration Env vars (prefixed by service name)
Declare its port Env var, registered in expose.http.internal.port
Health endpoint GET /health returns 200
Data storage Read *_DATA_DIR env var, write there
Logging stdout for output, stderr for errors
Graceful shutdown Handle SIGTERM, exit cleanly
Secrets Read from env vars (Castle resolves ${secret:NAME})
No Castle dependency Must run standalone with just env vars set

Tools (CLI utilities)

Requirement Mechanism
Input File argument or stdin
Output stdout (pipeable)
Errors/status stderr
Exit codes 0 success, non-zero failure
No interactive prompts Scriptable by default

Jobs (scheduled tasks)

Same contract as tools, plus:

Requirement Mechanism
Idempotent Safe to re-run or run concurrently
Short-lived Exit when done (oneshot systemd unit)

Component Lifecycle

The path from source to managed process:

source → [build] → artifact → [install] → available → [deploy] → managed

Each step is distinct:

  1. Build — Language-specific. Produces an artifact (binary, venv entry point, static bundle). Castle records the commands but doesn't execute them implicitly.

  2. Install — Makes the artifact available on the system. For tools: uv tool install or compiled binary placed in ~/.local/bin/. For services: same — the binary or entry point is on PATH or in a known location. For frontends: assets built in place under the repo (<source>/<dist>/), served from there — no copy.

  3. Deploy — Materializes the runtime configuration. Reads the program spec, merges with node config, generates systemd units and Caddyfile entries that reference installed artifacts — never the source tree. Enables and starts services.

For compiled languages (Rust, Go), build produces a standalone binary and install is just placing it in ~/.local/bin/. For interpreted languages (Python, Node), the runtime wrapper (uv, node) handles finding the installed artifact.

Runtime Filesystem Layout

Two roots, each overridable by an env var: $CASTLE_HOME (config, code, artifacts, secrets; default ~/.castle) and $CASTLE_DATA_DIR (bulk program data; default /data/castle, kept on a dedicated volume):

$CASTLE_HOME/                   ← Config & artifacts (default ~/.castle)
├── castle.yaml                 ← Registry spec (programs, services, jobs)
├── infra.conf                  ← Infrastructure install choices
├── code/                       ← Program source (your programs)
│   └── <name>/
├── artifacts/
│   ├── specs/                  ← Generated by `castle deploy`
│   │   ├── Caddyfile
│   │   └── registry.yaml       ← Node config (what's deployed here)
│   └── content/                ← Built frontend assets
│       └── castle-app/         ← (index.html + assets, served at root)
└── secrets/                    ← Secret files (NAME → value)
    └── PROTONMAIL_API_KEY

$CASTLE_DATA_DIR/               ← Persistent service data (default /data/castle)
└── <name>/

~/.config/systemd/user/         ← Systemd units (standard location)
├── castle-central-context.service
├── castle-protonmail.service
├── castle-protonmail.timer
└── ...

Compiled-language tools (Rust, Go — planned) install their binaries to the standard ~/.local/bin/, not under $CASTLE_HOME.

Source (the repo) is referenced only during build and install. Everything the runtime touches lives under $CASTLE_HOME, $CASTLE_DATA_DIR, or standard systemd paths.

OTP as Design Guide

Castle's architecture parallels Erlang/OTP, mapped onto Unix:

OTP Concept Castle Equivalent
Application Component (independent, self-contained)
Application resource file Component spec in castle.yaml
Release config (sys.config) Node config in $CASTLE_HOME/artifacts/specs/registry.yaml
Release assembly castle deploy (spec + node config → runtime)
Supervisor systemd (restart policies, ordering)
Process Running service/worker/job
Application env Env vars
Node A machine running Castle
epmd mDNS / MQTT discovery
Distribution Inter-node coordination via MQTT + gateway proxying
"Let it crash" restart: on-failure in systemd
Global registry Merged node registries via MQTT retained messages

The mapping is conceptual, not literal. Castle doesn't implement OTP semantics — it uses OTP's thinking to guide which Unix primitives to compose and how.

Key OTP ideas that apply:

  • Isolation. Components don't share state. Communication is through explicit interfaces (HTTP, MQTT, filesystem paths).
  • Let it crash. Services don't need elaborate error recovery. systemd restarts them. Design for restartability, not immortality.
  • Supervision hierarchy. systemd's dependency ordering provides this. Services declare what they need to start after.
  • Location transparency. Components talk to paths (/api, /central-context), not to specific hosts or ports. The gateway can remap these across nodes.
  • Spec vs. config. In OTP, an application defines its structure (the .app file) and a release provides the deployment config (sys.config). Castle mirrors this: the program spec defines structure, the node config provides deployment values.

Current State

What exists today:

  • CLIcastle command, installed via uv tool install --editable cli/

  • Three packagescastle-core (models, config, generators), castle-cli (commands), castle-api (HTTP API)

  • Source/runtime splitcastle.yaml (spec) → castle deploy$CASTLE_HOME/artifacts/specs/registry.yaml (node config). Systemd units and Caddyfile generated from registry with fully resolved paths. No repo references in runtime artifacts.

  • Explicit env with placeholders — a deployment's env is exactly its defaults.env; castle deploy resolves ${port}/${data_dir}/${name}/ ${secret:…} into concrete values. No hidden convention injection.

  • Gateway — Caddy on port 9000, Caddyfile generated from registry

  • APIcastle-api on port 9020, reads from registry (optional castle.yaml fallback for non-deployed programs)

  • Dashboardcastle-app React/Vite frontend, static assets served in place from its repo build output (<source>/dist/)

  • Services — central-context (content storage), notification-bridge (desktop notification forwarder)

  • Jobs — protonmail (email sync every 5 min), backup-collect (nightly), backup-data (nightly restic backup)

  • Tools — ~15 CLI utilities (pdf2md, docx2md, search, gpt, etc.)

  • Manifestcastle.yaml with typed Pydantic models

  • Mesh infrastructure — MQTT client (paho-mqtt), mDNS discovery (python-zeroconf), MeshStateManager, all wired into API lifespan. Opt-in via CASTLE_API_MQTT_ENABLED / CASTLE_API_MDNS_ENABLED.

  • MQTT broker — Mosquitto running as castle-mqtt Docker container on port 1883, managed by systemd. Config and data in $CASTLE_DATA_DIR/castle-mqtt/.

  • Node APIGET /mesh/status, GET /nodes, GET /nodes/{hostname}. GET /deployments?include_remote=true for cross-node program listing.

  • Gateway panel — Dedicated UI showing route table, health per route, reload button, Caddyfile viewer. Cross-node routes shown when multi-node.

  • Mesh panel — Dashboard UI showing MQTT connection status, broker address, mDNS state, peer count. Hidden when mesh is disabled.

  • Node-aware UI — NodeBar (hidden single-node), node detail page, mesh SSE events for live node discovery updates.

  • Cross-node routing — Caddyfile generator accepts remote registries, generates reverse_proxy {hostname}:{port} entries.

What doesn't exist yet:

  • Multi-language support — Rust and Go programs (the abstractions support them via command runner, but no examples exist yet)
  • Build automation — Castle records build specs but doesn't orchestrate builds (each project builds independently)
  • Multi-machine testing — Mesh infrastructure is built and running on one node, but not yet tested with a second Castle node

Technology Map

Concern Technology Status
Process supervision systemd (user units) Active
HTTP routing Caddy (port 9000) Active
Component specs castle.yaml + Pydantic models Active
Node config $CASTLE_HOME/artifacts/specs/registry.yaml Active
CLI castle (Python, uv) Active
API castle-api (FastAPI) Active
Dashboard castle-app (React, Vite, shadcn/ui) Active
Python packaging uv Active
Node packaging pnpm Active
Linting ruff (Python), ESLint (TS) Active
Type checking pyright (Python), tsc (TS) Active
Testing pytest (Python), Vitest (TS) Active
Secrets ~/.castle/secrets/ file-based Active
Data storage Filesystem ($CASTLE_DATA_DIR/, default /data/castle/) Active
Messaging MQTT (paho-mqtt client, Mosquitto broker) Active (opt-in)
Node discovery mDNS (python-zeroconf) + MQTT Active (opt-in)
Rust packaging cargo Planned
Go packaging go build Planned