I recently read Rust Projects – Write a Redis Clone book that builds a real Redis-compatible server from scratch in async Rust. It’s a good book that hand-rolled wire protocol using actor like abstractions. This inspired me to show how Redis clone can be built withPlexSpaces, the distributed actor framework I’ve been building. The code examples in the book used raw Tokio: an mpsc::channel for the actor mailbox, tokio::spawn for every connection, and replica examples used tokio::select! loops for fan-out. PlexSpaces abstracts that the kind of plumbing so I rebuilt the same Redis subset like storage, expiry, replication, transactions as PlexSpaces actors, once in Rust and once in Python compiled to WebAssembly, then benchmarked it against a real two-node gRPC cluster. This post walks through key abstractions I used to simplify the implementation of Redis clone.
What is Redis?
Redis is a single-threaded, in-memory key-value store. A single thread processes every command without parallelism and coordination inside the store, locking, and transactions. This makes it fast as nothing contends for anything. Here are its core capabilities:
The wire protocol. Redis speaks RESP (Redis Serialization Protocol), a binary format: type prefixes (+ for simple strings, $ for bulk strings, * for arrays), length prefixes, \r\n terminators. ECHO HELLO on the wire looks like *2\r\n$4\r\nECHO\r\n$5\r\nHELLO\r\n.
Persistence. It offers two durability modes. RDB takes periodic snapshots of the whole keyspace to disk. AOF (Append-Only File) logs every write command and replays the log on restart.
Replication. A master streams write commands to replicas. The handshake is three steps: the replica sends PING (master replies PONG), then REPLCONF (negotiates parameters), then PSYNC (triggers a full sync). After that, every write streams to replicas as it happens. WAIT blocks until N replicas confirm receipt.
Key expiry. Per-key TTLs, handled two ways: passively (check on GET, return nil if expired) and actively (a background scan periodically deletes expired keys).
Transactions.MULTI starts a queue; every command after it gets queued instead of executed. EXEC runs the whole queue atomically. DISCARD cancels. The guarantee is serialization but there’s no rollback on an individual command failure.
The Book: Working Redis Clone
Here’s a short overview of each chapter of the book:
Chapter
What it builds
Ch1: TCP bind
TcpListener::bind, accept() in a loop, spawn a task per connection.
Ch2: RESP parsing
A custom result type for partial parses, a test harness, scanning for \r\n, identifying type-prefix bytes, parsing simple strings, bulk strings, and arrays, etc.
Ch3: Storage
GET, SET, DEL against a HashMap<String, String> behind a Mutex.
Ch4: Key expiry
SET key value EX seconds / PX milliseconds. Store a creation timestamp per value; check it passively on GET; sweep expired keys actively with a background task.
Ch5: The actor pattern
This chapter swap the mutex-guarded HashMap for an mpsc::channel(32): one storage actor owns the data and processes messages sequentially. Connection handlers send messages and wait for replies without locks.
Ch6: Command modules
Refactor the growing match in the connection handler into separate modules (strings, server commands, etc.).
Ch7–8: Replication
The three-step handshake (PING -> REPLCONF -> PSYNC). The master ships an RDB-equivalent snapshot to each new replica, then streams every write command to all replica senders in a fan-out loop. Chapter 8 adds WAIT: block until N replicas confirm their replication offset, via a tokio::select! loop.
Ch9: Transactions and INCR
INCR with create-if-missing and error-if-non-integer semantics. MULTI / EXEC / DISCARD with per-connection state. EXEC drains the queue atomically.
The Core Insight
The chapter 5 that showed how an actor owns one dataset, processes one message at a time without locking. However, it used fairly low-level APIs and only supported an architecture of one actor per machine. What if you had N actors across multiple nodes? That’s exactly what PlexSpaces‘ create_shard_group gives you. So I created an equivalent examples where PlexSpaces spins up N copies (StorageActors), hash-partitions the keyspace across them, and routes each operation to the shard that owns it. The application code never touches partitioning, routing, placement, or cross-node communication. It just calls set(key, value).
Here are five primitives in PlexSpaces that do all the distributed heavy lifting in this example:
create_shard_group: spins up N actor instances, hash-partitioned, placed across nodes.
bulk_update_shard_group: routes a batch of writes to the shard that owns each key.
scatter_gather: fans a query out to shards and collects responses, with a min_responses threshold and timeout.
broadcast_shard_group: sends the same message to every shard (replication, expiry sweeps, handshakes).
map_shard_group / reduce_shard_group: runs an operation on every shard in parallel and collects (map) or aggregates (reduce) the results.
Following diagram shows mapping of low-level Tokio implementation to above five calls:
The handler declarations stay almost identical in spirit but simpler (instead of a match-tree/HashMap):
Rust Implementation
#[plexspaces_handlers(gen_server)] impl StorageActor { #[handler(“get”)] async fn handle_get(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> { let key = msg.payload_json()?[“key”].as_str().unwrap_or(“”).to_string(); if let Some(entry) = self.store.get(&key) { // passive expiry check if let Some(exp) = entry.expires_at_ms { if now_ms() > exp { self.store.remove(&key); return Ok(json!({“found”: false})); } } Ok(json!({“found”: true, “result”: entry.value})) } else { Ok(json!({“found”: false})) } }
/// INCR key — create with 1 if missing; error if value is not an integer (Ch9). #[handler(“incr”)] async fn handle_incr(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> { let payload: Value = serde_json::from_slice(&msg.payload) .map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?; let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”).to_string();
// Passive expiry if self.data.get(&key).map(is_expired).unwrap_or(false) { self.data.remove(&key); }
let new_val = match self.data.get(&key) { None => 1i64, Some(entry) => { match entry.value.parse::<i64>() { Ok(n) => n + 1, Err(_) => return Ok(json!({ “result”: null, “error”: “ERR value is not an integer or out of range” })), } } };
/// DEL key — remove key, return count deleted. #[handler(“del”)] async fn handle_del(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> { let payload: Value = serde_json::from_slice(&msg.payload) .map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?; let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”); let deleted = if self.data.remove(key).is_some() { self.replication_offset += 1; 1 } else { 0 }; Ok(json!({ “result”: deleted })) }
}
Python Implementation
@actor
class StorageActor:
@handler("get")
def handle_get(self, key: str = "") -> dict:
entry = self.data.get(key)
if entry is None:
return {"result": None, "found": False}
if is_expired(entry):
del self.data[key]
return {"result": None, "found": False}
return {"result": entry["value"], "found": True}
@handler("set")
def handle_set(
self,
key: str = "",
value: str = "",
nx: bool = False,
xx: bool = False,
ex: Optional[int] = None,
px: Optional[int] = None,
) -> dict:
if self.num_shards > 1 and not self._owns_key(key):
return {"result": None, "skip": True}
if nx and key in self.data:
return {"result": None, "ok": False}
if xx and key not in self.data:
return {"result": None, "ok": False}
expires_at_ms: Optional[int] = None
if ex is not None:
expires_at_ms = now_ms() + ex * 1000
elif px is not None:
expires_at_ms = now_ms() + px
self.data[key] = {"value": value, "expires_at_ms": expires_at_ms}
self.replication_offset += 1
return {"result": "OK", "ok": True}
@handler("incr")
def handle_incr(self, key: str = "") -> dict:
if self.num_shards > 1 and not self._owns_key(key):
return {"result": None, "skip": True}
entry = self.data.get(key)
if entry is not None and is_expired(entry):
del self.data[key]
entry = None
if entry is None:
new_val = 1
else:
try:
new_val = int(entry["value"]) + 1
except (ValueError, TypeError):
return {
"result": None,
"error": "ERR value is not an integer or out of range",
}
self.data[key] = {"value": str(new_val), "expires_at_ms": None}
self.replication_offset += 1
return {"result": new_val, "error": None}
@handler("del")
def handle_del(self, key: str = "") -> dict:
if self.num_shards > 1 and not self._owns_key(key):
return {"result": 0, "skip": True}
deleted = 1 if self.data.pop(key, None) is not None else 0
if deleted:
self.replication_offset += 1
return {"result": deleted}
Chapter 2 Disappears
Chapter 2 is entirely about parsing RESP: 14 steps for byte-scanning and test harness. In PlexSpaces, this code disappears as the framework handles serialization, routing, and delivery.
In PlexSpaces, actors talk over JSON. A set command looks like so entire chapter disappears:
The book’s replication fan-out looks roughly like this:
// Book Ch7-8 — manual fan-out per write
for replica in &self.replicas {
let tx = replica.sender.clone();
let cmd = replication_event.clone();
tokio::spawn(async move { tx.send(cmd).await.ok(); });
}
Each replica gets its own spawned task without retries, timeout or automated ACK tracker. With PlexSpaces:
// One call fans out to all replica shards, collects all ACKs
let ack_count = cluster.propagate_to_replicas("SET", "replicated:key", "hello", 1).await?;
// Replication: write propagated to all 3 replica shards via broadcast
Under the hood, broadcast_shard_group fans out to every shard in the replica group, collects responses, handles timeouts, and returns. Here is how the chapter 8 implements WAIT:
This is straightforward for a a single process but in a distributed environment, connections might route to different servers so you need to track transaction state. PlexSpaces solves this with virtual actors, which are inspired by Orleans Actors, i.e., one ConnectionActor per client, created lazily on first call.
Each actor processes one message at a time without locks, so in_multi and queue are just plain struct fields: MULTI -> SET -> EXEC arrive in order at the same actor without mutext or atomics. Virtual actors spin up on first message and get garbage-collected when idle, so there’s no connection map to maintain and no cleanup to do on disconnect.
Throughput Numbers
Here are numbers from rudimentary benchmarks that produced: 20 batches of 50 keys each via bulk_update_shard_group, plus 50 individual GETs, against a 3-shard group spread across two real gRPC nodes:
Python WASM numbers are somewhat lower than Rust’s because WASM compilation adds overhead per handler invocation. But the architecture is identical: same PlexSpaces primitives, same shard group, same gRPC routing underneath.
The Python WASM Version
The same cluster logic also runs as Python actors compiled to WASM. No TCP socket without RESP parser or Tokio using the same broadcast_shard_group, scatter_gather, reduce_shard_group, and map_shard_group calls:
This compiles to WASM, deploys to a running PlexSpaces node over HTTP, and runs against a live cluster. The host.* calls map to the exact same primitives the Rust version calls such as fan-out, collect, timeout, all identical in semantics. The full source, including StorageActor, ConnectionActor, RedisCoordinator, and the BenchmarkActor, is in the redis_cluster example.
The Lines That Disappeared
What
Book (~650 lines)
PlexSpaces Rust (~280 lines)
PlexSpaces Python (~300 lines)
RESP protocol parser
~120 lines (full Ch2)
0 (JSON messages)
0 (SON messages)
TCP accept loop
~40 lines
0 (actor mailbox)
0 (actor mailbox)
Connection tracking map
~30 lines
0 (virtual actor lifecycle)
0 (virtual actor lifecycle)
MPSC channel setup
~20 lines
0 (actor framework)
0 (actor framework)
Replica list management
~40 lines
0 (broadcast_shard_group)
0 (host.broadcast_shard_group)
WAIT loop (tokio::select!)
~50 lines
~3 lines (scatter_gather)
~8 lines (host.scatter_gather)
Manual fan-out per replica
~30 lines
~5 lines(broadcast_shard_group)
~8 lines
Shard routing / partitioning
0 (single node)
~1 line (partition_strategy: hash)
~1 line
Multi-node placement
0 (single node)
~2 lines ( NodePlacement::Specific)
~2 lines
Coordinated snapshot
Missing
~5 lines (map_shard_group)
~8 lines
Active expiry broadcast
Missing
~5 lines (broadcast_shard_group)
~8 lines
In PlexSpaces implementation includes the StorageActor handlers (get, set, incr, del, expiry logic, replication handlers), the ConnectionActor MULTI/EXEC/DISCARD state machine, and the cluster setup logic.
How to Test Everything
Rust embedded example
cd examples/rust/embedded/redis_cluster
# Run the demo directly — prints all 11 steps with coord_ms timing:
cargo run --bin redis_cluster
# Or run the full validated test suite:
./scripts/test.sh
Python WASM example
Prerequisites: a running PlexSpaces node on port 8091 (and optionally 8093 for multi-node), Python 3.10+, and the plexspaces-py CLI.
cd examples/python/apps/redis_cluster
# Build actors to WASM:
./build.sh
# Deploy, initialize, and test all 11 steps (single-node):
./test.sh
# or explicitly: ./test.sh 8091
# Multi-node (shards distributed across both nodes):
./test.sh 8091 8093
Above example runs two nodes on ports 8091 and 803. The create_shard_group uses from_registry placement to spread shard actors across both nodes automatically. Every collective operation like scatter_gather, reduce, map, broadcast routes cross-node over gRPC, using the same primitives whether the shards are local or remote.
Fixed ports, on purpose
The Rust embedded example starts two in-process nodes on fixed ports, :8091 and :8093, rather than ephemeral ones:
Both examples use the same ports, so you can point the Python test.sh at a cluster the Rust example already stood up, or vice versa. Run ./scripts/test.sh for the Rust side.
The Python test.sh checks: a setup response with "status".*"ok", GET/SET/INCR semantics, an expired key returning "found".*false, replication returning "acks", a snapshot returning "shard_count" and "shards", and the benchmark returning "tps" and "p50_ms".
Learnings
The purpose of the book was to teach how Redis works using Rust. I rebuilt it on PlexSpaces to show how abstractions can simplify building complex distributed applications. For example, the RESP parser, the connection lifecycle, the replica list, and the WAIT loop are not your job. The redis book teaches you to hand-roll patterns like an mpsc mailbox, a spawn-per-connection loop, a tokio::select! deadline race. ThePlexSpaces uses many of same primitives to define high level abstractions but it provides an actor runtime that hides all complexity. This allows you to build the actual business logic like the storage logic, the replication semantics, the transaction model. Actors just give you a place to put them that scales horizontally without rewriting any of the logic itself. The throughput numbers show rough cost of the actor primitives, e.g., cluster setup is expensive, so you pay it once; individual operations are fast. With p50s in the hundreds of microseconds; bulk operations get much cheaper per key as the coordination overhead amortizes over more keys. This is how distributed systems work in general so you need to measure performance overhead.
I have been using various workflow systems for business process management, data pipelines and batch processing over twenty years. I built a declarative orchestration system over ten years, which was before GitHub Actions, CircleCI, and GitLab CI. The idea was simple: describe your work as a graph of tasks with pipes and filter patterns declaratively, give each task clear inputs and outputs, let a server run them on a schedule or in response to events, and report the results back. Automation has always been the core discipline of software engineering and we built tools for CI/CD, cron jobs, and data pipelines. With AI, your job is changing from the one who executes the steps to a conductor. Instead of manual coding, testing and other tasks, you now design the graph of nodes, define what each node is allowed to do, let agents work on the nodes while you monitor the work where it needs a human hand. You need a harness around each agent so the graph runs reliably, a sandbox for every agent because you fundamentally cannot trust what an LLM decides to do, and a skills layer so the same harness stays general-purpose.
I have seen teams building tightly coupled monolithic agentic systems that includes poorly implemented orchestration, the integrations, the prompts, and the skills, all bolted together. This makes it harder to make changes or extend agent capabilities and skills independently. In this post I will walk through three open source projects: Formicary, the orchestration engine that turns workflows into a graph with the Slack interface; ai-dev-tools, the harness of small scripts that actually do the work; and you-got-skills, the library of skills for SDLC.
Daily Routine
Each morning, you typically have to scan Jira/Github board, check pull requests to review, triage any new blockers, scroll through Slack messages to respond before deciding what to actually work on. AI coding tools have automated the implementation but you still need to know what the team is doing, catching problems, following up on reviews, etc. The basic flaw in most “agentic” setups today is that they’re still fundamentally interactive. You close your laptop and the agent stops. You need a way to keep agents working in background and instead of building systems that make you go pull information, and build agents that push it to you instead. You need somewhere to define the graph of who-triggers-what in a sandbox environment. You need a harness around each step so a flaky script doesn’t take the whole pipeline down. And you need the agent’s behavior to live somewhere editable, not buried in a prompt string, so the system stays general enough to point at a new kind of problem without a rewrite.
The Architecture: Three Tools, Four Layers
This section describes three open source tools: Formicary is the orchestration engine with Slack integration; ai-dev-tools is the harness that actually runs each agent inside a sandbox; and you-got-skills is the knowledge layer that keeps the whole system general-purpose.
Above diagram shows a graph where every box is a node with a job type, a task, a skill and every arrow is an edge that Formicary evaluates at runtime. Designing agents this way, instead of as one long prompt with a loop around it makes the system debuggable. The ai-dev-tools is the harness that runs inside each node and it turns “call an LLM” into “call an LLM inside a container, with a defined timeout, a defined exit-code contract in a sandbox environment. You should not trust an agent’s judgment about what’s safe to run but with sandbox you let the harness enforce the boundary.
Why hand off state through files?
Formicary provides declarative syntax to store artifacts or consume artifacts from a previous task. Every script starts by checking whether its own output already exists:
# Every script starts with this pattern
existing = read_json(config, issue_id, "plan_result.json")
if existing and existing.get("status") == "DONE":
print("Already done, skipping")
sys.exit(0)
If a task dies halfway through and gets re-run, it just picks up where it left off. Exit codes are the contract between a script and the orchestrator:
Exit code
Meaning
What Formicary does
0
Success
Move on to the next task
1
Error (worth retrying)
Retry with backoff
2
Blocked for a human input
Pause the job indefinitely
3
Not finished yet / waiting on something
Pause, then resume on a trigger or after a delay
The Skills Library
Skills are the knowledge layer that allow general-purpose harness instead of hardcoded to whatever workflow you built. The graph and the sandbox don’t know anything about code review or standups as they just run nodes. A skill is what tells an agent how to do a specific kind of engineering work well. Add a new skill and you’ve extended the system to a new kind of task without touching Formicary or ai-dev-tools at all.
Skills produce and consume a consistent folder structure inside your project:
your-project/
??? docs/
? ??? prd/ # Product requirements (YYYY-MM-DD-slug.md)
? ??? trd/ # Technical designs
? ??? adr/ # Architecture decisions (NNN-slug.md)
? ??? spikes/ # Spike findings
? ??? learnings/ # Learnings pulled from PRs and incidents
??? tasks/
??? backlog/ # task-NNN.md
??? in-progress/ # moving a file here = starting it
??? done/ # moving a file here = finishing it
Status is the folder. No ticket-state dropdown, no transition workflow to configure. mv tasks/backlog/task-042.md tasks/in-progress/ means the task has started. git blame tells you who moved it and when.
Diverge, then converge
The diverage and converge pattern allows running multiple independent LLM passes first (diverge), then merge and rank what came back (converge). A single reviewer may miss something but several independent reviewers will catch most of the issues. The ygs-review-pr skill runs four independent passes in parallel for correctness, security, API surface, and SRE concerns. A finalize step then merges everything and ranks it by severity.
The standup workflow uses the same idea. ygs-standup gathers signals from the issue tracker and from Slack then cross-references the two.
The Standup Workflow
Every weekday at 8am, a cron job wakes up, queries your Jira sprint (or GitHub), reads the open PRs, pulls the last 26 hours of Slack messages from your standup channel, and turns all of it into a brief.
The synthesize task calls the ygs-standup skill, which follows a fairly strict protocol:
**Alice:** Closed PROJ-42 (auth fix). Working on PROJ-51 (rate limiter)
— PR open 28h, no review yet. [Slack: "waiting on infra cert renewal"]
**Bob:** No tracker activity in the last 24h. Last Slack message Monday
(3 days ago). [Slack: silent since Monday]
**Carol:** PROJ-55 (data export) marked In Progress, no commits in 4 days.
Blocked label present. [Tracker: blocked since Tuesday]
Every claim traces back to a ticket, a PR, or a Slack message. ygs-risk-scan runs right after and appends a ranked list, using thresholds you can tune:
? HIGH PROJ-55 blocked, blocks PROJ-60 (in progress, owned by different person)
? MED PR #142 open 28h, single reviewer, no activity
? MED Carol: no updates in 4 days, sprint ends Friday
The thresholds live in a shared Markdown file:
Signal
Default severity
Escalates to HIGH if…
Issue stale > 3 days
MEDIUM
it blocks another issue
Issue stale > 5 days
HIGH
—
PR open > 2 days, no review
MEDIUM
only one reviewer assigned
PR open > 4 days
HIGH
—
Person silent > 2 days
MEDIUM
also no tracker activity
Blocked label
HIGH
—
Dependency chain: upstream is stale
HIGH
—
Sprint ends in < 2 days, not started
HIGH
—
You can also trigger the standup on demand from Slack:
@bot standup
@bot risk
Label an Issue, Get Back a Pull Request
You can label any Jira or GitHub issue ai-ready. Every five minutes a cron job checks for newly labeled issues and kicks off a four-task pipeline. When it’s done, there’s an open PR with an implementation and tests, the label has flipped to ai-pr-open.
# ai-gh-issue-picker.yaml
job_type: ai-gh-issue-picker
cron_trigger: "*/5 * * * *"
tasks:
- task_type: gather-issues
script:
- python -m scripts.gh.issue_picker
on_exit_code:
2: COMPLETED # no issues = not an error
- task_type: submit-jobs
# Uses formicary template to fan out one ai-gh-implement job per issue
script:
- '{{SubmitJobsFromJSON "ai-gh-implement" .IssuesJSON}}'
It has a built-in guard: if 10 or more implement jobs are already running or queued, it skips its turn. That stops someone from labeling 50 issues at once and blowing up the queue. The implementation pipeline itself:
# ai-gh-implement.yaml
job_type: ai-gh-implement
max_concurrency: 5
timeout: 86400s # 24 hours — some implementations take a while
tasks:
- task_type: plan
timeout: 15m
script:
- python -m scripts.gh.issue_picker --issue-id {{.IssueNumber}}
- python -m scripts.gh.plan --issue-id {{.IssueNumber}}
on_exit_code:
2: PAUSE_JOB # BLOCKED — needs a human before continuing
on_completed: implement
- task_type: implement
timeout: 45m
script:
- python -m scripts.gh.implement --issue-id {{.IssueNumber}}
on_completed: create-pr
- task_type: self-review
timeout: 10m
script:
- python -m scripts.review.run --mode self-review
--issue-id {{.IssueNumber}} --base-branch main
on_exit_code:
2: PAUSE_JOB # BLOCKED — critical finding, needs a human before the PR opens
on_completed: create-pr
- task_type: create-pr
timeout: 10m
script:
- python -m scripts.gh.create_pr --issue-id {{.IssueNumber}}
on_completed: poll-pr
- task_type: poll-pr
dependencies: [create-pr, poll-pr] # self-referencing = loop
script:
- python -m scripts.gh.poll_pr --issue-id {{.IssueNumber}}
on_exit_code:
3: PAUSE_JOB # PR still open — check back in `delay` seconds
delay: "{{.PollInterval}}s" # default 120s
Here’s the full chain:
Let’s walk through what actually happens for a real issue.
Plan
scripts/gh/plan.py calls Claude with up to 50 turns and a prompt that tells it to:
1. Read CLAUDE.md, .cursorrules, or any repo-specific coding guidelines if they exist
2. Discover .claude/skills/ in the repo — if a skill applies, plan to invoke it
3. Before designing new abstractions, search utils/, shared/, common/ for existing utilities
4. Check for monorepo structure
5. Generate a concise plan covering:
- Task breakdown with complexity estimates (S/M/H/XL)
- Exact files to create/modify per task
- Test strategy: write failing tests first, then implement
- A "Failing Test Spec" section
- Any risks or blockers
6. Classify overall complexity: S/low (?3 files), M/medium (4-10), H/high (>10)
7. Write the plan to PLANS/{slug}-{issue_id}-plan.md
ygs-implement also uses “ceremony levels” so small tasks don’t get over-engineered:
Light (1-3 files, <300 lines): proceed directly, skip checkpoints
Standard (4-8 files, 300-800): plan mode + checkpoint every 5 files
Heavy (8+ files, 800+ lines): flag as oversized, ask user to split
Picking the model by complexity
The plan task classifies overall complexity and writes it to /workspace/plan_complexity.txt. The implement task reads that and picks the right model:
# scripts/common/config.py
COMPLEXITY_MODEL_MAP = {
"low": MODEL_BEDROCK_HAIKU, # ?3 files, simple edits — fast and cheap
"medium": MODEL_BEDROCK_SONNET, # default — most issues
"high": MODEL_BEDROCK_OPUS, # complex architecture changes
}
The plan prompt writes a single word (low, medium, or high) to that file, and the implement task reads it back:
AnthropicComplexityLowModel and AnthropicComplexityHighModel are set in your org config by deploy-ai-workflows.sh, and can be overridden per deployment in models.env.
Polling the PR and responding to feedback
Every 2 minutes, the poll task checks the PR for new comments. It only reacts to comments that start with ai-bot. The agent stays out of human-to-human review discussion, and it never responds to its own earlier comments. So when a reviewer writes:
ai-bot please add a test for the rate limit exceeded case
The poll task reads it, applies the feedback, marks the comment handled in processed_comments.json, and keeps polling. Once the PR merges, the learning step kicks off automatically.
PR Review With a Human Gate
Code review usesthe diverge-then-converge pattern to review the code with multiple perspectives like security, architecture, SRE, etc.
# ai-gh-review.yaml
job_type: ai-gh-review
max_concurrency: 10
tasks:
- task_type: review # Claude runs ygs-review-pr, writes findings.json
- task_type: await-feedback # posts Block Kit to Slack, exits 3 ? PAUSE_JOB
- task_type: finalize # reads Decision, posts result to PR thread
The review step runs ygs-review-pr with this instruction:
1. Invoke the /ygs-review-pr skill to perform a full PR review
2. After the skill completes, write findings to findings.json
3. Output ONLY this JSON on the last line:
{"status":"DONE","findings_count":N,"verdict":"APPROVE|REQUEST_CHANGES","summary":"..."}
Once all four are done, findings get merged and ranked: CRITICAL > HIGH > MEDIUM > LOW, and by confidence within each level. The verdict maps straight off the highest severity found:
Any CRITICAL or HIGH finding ? REQUEST_CHANGES
Only MEDIUM/LOW findings ? COMMENT
No findings / only low conf. ? APPROVE
Deep review: seven domains in one pass
Standard review runs four passes. Deep review adds three more: performance, testing quality, and architecture.
@bot deep review https://github.com/org/repo/pull/42
These all map to the same ai-gh-review (or ai-jira-review) job type, just with ReviewDepth=deep injected as a static job variable:
The workflow reads ReviewDepth and picks the right skill: ygs-review-deep when it’s set to “deep,” ygs-review-pr otherwise. ygs-review-deep is a superset of the standard four passes, plus:
The implement pipeline runs a self-review task right before create-pr. Before the PR exists, the agent reviews its own diff against the base branch:
- task_type: self-review
timeout: 10m
script:
- python -m scripts.review.run --mode self-review
--issue-id {{.IssueNumber}} --base-branch {{.BaseBranch}}
on_exit_code:
2: PAUSE_JOB # BLOCKED — critical finding, needs a human before the PR opens
on_completed: create-pr
This runs ygs-implement in review mode, compares the diff to the original plan, and writes self_review.json. The outcome maps directly to an exit code:
self_review_status
Exit code
What the pipeline does
APPROVED
0
Proceed to create-pr
NEEDS_FIX
0
Claude fixes it inline, then create-pr
BLOCKED
2
PAUSE_JOB — a human needs to decide before the PR opens
How Slack Messages Turn Into Workflows
Socket Mode lives inside the Formicary queen itself. The queen opens an outbound WebSocket to Slack using your xapp- app-level token. When you mention the bot:
The queen’s SlackService does one thing, deterministically: it strips the mention, takes the first word, and looks it up against a route table in the queen’s config.
# In k8s/formicary-leader.yaml ConfigMap, under slack.routes:
slack:
routes:
- triggers: ["review", "pr"]
job_type: ai-gh-review
description: "PR review: correctness, security, API, SRE"
- triggers: ["implement", "build"]
job_type: ai-jira-implement
description: "Full pipeline: plan ? implement ? PR"
- triggers: ["standup", "status", "daily"]
job_type: ai-standup-jira
description: "Daily standup brief from Jira"
- triggers: ["adhoc"]
job_type: ai-adhoc
description: "Ad-hoc Claude invocation with any skill"
Everything after the trigger word gets passed through as the Prompt job parameter, verbatim. The queen’s whole job is mapping verb to job type and passing the text along. It does zero AI work of its own. All of that happens inside the ai-dev-tools container once the job actually starts. Once routing resolves, the queen submits the job and replies right in the thread:
Started ai-gh-review (job req-7f3a2) — I'll post updates here.
https://formicary.example.com/dashboard/jobs/requests/req-7f3a2
Replying to the thread resumes a paused job. If a review job is paused waiting on a decision and you reply in that thread, the queen matches it by SlackThreadTs and resumes it with your reply text injected as Prompt.
Registering as a developer
Before Slack commands work for you, you DM the bot your Formicary API token, once:
DM to @bot:
setup eyJhbGc... (your Formicary API token)
The queen validates the token inline and from that point on, any @bot mention from you has a known Formicary identity behind it.
How multi-tenant isolation actually works, end to end:
When you type @bot review https://github.com/org/repo/pull/42, here’s what the queen does, entirely server-side:
Reads your Slack user ID (U0A1HQL0C9J) off the Socket Mode event.
Looks up slack_user_id = U0A1HQL0C9J in user_configs and finds your Formicary user record, including your UserID and OrganizationID.
Calls SaveJobRequest(qc, req) and the server overwrites request.UserID and request.OrganizationID from that context.
Schedules the job and the ant scheduler first looks for a worker registered under your org_id.
Ant routing: when you connect your laptop as a worker with setup-ant-worker.sh --token <your-token>, the queen reads org_id out of your JWT at connect time and records it on that worker. Your jobs prefer your own worker.
All the commands
What you type
What runs
@bot standup
Daily brief: per-person status, risks, discussion questions (routes to Jira or GitHub via DEFAULT_TRACKER)
@bot risk / @bot risks
Ranked sprint risks with a capacity check
@bot prs / @bot open prs / @bot review queue
Open PRs grouped by reviewer status, sorted by age
@bot pr comments <url>
All inline feedback and open tasks for a PR
@bot review <github-url>
Standard PR review: correctness, security, API, SRE (4 domains)
@bot review <bitbucket-url>
Same, for Bitbucket PRs
@bot deep review <url>
Deep review: standard 4 domains + performance, testing, architecture (7 domains)
@bot full review <url>
Alias for deep review
@bot arch review <url>
Alias for deep review
@bot security review <url>
OWASP-focused security audit
@bot sre review <url>
Failure modes, observability, deploy safety
@bot implement PROJ-123
Full pipeline: plan ? implement ? self-review ? PR, for a Jira issue
@bot implement 42
Same, for a GitHub issue number (model picked by complexity: Haiku/Sonnet/Opus)
@bot jira-query <term> / @bot qjira <term>
Search open Jira issues by keyword, results as a Block Kit table
@bot jira-analyze PROJ-1, PROJ-2
Claude analyzes root cause + possible fixes for Jira issues
@bot gh-query <term>
Search open GitHub issues by keyword
@bot gh-analyze #123, #456
Claude analyzes root cause + possible fixes for GitHub issues
@bot adhoc <free text>
Run any Claude skill with a freeform prompt
@bot help
List every command
Ad-hoc Skill Execution
ai-adhoc is a general-purpose runner: any you-got-skills skill can be invoked with a free-form prompt, and the result comes back into your Slack thread.
@bot prs then submits ai-adhoc with Prompt="prs", and the container maps that to the ygs-pr-queue skill. No Python code changes needed.
Querying and Analyzing Issues From Slack
Two commands take you from a Slack message straight to structured Jira insight, no browser required.
@bot query-jira: find issues by keyword
@bot jira-query auth timeout
This submits an ai-jira-query job. The script builds a JQL query scoped to your project and, optionally, your team’s custom field. Results come back as a structured Slack Block Kit table:
Jira issues matching "flaky tests" (5 found)
PROJ-1001 [Bug] Flaky test in auth service - clickable link
Status: In Progress Priority: High
Assignee: alice Date: 2026-07-28
PROJ-995 [Story] Fix race condition in logger test
Status: To Do Priority: Medium
Assignee: bob Date: 2026-07-21
...
This routes to the same ai-jira-query job type. The result posts back to your thread.
@bot gh-query / @bot gh-analyze the GitHub equivalents
Same commands, gh- prefix, for teams on GitHub instead of Jira:
@bot gh-query open authentication bugs
@bot gh-analyze https://github.com/org/repo/issues/42
Team filtering
Both commands automatically filter to your configured team and sprint:
JIRA_SPACE (or BITBUCKET_WORKSPACE): the team/area filter value.
JIRA_TEAM_FIELD: the Jira custom field name (default EngScrumTeam, resolved to a field ID dynamically).
Set JIRA_TEAM_FIELD="" to turn the filter off entirely.
The Learning Loop: Getting Better Over Time
Most agent systems are stateless and every run starts from zero. In our workflow, after every PR merges, learn.py runs automatically as part of the poll-pr task. It reads the PR comments, the implementation artifacts, and the review findings, then invokes ygs-learn:
ygs-learn protocol:
1. Capture: What happened? Why does it matter? Category?
2. Dedup: search docs/learnings/ for similar slug before creating new
3. Write to docs/learnings/YYYY-MM-DD-slug.md
A learning document looks like this:
# Rate limiter key collision when user has multiple active sessions
**Category:** Edge Case
**Date:** 2025-08-03
**Source:** PR review finding, PROJ-123
## Learning
When a user has multiple active sessions, rate limiting by user_id counts across
all sessions. A single slow client can exhaust the budget for all their tabs.
## Evidence
Review comment on PR #142 flagged this. Reproduced locally with two
concurrent sessions against the same account.
## Application
When implementing per-user rate limits, check whether session isolation is
intended. If counts should be per-session, key by session_id not user_id.
Next time an implementation runs, that document is part of the context Claude reads.
ygs-retro runs at sprint end. It reads tasks/done/, recent git history, and the sprint’s accumulated learnings, and asks pointed questions based on what it actually found.
ygs-investigate enforces a debugging discipline: build a feedback loop first, e.g., a failing test, a log line, a REPL session before forming any hypotheses. Rank hypotheses 1 through 5. Instrument one variable at a time with tagged markers.
Trust and Oversight
Though, AI agents have solved most of coding and testing tasks but it still requires human review and feedback. We need a trusted autonomy, with minimal friction. You don’t trust the model to know its own limits; you build a harness that doesn’t need you to. That’s why every agent runs inside a container it can’t escape. You can’t ask an AI agent to self-police or prompt it to be careful. Every decision point is explicit. The agent never merges to main. It opens a branch, opens a PR, and stops there. A human approves and merges. For anything that needs a more formal sign-off, Formicary supports approval workflows with SLAs:
Secrets live in Kubernetes Secrets, never in ConfigMaps or plain env files. The container runs as non-root (uid 1000). Every artifact is a plain file and every state transition is logged in Formicary.
What Else You Can Build
Everything above is running in production today, but the same architecture supports a much wider range of background agents.
Codebase quality agent. A weekly workflow runs ygs-code-review across everything changed in the last week, posts a ranked findings report to Slack, and files tasks in tasks/backlog/ for anything CRITICAL or HIGH.
Documentation drift detector. A webhook fires when a PR touching an API handler merges. The workflow checks whether the matching docs were updated.
Duplicate abstraction scanner. A periodic workflow compares utility functions across repos owned by different teams and posts “team B has something that looks like what you just built,”.
Security posture monitor. Nightly, ygs-security-review runs against everything merged in the last 24 hours that touches auth, authorization, or data access. Findings go to a security channel.
Sprint health check, Wednesday afternoons. A mid-sprint cron runs ygs-risk-scan and only posts if it finds something HIGH severity. Most weeks it says nothing.
Bug pattern finder. A workflow runs ygs-investigate against recent error logs, proposes the top three hypotheses for each recurring pattern.
Getting Started
Installing the skills locally
git clone https://github.com/bhatti/you-got-skills.git
cd you-got-skills && ./setup
Every job pod can pull in additional skill repos without a rebuild. Set EXTRA_SKILLS_REPOS before running the deploy script.
# 1. Plain URL — sparse-clones only the skills directory (fast, default)
EXTRA_SKILLS_REPOS=https://github.com/myorg/my-skills.git
# 2. Comma-separated — multiple repos in one value, YAML-safe
EXTRA_SKILLS_REPOS="https://github.com/bhatti/you-got-skills.git,skills-cli:nutlope/hallmark"
# 3. JSON array — full control (use for org config; JSON breaks YAML template substitution)
EXTRA_SKILLS_REPOS='[
{"url": "https://github.com/bhatti/you-got-skills.git", "sparse": false},
{"url": "nutlope/hallmark", "type": "skills-cli"}
]'
Setup environment variables:
GH_ORG=your-org
GH_REPO=your-repo
GH_TOKEN=ghp_your_token_here
ANTHROPIC_API_KEY=sk-ant-your_key_here
AI_MODEL=claude-sonnet-4-6
# Controls which tracker bare commands like "standup" route to.
# "jira" routes to ai-standup-jira; "github" routes to ai-standup-gh.
DEFAULT_TRACKER=jira
Enable Socket Mode: generate an xapp- app-level token (scope: connections:write).
Add these bot token scopes under OAuth & Permissions:
Scope
Purpose
app_mentions:read
Receive @bot mentions
channels:history
Read channel messages
channels:read
List channels
chat:write
Post messages and Block Kit
groups:history
Read private channel messages
groups:read
List private channels
im:history
Read DMs (for the setup registration flow)
im:write
Reply in DMs
users:read
Resolve user display names
Subscribe to bot events under Event Subscriptions:
app_mention — @bot mentions in channels
message.im — DMs, for the setup registration flow
Install to your workspace (needs admin approval if app installs are restricted).
Each developer registers
Anyone who wants Slack commands to work DMs the bot once:
DM to @bot:
setup eyJhbGc... (Formicary API token from dashboard ? API Tokens)
In any channel the bot’s been invited to:
@bot help - lists all commands
@bot standup - runs standup, posts to thread
Summary
The core patterns in this post include declarative pipelines, cron triggers, file-based artifact handoff, exit-code contracts, approval gates. I have used these patterns to automate complex business processing, data pipelines, and CI/CD processes. I am now using it to automate AI backed tasks: a task can read code and form a judgment about it. When the graph, the harness, the sandbox, and the knowledge are four separate layers instead of one tangled system, each one evolves on its own. It allows you to update an environment with configuration, markdown files and configuration. Your job is now to design the graph, define what’s in each node, decide where the sandbox boundary sits, and write down what “good” looks like as a skill. Instead of manually gathering information, you use agents to do the low-level work. You then review the finished product, at the review verdict, at the escalation and decides what matters. That’s conducting, not playing every instrument, and it’s where your judgment actually belongs.
This is a part of series on structured concurrency: Part I (the general problem and TypeScript), Part II (Erlang and Elixir), Part III (Go and Rust), and Part IV (Kotlin and Swift).
In the earlier parts of this series I delved into how TypeScript, Erlang, Elixir, Go, Rust, Kotlin, and Swift each handle structured concurrency in practice such as spawning tasks, waiting for children to finish, propagating errors, and cancelling work cleanly. But I skipped over the the coordinationmodels these languages are actually built on. For example, Go didn’t invent channels and Erlang didn’t invent actors. Both are engineering ideas that go back to the 1970s and 80s, and once you understand the original model, most of the “gotchas” you hit while using the language stop looking like bugs and start looking like predictable consequences of a design choice made decades ago.
This post explains where each model came from, what it actually guarantees and how structured concurrency sits on top of all of them as a separate concern. It includes PlexSpaces, an actor-and-tuplespace framework I’ve been building in Rust that takes a pragmatic stance on this history, e.g., bounded mailboxes instead of Erlang’s unbounded ones, first-in-first-out matching instead of the classic tuple-space model’s unspecified ordering, and one small API instead of forcing you to learn several calculi at once.
A short timeline
It helps to see these ideas in the order they actually appeared:
1973: Carl Hewitt proposes the actor model: small, isolated units of state that can only talk to each other by sending messages.
1978: Tony Hoare publishes Communicating Sequential Processes (CSP), a mathematical notation (a “process algebra”) with a precise definition of what it means for two processes to synchronize.
1985: David Gelernter publishes Linda, a coordination model built around a shared associative memory (the “tuple space”).
1986: Gul Agha’s book extends the actor model with a fuller algebraic treatment.
1986: Joe Armstrong and colleagues at Ericsson start building Erlang.
2009: Go ships with goroutines and channels inspired by CSP.
2018 onwardstructured concurrency (Trio in Python, Kotlin’s coroutines, Swift’s TaskGroup, Java’s StructuredTaskScope) formalizes a simple idea: a spawned task’s lifetime should never outlive the scope that spawned it.
Notice that everything on that list except the last item is about how work talks to other work. Structured concurrency is about a completely different question, i.e., how work’s lifetime gets tracked.
Concurrency and parallelism
Concurrency is a property of how a program is structured: multiple logically independent activities are in progress, possibly interleaved on a single CPU core. Parallelism is a property of execution: things are genuinely happening at the same time, which requires more than one core. You can have concurrency without parallelism like Node.js’s single-threaded event loop juggling many pending requests on one core. You can also have parallelism without concurrency like a tight SIMD loop doing the same arithmetic on many numbers at once has no interleaved independent logic at all. Go’s own documentation defines it as: concurrency is about dealing with lots of things at once, parallelism is about doing lots of things at once. Goroutines give you concurrency; whether that concurrency turns into real parallelism depends on GOMAXPROCS and how many cores are actually available.
This matter for CSP and actors because both are concurrency models and neither one is “more parallel” than the other. What actually differs between them is how they structure communication.
Five models, one spectrum of coupling
Every concurrency model is answering the same underlying question, i.e., how does one unit of work talk to another one.
Model
Origin
How units talk
Coupling
Formal backing
CSP
Hoare, 1978
Synchronous rendezvous on a named channel
Time-coupled
Full algebra, checked by tools like FDR
Go-style CSP
Go, 2009
Channel, synchronous or buffered
Time-decoupled if buffered
None
Actor model
Hewitt 1973 / Erlang 1986
Async message to a named address
Identity-coupled, time-decoupled
Partial (Clinger, Agha)
async/await
Node.js/C#/Python event loops
Future/promise handle
Time-decoupled
None
Linda / tuple space
Gelernter, 1985
Tuple matched by content
Fully decoupled – no identity, no timing
Partial (Klaim’s semantics)
Structured concurrency
Trio/Kotlin/Swift, 2018+
Whatever the underlying model uses
Lifetime-coupled to a scope
None
CSP: the algebra
Before getting into Go’s implementation, let me explain algebra in CSP. I’ve written before about algebraic effects like resumable exceptions in OCaml 5 / Koka that let a function declare what it needs without saying who provides it. But algebra in CSP is a process algebra: a small set of operators like sequence, choice, parallel composition, hiding with equational laws. Because those laws exist, you can prove two CSP process descriptions behave identically. Tools like FDR (Failures-Divergences Refinement) do this mechanically, e.g., you describe your system as CSP processes, describe a specification as another CSP process, and FDR checks whether the implementation actually refines the spec, across every possible interleaving.
Here’s what that looks like for a scatter-gather pattern, an orchestrator firing off requests to several workers and collecting exactly K responses:
-- Specification: orchestrator collects exactly K results then stops
SPEC = scatter -> (collect -> collect -> collect -> STOP)
-- Implementation: N workers communicate via channels
WORKER(i) = request.i -> response.i -> STOP
SYSTEM = (||| i : {0..4} @ WORKER(i))
[| {| response |} |]
COLLECTOR(3)
COLLECTOR(0) = STOP
COLLECTOR(k) = response?i -> COLLECTOR(k-1)
-- FDR checks: assert SYSTEM [T= SPEC (trace refinement)
-- This PROVES: no deadlock, no livelock, exactly K responses collected
A handful of operators do almost all the work here:
CSP Operator
Meaning
What FDR Proves
P ? Q
External choice: the environment decides
Deadlock-freedom: at least one branch is always available
P ? Q
Internal choice: the process decides nondeterministically
Liveness: both branches are eventually reachable
P ? Q
Parallel composition, synchronized on shared events
No protocol deadlock between P and Q
P ; Q
Sequential composition: Q starts only after P terminates
Termination: P always reaches STOP
P \ A
Hiding: internal events in set A become invisible
Divergence-freedom: no infinite internal loops
In other words you can model your protocol in CSP and let FDR check every possible interleaving for you. For the scatter-gather pattern specifically, FDR would catch, automatically, before any code runs:
A worker that never responds (a deadlock)
A collector that waits for more responses than the workers can ever produce (also a deadlock)
A timeout path that accidentally creates an infinite retry loop (a livelock)
Go and Rust can’t do any of this because the as soon as you add a buffer (make(chan int, 5)) or an async boundary, you’ve left the strictly synchronous world that FDR reasons about. Go’s race detector can find data races at runtime, after the fact. Rust’s borrow checker prevents a whole class of shared-state bugs at compile time. But, neither one can prove protocol-level, whole-system deadlock-freedom the way FDR can for pure CSP.
Go’s channels
Hoare’s CSP defines communication as synchronous by construction where a send and its matching receive aren’t two separate events that happen to line up in time but they’re the same event in the algebra. Go’s unbuffered channel matches that faithfully: ch <- x and <-ch really do rendezvous. A buffered channel doesn’t, and that one divergence from the original model explains most of the sharp edges Go developers run into. Also, real CSP processes have no persistent identity beyond the algebra describing them, and channels are closer to anonymous synchronization events than to objects you hold a reference to. Go’s channels, by contrast, are first-class values that you create one, pass it into ten different functions, and any of them can close it. Nothing in the language enforces “exactly one owner, exactly one closer” and this is the seed of several of the gotchas below.
func worker(jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * j
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
go worker(jobs, results)
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs) // safe: only the sender closes, and no sends follow
for i := 0; i < 5; i++ {
fmt.Println(<-results)
}
// jobs <- 6 // panics — sending on a closed channel always panics,
// whether or not anything is still listening
}
Three more gotchas that Go’s compiler won’t warn you about:
Receiving from a closed channel never panics. It returns the zero value and ok == false immediately instead of blocking.
A nil channel blocks forever, on both ends, with no panic. Occasionally this is useful on purpose but if it happens to an uninitialized struct field, it results in permanent hang with no error message pointing you at the cause.
Goroutines have no structure by default.go func(){}() creates nothing that ties that goroutine’s lifetime to the caller. A goroutine permanently blocked on a channel operation is invisible to the garbage collector and invisible to Go’s deadlock detector. It causes a partial leak where the program running fine, with one goroutine stuck forever in the background.
One place Go actually stayed close to the algebra: select. Hoare’s algebra has external choice (?) as a first-class operator, and select‘s randomized tie-break among multiple ready cases matches CSP alegbra.
select {
case job := <-jobs:
handle(job)
case <-ctx.Done():
return ctx.Err() // structured cancellation, Go-style
default:
// non-blocking probe — CSP has no built-in default arm,
// but this is the standard way to build one
}
Best practices that have converged around Go’s channels
Confine, don’t share. Exactly one goroutine should own a channel’s write side and be the one to close it.
Thread context.Context through every long-running goroutine. A select that never watches ctx.Done() is a goroutine leak waiting to happen.
Size buffered channels as semaphores for bounding concurrency (worker pools, rate limiting) instead of letting goroutines fan out unbounded.
Use errgroup (or equivalent) for propagating the first error and coordinating cancellation across a group of goroutines, instead of hand-rolling error channels.
Treat structured concurrency as the governing principle anyway, even without language support: a goroutine’s lifetime should be scoped to, and never outlive, the function or request that spawned it.
Instrument the concurrency itself: race detector in CI, plus metrics and tracing on channel operations and goroutine counts in production because concurrency bugs are nondeterministic and hard to catch with a handful of unit tests.
Actors: isolation you get structurally
Actors give you a different, and in some ways weaker, guarantee than CSP but they give it to you structurally. An actor’s state is private, and it processes exactly one message at a time. There is no data race inside one actor, full stop. Instead of “processes synchronizing on named events,” Hewitt’s model says: everything is an actor. Each actor has a private mailbox (unbounded and asynchronous), private state and three things it’s allowed to do on receiving a message: send messages to other actors, create new actors, and decide how to handle its next message. There’s no synchronous handshake requirement anywhere.
Here’s a worker pool in Erlang, matching the crawler pattern from Part II of this series:
-module(worker_pool).
-export([start_pool/1, dispatch/2, worker_loop/1]).
start_pool(N) ->
[spawn_link(fun() -> worker_loop(0) end) || _ <- lists:seq(1, N)].
worker_loop(Count) ->
receive
{work, Job, From} ->
From ! {result, do_work(Job)},
worker_loop(Count + 1);
{status, From} ->
From ! {count, Count},
worker_loop(Count)
% No catch-all clause yet — see the gotcha below
end.
dispatch(Pid, Job) ->
Pid ! {work, Job, self()},
receive
{result, R} -> R
after 5000 ->
{error, timeout}
end.
Two Erlang-specific gotchas worth knowing before you ship anything like this:
Selective receive skips a non-matching message instead of discarding it.receive scans the mailbox in arrival order against your clauses. Anything that matches none of them just sits there, and the next receive call starts scanning from the front all over again. Left unchecked, this is O(n²) behavior over time as junk quietly accumulates. The fix is a catch-all clause:
worker_loop(Count) ->
receive
{work, Job, From} -> ...;
{status, From} -> ...;
Other ->
logger:warning("unexpected message: ~p", [Other]),
worker_loop(Count) % drop it, don't let it pile up
end.
Mailboxes have no bound by default.! never blocks in Erlang and there’s no rendezvous. If a producer outpaces a slower worker, the worker’s mailbox just keeps growing until memory runs out. In practice, you either switch to a blocking gen_server:call for anything where backpressure actually matters, or you monitor process_info(Pid, message_queue_len) yourself and shed load manually.
Supervision is the actor model’s answer to fault structure where a supervisor’s children are linked to it, and a crash triggers a restart strategy instead of taking the whole system down with it. But it is structured fault handling, not structured lifetime tracking. A supervisor doesn’t block waiting for its children to finish instead supervision answers “what happens when a child crashes.” Erlang also provides a location transparency, e.g., an Erlang Pid looks identical whether it points to a local process or one on another node ( Pid ! Msg). But that transparency is syntactic, not operational. A remote send can fail with nodedown or badrpc, latency is never zero so you cannot skip handling the failures that only show up once the mailbox is across a network.
Where actors are simpler than channels
A few structural reasons actors tend to feel simpler in practice than channel-based code:
Ownership is enforced by the design itself. There’s no equivalent of “who’s allowed to write to this channel,”, every interaction is a message dropped into a mailbox that only the receiving actor ever drains.
There’s no close semantics to get wrong. Actors don’t have anything like Go’s send-on-closed-panics / double-close-race. An actor’s lifecycle like start, running, terminated is a small, well-understood state machine, and you can monitor/link actor for detecting unexpected crash.
Failure handling is first-class. Supervision trees and let-it-crash give you a systematic answer to “a worker just crashed, now what?” Go’s answer is recover() scattered wherever someone remembered to put it or manual errgroup/context-cancellation wiring to propagate failure to siblings.
Location transparency. With channels, you need to build remoting capability yourself. With actors, it’s often just a deployment decision.
Where actors are not automatically simpler: mailbox-based concurrency can hide backpressure problems, e.g., an actor with an unbounded mailbox can happily accept messages faster than it processes them and quietly balloon memory. Reasoning about message ordering across several independent actors’ mailboxes is also harder than reasoning about a single shared channel’s FIFO order. CSP’s synchronous rendezvous gives you stronger backpressure for free where an unbuffered send blocks until the receiver is ready (some of modern actor runtimes like Akka support mailbox bounding).
Best practices for actor systems
Bound mailboxes and monitor mailbox depth as a first-class metric, e.g., an unbounded mailbox is the actor world’s version of an unbuffered-channel leak.
Design supervision hierarchies deliberately like one-for-one, one-for-all, rest-for-one.
Keep actor state small and serializable if you ever want migration or persistence.
Use location transparency deliberately, not accidentally.
CSP/channels fit use cases when you have a fixed, well-understood pipeline topology like stream-processing stages, worker pools with a known fan-out shape. Actors suite when your system’s topology is dynamic like agents spawning agents and where failure isolation matters. I have built PlexSpaces, an actor-based framework, with facets for durability, supervision, and virtual-actor placement based on these lessons. For example, it provides location transparency, failure isolation, and the backpressure/mailbox-bounding. Here is how an actor lifecycle is managed in PlexSpaces:
async/await
Async/await never got a formal algebra or expressiveness proof. It’s syntactic sugar over futures and promises, running on a single-threaded event loop or a thread-pool-backed task scheduler. Within one event loop, there’s no preemption between await points, which quietly eliminates a lot of classic race conditions but it introduces its own flavor of the “who’s tracking this” problem:
async function processOrder(order) {
sendConfirmationEmail(order); // fire-and-forget — no await!
return { status: "accepted" };
}
sendConfirmationEmail here returns a promise nobody is holding onto. If it rejects, nothing catches it and in most runtimes that becomes an unhandled-rejection warning nobody reads. If the process exits before it resolves, it just silently never finishes. Structurally, this is the exact same failure as an unstructured Go goroutine, a unit of work whose lifetime nothing owns. Promise.all and asyncio.gather fix this for the cases you remember to wrap explicitly.
This is also where the “function coloring” problem lives, which I covered in the ADTs and algebraic effects post: once a single function is async, every caller up the chain has to become async too. Algebraic effects unrelated to CSP’s process algebra are one proposed fix: separate what a function needs from who provides it.
Linda Memory Model
Linda coordinates through a shared associative memory called a tuple space, with four operations:
out(t): write a tuple, don’t block
in(t): block until a tuple matches your template, then atomically remove it
rd(t): block until a tuple matches, but leave it there for others
eval(t): spawn a computation; its eventual result becomes an ordinary tuple once it finishes
Neither side of a Linda interaction needs to know who the other one is. A producer can out() a tuple long before any consumer even exists. This is “generative communication”, data just floats in the shared space until something matching comes looking for it:
// Pseudocode — classic Linda fan-out/fan-in
for i in 0..n:
eval(("result", i, compute(i))) // spawn n concurrent computations
count := 0
while count < n:
in(("result", ?i, ?r)) // blocking, destructive, matched by content
collect(r)
count += 1
Notice there’s no worker identity anywhere in that collector loop at all. This is suitable for use cases like master/worker fan-out, blackboard-style coordination, barrier synchronization by counting tuples as they arrive. Linda has two gotchas of its own:
Which matching tuple you get is unspecified. If two tuples both match your template, the classic Linda spec never says which one in() hands you.
eval() returns nothing. No handle, no future, no promise object of any kind. The only way to know a spawned computation ever finished is to already know the shape of its result tuple and read it.
These issues prevented Linda from going mainstream but its associative memory primitives are natural for coordination related use cases.
Structured concurrency
Kotlin’s coroutineScope, Swift’s TaskGroup, and Java 21’s StructuredTaskScope bind a spawned task’s lifetime to the lexical scope that spawned it. The scope literally cannot exit until every child has finished whether error or not. None of the five communication models above give you that by default:
Model
What tracks a spawned unit’s completion
CSP / Go
Nothing: go func(){}() has no parent link at all
Actors / Erlang
Supervision restarts a crashed child, but nothing blocks waiting for a healthy one to finish
async/await
Nothing, an un-awaited promise just runs, or silently fails
Linda
Nothing, eval() doesn’t even return a handle to check
This is exactly why structured concurrency reads as an add-on layer rather than another communication model. It’s a lifetime discipline you can apply on top of channels, actors, promises, or tuples, e.g., Trio applies it to async/await, Kotlin applies it to coroutines that might be built on channels.
How PlexSpaces answers these gotchas
Most frameworks inherit one of above models’ specific historical rough edges along with its strengths. PlexSpaces is a actor-and-tuplespace framework I’ve been building, and wrote about in more depth here. It deliberately combines actors and Linda rather than picking one, but it doesn’t reproduce either one’s original sharp edges just for the sake of purity. Here’s the mapping from “gotcha described above” to “the specific fix PlexSpaces makes”:
Gotcha, as described above
PlexSpaces’ pragmatic answer
Erlang mailboxes have no bound, so a fast producer can grow one until memory runs out
Bounded mailboxes. An actor’s inbox has a real, configurable limit, a producer that outpaces its consumer gets backpressure instead of an unbounded memory leak.
Classic Linda leaves the order of matching tuples unspecified, so which one you get is nondeterministic by design
FIFO tuple matching. When more than one tuple matches a template, PlexSpaces returns them in the order they were written, not an arbitrary one, removing nondeterminism-by-specification.
Full CSP requires learning a process algebra; full Linda requires learning a four-primitive calculus bolted onto a host language; Erlang requires learning OTP’s supervision idioms
One small API surface. Actors expose a handful of primitives like send, ask, and the tuple-space operations.
eval() in classic Linda returns no handle, so a spawned computation’s completion is untracked by default
Because the “worker” side of a fan-out/fan-in in PlexSpaces is an ordinary supervised actor rather than a bare eval(), its lifetime is owned by a supervisor even though its result is collected the Linda way.
A crash mid-task loses whatever work was in flight, a concern none of CSP, actors, or Linda’s formulations really address
Durability journaling underneath everything. Messages are journaled at the actor-framework level, below application code, so a crash doesn’t silently lose in-flight work, replay picks the actor back up where it left off.
A fan-out/fan-in worker pool shows the combination directly:
// Coordinator spawns N supervised, bounded-mailbox workers.
for i in 0..n {
spawn_with_facets(
&ctx, service_locator.clone(),
"worker", "default",
Worker::new(i), vec![],
).await?;
}
// Coordinator collects results Linda-style — associative, FIFO,
// no ActorRef needed for any individual worker.
let mut collected = 0;
while collected < n {
let tuple = ctx.tuple_space()
.in_(template!["result", Wildcard, Wildcard])
.await?;
collect(tuple);
collected += 1;
}
The workers are ordinary supervised actors, restartable, journaled, isolated, with bounded mailboxes so a slow coordinator can’t be flooded. The result collection is Linda-style associative matching, but FIFO instead of unspecified, so results come back in the order the workers actually produced them rather than in an arbitrary one.
Example: scatter-gather with a timeout, across three models
Comparisons are easier to trust when they’re concrete rather than abstract, so I implemented the same pattern, scatter-gather with a timeout across all three approaches. The problem: fan requests out to N services, collect the first K responses within a deadline, then cancel everything else, guaranteed. This is the pattern underneath every hedged-request system, every parallel-search aggregator, and every timeout-bounded fan-out you’ve seen in production.
Go CSP: the naive version leaks goroutines
The code below shows the mistake almost everyone makes on the first pass like spawning goroutines with no cancellation path at all:
func ScatterGatherNaive(services []time.Duration, firstK int) []ServiceResponse {
ch := make(chan ServiceResponse, len(services))
for i, latency := range services {
go func(id int, lat time.Duration) {
time.Sleep(lat)
ch <- ServiceResponse{ServiceID: id, Data: fmt.Sprintf("response-%d", id)}
}(i, latency)
}
results := make([]ServiceResponse, 0, firstK)
for range firstK {
results = append(results, <-ch)
}
// BUG: N-K goroutines still running in background with no cancellation path
return results
}
The fix combines context.WithTimeout with errgroup to get a real structured lifetime:
func ScatterGatherStructured(services []time.Duration, firstK int, timeout time.Duration) []ServiceResponse {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var mu sync.Mutex
results := make([]ServiceResponse, 0, firstK)
g, ctx := errgroup.WithContext(ctx)
for i, latency := range services {
g.Go(func() error {
resp, err := simulateService(ctx, i, latency)
if err != nil { return nil }
mu.Lock()
defer mu.Unlock()
if len(results) < firstK {
results = append(results, resp)
if len(results) >= firstK { cancel() }
}
return nil
})
}
_ = g.Wait() // All goroutines done — structured lifetime guarantee
return results
}
errgroup.Wait() guarantees every goroutine finishes before the function returns, and context.WithTimeout propagates cancellation down to the slow workers, so nothing leaks. The catch: you still have to write that plumbing by hand every time.
Go CSP gotchas, side by side with the CSP algebra:
Gotcha
What happens
CSP algebra equivalent
Goroutine leak
Blocked goroutines run forever, invisible to GC and deadlock detector
N/A
Nil channel recv
Blocks forever – no panic, no warning
N/A
Nil channel send
Also blocks forever silently
N/A
Send on closed
Runtime panic – unrecoverable crash
N/A
Recv from closed
Returns zero value + ok=false
N/A
Buffered vs unbuffered
Breaks rendezvous = breaks the formal reasoning about synchronization
Buffered channels aren’t part of original CSP
Select non-determinism
A random ready case is chosen when several are ready
CSP’s external choice (?) is nondeterministic by design
Runnable, with tests: native/gotchas_test.go — 8 tests demonstrating every row above as failing-then-fixed code.
// Gotcha: Goroutine leak — spawned workers block forever on an unread channel
ch := make(chan int)
for i := 0; i < 10; i++ {
go func(id int) { ch <- id }(i) // blocks forever — no reader
}
// 10 goroutines leaked: invisible to GC, invisible to deadlock detector
// Gotcha: Nil channel blocks forever (both directions, no panic)
var ch chan int // nil
<-ch // blocks forever on recv
ch <- 42 // blocks forever on send
// Gotcha: Send on closed panics, recv from closed returns zero (asymmetric!)
ch := make(chan int, 1); close(ch)
ch <- 1 // PANIC: send on closed channel
v, ok := <-ch // v=0, ok=false — no panic, just zero value
// Gotcha: Buffered channel breaks rendezvous
buffered := make(chan int, 5)
buffered <- 1 // sender proceeds without receiver — not CSP anymore
Pure Rust: a Nursery, and select! as a guarded command
Rust gives you ownership-enforced isolation without shared state by default but it doesn’t give you structured lifetime by default either. The Nursery type below binds a group of spawned tasks to a scope explicitly:
tokio::select! maps almost directly onto CSP’s guarded command / external choice operator, whichever branch is ready first wins, and the biased; modifier gives you deterministic priority ordering (unlike Go’s deliberately random tie-break):
let results = scatter_gather_csp(&services, 3, Duration::from_millis(300)).await;
// Nursery guarantees: all children cancelled before scope exits
Ownership prevents shared-state bugs entirely, at compile time. JoinSet::abort_all() gives you a real, guaranteed cancellation. The nursery pattern gets you structured lifetime with essentially no runtime overhead. The catch: there’s no nursery built into the standard library and there’s still no formal algebra backing any of it (no FDR-style prover checking).
// Gotcha: Naive tokio::spawn has no parent link — tasks leak
let mut handles = vec![];
for svc in &services {
handles.push(tokio::spawn(simulate_service(svc)));
}
// If we return early, spawned tasks run forever — no cancellation
// Fix: JoinSet provides structured lifetime
let mut join_set = JoinSet::new();
for svc in &services { join_set.spawn(simulate_service(svc)); }
// On drop or abort_all(), all tasks are cancelled — guaranteed
PlexSpaces: supervised lifetime plus decoupled collection
This is where actor supervision (structured fault handling) and Linda-style tuple-space coordination (decoupled result collection) get combined. Start with thin Linda-style wrappers over the tuple-space host functions:
The orchestrator scatters by spawning supervised workers, then sets its own timeout with a self-message:
// Scatter: spawn N workers under supervisor
for i in 0..num_services {
spawn("actor-csp-wasm", &format!("worker-{i}"), "", &init_json)?;
send(&worker_id, "cast", &work_payload)?;
}
// Set timeout — send_after fires a collection message to self
send_after(timeout_ms, "cast", &collect_msg)?;
Each worker writes its result to the shared tuple space, with zero knowledge of who’s collecting it:
// Worker: Linda OUT — write result tuple to shared tuplespace
linda_out(&[
Value::String("result".into()),
Value::String(request_id.into()),
Value::Number(service_id.into()),
Value::String(result_data),
])?;
And gathering reads back whatever arrived in time, then explicitly tells the supervisor to stop the rest:
// Gather: Linda RD-ALL — collect whatever arrived before timeout
let results = linda_rd_all(&["result", request_id, *, *])?;
// Structured cleanup: stop remaining workers via supervisor
for wid in &worker_ids { stop(wid)?; }
Workers never need to know who’s collecting their results, that’s the Linda decoupling doing its job. The supervisor guarantees the worker lifecycle end to end, e.g., a crashed worker restarts automatically under a OneForOne strategy. Bounded mailboxes keep a slow coordinator from getting flooded. FIFO tuple matching makes the collection step deterministic instead of an open question.
The three approaches, side by side
Property
Go CSP (errgroup)
Rust (Nursery/select!)
PlexSpaces (actors + Linda)
Cancellation
context.Cancel() propagated
JoinSet::abort_all()
stop() via supervisor
Structured lifetime
g.Wait() blocks
Nursery scope exit
Supervisor manages lifecycle
Backpressure
Buffered channel capacity
Channel capacity
Bounded mailbox
Failure handling
errgroup collects first error
JoinError on abort
Supervisor restarts crashed worker
Coupling
Workers know the result channel
Workers know the result type
Workers only know the tuple shape (Linda)
Formal backing
None
None
None (but FIFO + bounded mailboxes removes two classes of nondeterminism)
One more actor-model gotcha, demonstrated via supervisor behavior
// Gotcha: Unbounded mailbox — fast producer OOMs the consumer
// Fix: PlexSpaces uses bounded mailboxes with a configurable limit
// Gotcha: No structured lifetime — actors are async, no scope to wait on
// Fix: Supervisor + explicit stop() for child actors after collection
// Gotcha: Orphaned actors — a spawned actor runs forever if nobody stops it
// Fix: OneForOne supervisor manages worker lifecycle; orchestrator calls stop()
The tldr;
CSP has real algebra and real tooling (FDR) behind it. Go borrows the vocabulary but drops the proof the when you add a buffer.
Actors have partial formal treatment and isolation by construction, but unbounded mailboxes and selective-receive skip are real, sharp edges the model doesn’t protect you from on its own.
async/await never had formal backing at all, and its fire-and-forget promise is the exact same “who’s tracking this” bug as an unstructured goroutine.
Linda is the most decoupled model on this list and the least adopted. The original spec leaves match order unspecified and spawned work untracked.
Structured concurrency isn’t a another concurrency model, instead it’s a lifetime discipline layered.
PlexSpaces’ bet is that you don’t have to inherit every historical rough edge along with the good ideas like bounded mailboxes, FIFO matching, and one small unified API let it combine actor supervision with Linda’s decoupled coordination.
Comments Off on Structured Concurrency in Modern Programming Languages Part V: The Coordination Models Behind It All (CSP, Actors, Linda, and async/await)
Every major cloud now supports Serverless FAAS capabilities like AWS Lambda/Step Functions, Azure Durable Functions, GCP Cloud Functions and Cloudflare Durable Objects where you write a function or a small stateful actor. This allows you to scale it, pay only for what runs but there is a catch, you build on a proprietary runtime, and the runtime’s storage model, invocation model, and IAM rules become part of your application whether you meant them to or not. You cannot easily rewrite it or run it somewhere else. I saw a recent post (Why we’re moving Wire off Cloudflare Durable Objects) from Wire, which ran every container on Cloudflare Durable Objects since day one. They wrote why they rebuilt their own data plane instead of staying, which included extra network hops on the hottest path, drift of state, separation of compute from data, rigid placement policies and lack of self-hosting. None of these are reliability complaints, instead they’re architectural ceilings baked into a runtime you don’t own. And the pattern generalizes past Cloudflare:
AWS Lambda: SAM and LocalStack approximate the runtime locally, but diverge on execution environment, IAM, and VPC behavior.
Azure Durable Functions: Azurite emulates the storage layer, but the replay-based orchestration engine behaves differently under real concurrent load than it does in the emulator.
GCP Cloud Functions: the Functions Framework runs locally, but Eventarc, Pub/Sub push, and Cloud Run triggers all need live GCP resources.
Cloudflare Workers/DO/Agents: wrangler dev simulates KV with SQLite and alarms with in-process timers, but never replicates the distributed routing that decides which data center actually holds your object.
Every one of these runtimes gives you a great abstraction and takes your operational sovereignty in exchange. This post shows how to keep the abstraction like stateful actors, durable storage, alarms, WASM sandboxing, LLM calls, observability, an event bus, webhooks while running it on infrastructure you control with an open-source framework called PlexSpaces.
PlexSpaces
PlexSpaces is an open-source, polyglot actor framework that gives every actor durable KV storage and alarms, routes messages between actors on one node or across a gRPC mesh, and exposes a host API to Go, TypeScript, Python, and Rust. You write an actor once; the same WASM module deploys to your laptop, a Docker container, Kubernetes, bare metal, or several clouds at once, unchanged. The mental model sits close enough to Cloudflare Durable Objects that migrating existing DO code is mostly mechanical. The key difference: there’s no simulated version to diverge from production, because the production runtime is the development runtime.
Part I: Durable Objects
Cloudflare publishes a short list of rules for writing correct Durable Objects, which are easily mapped to a PlexSpaces constraint, and for most of them the mapping is tighter:
Cloudflare Rule
How PlexSpaces handles it
Don’t coordinate between objects from inside an object: use async messaging
host.send(actorId, op, payload) is the only cross-actor primitive. There is no shared-memory path on the same node.
Don’t assume a single instance: globally unique, but workers can race to create one
Actor IDs are content-addressed: {name}//{type}::{ns}@nodeId. The node that owns the ID wins.
Store state before returning: in-memory state is lost on eviction
getState()/setState() checkpoints on every handler return. Durable KV is secondary store. Survive eviction and restart.
Keep objects small: large objects cause cold-start latency
WASM heap is the actor’s private address space, isolated from the host. State serializes only on checkpoint.
Use blockConcurrencyWhile() for init
onInit() in TypeScript / @init_handler in Python / Init() in Go runs before the first message and restore persisted state.
Alarm fires at-most-once
ReminderFacet persists the alarm timestamp in durable storage and re-queues after restart.
The one meaningful difference: Cloudflare guarantees globally unique placement. PlexSpaces virtual actors are unique per node or per cluster when pinned with @* (any node) or @nodeId (specific node). PlexSpaces provides an object-registry for managing cross-cluster deduplication.
Durable KV Storage
Cloudflare’s ctx.storage gives you a transactionally consistent store scoped to one object:
PlexSpaces gives you the same guarantee through host.kv. Because each actor processes one message at a time, a plain read-modify-write is safe without extra locking. The migrating_cloudflare_workers TypeScript example restores room history on onInit() using batch KV similar to blockConcurrencyWhile:
PlexSpaces maps this directly through ReminderFacet, which persists the alarm to durable storage and re-queues it automatically after a restart. The AlarmDemoActor in the guild-chat example demonstrates the full lifecycle like set, query, fire, cancel:
Cloudflare’s core abstraction is the globally unique object that appears on first access, at the cost of a binding declared in wrangler.toml:
// Cloudflare DO — needs a binding in wrangler.toml
const id = env.CHAT_ROOM.idFromName(roomId);
const room = env.CHAT_ROOM.get(id);
await room.fetch("/send", { method: "POST", body: JSON.stringify(msg) });
PlexSpaces virtual actors give you the same behavior with no binding file. The actor ID ({name}//{actorType}::{namespace}@*) encodes both the shard key and the actor class, and the @* suffix lets the runtime place it on whichever node is best; pin it with @node-id for data locality:
The actor spins up on its first message, whether or not it existed before.
Listing Actors by Namespace
Cloudflare exposes a Durable Objects namespace list API for management and observability. PlexSpaces has a direct equivalent defined in its proto-first design. The ListActors RPC in actor_runtime.proto accepts namespace, actor_type, state, and node_id filters and returns paginated results:
// proto/plexspaces/v1/actors/actor_runtime.proto
message ListActorsRequest {
string actor_type = 3;
ActorState state = 4;
string node_id = 5;
// Namespace for tenant isolation — only actors in this namespace are returned
string namespace = 6;
PageRequest page_request = 2;
}
message ListActorsResponse {
repeated Actor actors = 2;
PageResponse page_response = 3;
}
From a PlexSpaces client, listing all active ChatRoomActor instances in the default namespace:
# HTTP API (equivalent to Cloudflare's list-objects endpoint)
curl "http://localhost:8080/api/v1/actors/default/ChatRoomActor?state=active"
Cloudflare limits listing to metadata (ID, location, storage size). PlexSpaces returns full Actor records including state, facets, node assignment, resource usage, and tenant/namespace tags.
WebSocket Handling
Cloudflare’s Model
Cloudflare gives the Durable Object two WebSocket modes. In the standard model, the object holds the socket directly:
// Cloudflare DO — standard WebSocket
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws, message) {
for (const peer of this.ctx.getWebSockets()) {
peer.send(`broadcast: ${message}`);
}
}
async webSocketClose(ws, code) {
ws.close(code, "connection closed");
}
The WebSocket Hibernation API (state.acceptWebSocket / getWebSockets()) is Cloudflare’s optimization for objects that hold many sockets but are mostly idle: the object is evicted when no message is being processed, and WebSocket state is restored from durable storage on the next message.
PlexSpaces: A Cleaner Split
PlexSpaces takes a different approach: room state and connection state are in separate actors. A ChatRoomActor holds only membership and message history; each browser connection is a thin-node client registered under its own actor ID. When the room fans out, host.send(actorId, "chat_message", event) routes each delivery through the WsActorTransportClient to the right WebSocket session like the room never holds a socket handle.
This is the same split Discord uses internally in its Elixir stack, where session processes are separate from guild processes. Here’s the real onSend handler from examples/typescript/apps/ws_chat_room/:
// PlexSpaces TypeScript — examples/typescript/apps/ws_chat_room/chat_server_actor.ts
onSend(payload: SendPayload): unknown {
const senderUsername = this.state.members[payload.sender_actor_id] ?? payload.sender_actor_id;
const ts = host.nowMs();
this.state.history.push({
senderActorId: payload.sender_actor_id,
sender: senderUsername,
text: payload.text,
ts,
});
if (this.state.history.length > MAX_HISTORY) {
this.state.history = this.state.history.slice(-MAX_HISTORY);
}
const event = {
sender: payload.sender_actor_id,
sender_username: senderUsername,
text: payload.text,
room_id: this.state.roomId,
ts,
};
// Fan out to all members including sender (delivery confirmation)
// host.send() routes each tell through WsActorTransportClient ? WsRegistry ? thin-node WS session
const memberIds = Object.keys(this.state.members);
for (const actorId of memberIds) {
host.send(actorId, "chat_message", event);
}
return { success: true, members_notified: memberIds.length };
}
A companion PresenceActor in the same file tracks online/offline state using a durable reminder (host.sendAfter) to mark a user offline after 55 seconds of silence without external cron job involved:
Actor checkpoints and can be evicted; reconnect restores via getState()
Socket-level error handling
webSocketError(ws, err)
Session actor handles disconnect
Outgoing WebSocket from DO
new WebSocket(url) in fetch
host.httpClient("link").fetch(...) or service link for outbound calls
The PlexSpaces model costs more code upfront (session actor + room actor) and pays you back with independent scaling: fan-out scales with the number of receivers, not with the room actor’s memory; a crashed session doesn’t lock the room; reconnect logic is client-side only.
Fan-Out: DO’s Missing host.send() Primitive
In Cloudflare, cross-object fan-out means individual fetch() calls or Queues, which are not lean as a fire-and-forget tell. PlexSpaces’s host.send() is a fire-and-forget message routed through the actor mesh, no HTTP overhead, with in-process delivery for co-located actors. The Python guild-chat send_message handler shows this clearly:
# PlexSpaces Python — examples/python/apps/migrating_cloudflare_workers/guild_chat.py
@handler("send_message")
def send_message(self, user_id: str = "", content: str = "") -> dict:
msg = self._add_message(user_id, content, host.now_ms())
# Fan-out: fire-and-forget to each member actor
# Mirrors Discord's Manifold pattern for distributed fan-out
# In Cloudflare DO, this would be individual fetch() calls — expensive
fan_out_count = 0
for member_id in list(self.members.keys()):
if member_id != user_id:
host.send(member_id, "receive_message", {
"room_id": self._room_id(),
"seq": msg["seq"],
"from": user_id,
"content": content,
})
fan_out_count += 1
self._persist_history() # batch multiPut — one KV call for the whole room
return {"status": "ok", "seq": msg["seq"], "fan_out": fan_out_count}
Part II: Cloudflare Agents SDK
Cloudflare’s Agents SDK builds stateful AI agents on top of Durable Objects. PlexSpaces covers the same patterns; some are direct translations and a few require a different shape.
Conversation State and Memory
Cloudflare stores conversation history in the DO’s storage. PlexSpaces does the same through host.kv, with the same durability guarantee: the ChatAgentActor in examples/python/apps/chat_agent/ stores history under a well-known key and restores it across activations:
# PlexSpaces Python — examples/python/apps/chat_agent/chat_agent.py
@handler("chat")
def chat(self, message: str = "") -> dict:
# Load history — equivalent to: await this.storage.get('history')
history = host.kv.get_json("history") or []
history.append({"role": "user", "content": message, "timestamp": host.now_ms()})
assistant_reply = self._call_llm(history)
history.append({"role": "assistant", "content": assistant_reply, "timestamp": host.now_ms()})
# Persist — equivalent to: await this.storage.put('history', history)
host.kv.put_json("history", history)
self.total_messages += 1
# Schedule summarization alarm once history is long enough
if len(history) > _ALARM_THRESHOLD and host.alarm.get() == 0:
host.alarm.set(host.now_ms() + _ALARM_DELAY_MS)
return {"status": "ok", "reply": assistant_reply, "history_length": len(history)}
@handler("__alarm__")
def on_alarm(self) -> dict:
# Durable alarm callback — equivalent to Cloudflare Agents SDK onAlarm()
history = host.kv.get_json("history") or []
summary = self._call_llm([{
"role": "user",
"content": f"Summarize this conversation (2-3 sentences): {json.dumps(history)}"
}])
host.kv.put("summary", summary)
host.kv.delete("history") # clear after summarizing
return {"status": "ok", "action": "summarized", "messages_summarized": len(history)}
For long-term cross-session memory, store summaries under a user-scoped key (memory:{user_id}) and inject them into the next conversation’s system prompt similar to Cloudflare’s getMemory/setMemory implementation.
Calling LLMs
Cloudflare’s Agents SDK routes every call through env.AI, Cloudflare’s own inference gateway, locked to providers they support:
// Cloudflare Agents SDK — locked to Cloudflare's AI gateway
const response = await this.env.AI.run("@cf/meta/llama-3-8b-instruct", { messages });
PlexSpaces actors call any provider through a named HTTP service link resolved at deploy time, so the actor code never mentions a specific vendor:
# PlexSpaces Python — examples/python/apps/chat_agent/chat_agent.py
def _call_llm(self, messages):
http = ServiceHttpClient("llm-link")
body = {
"model": "claude-3-5-haiku-20241022",
"max_tokens": 1024,
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
}
resp = http.post("/v1/messages", body)
# Parse Anthropic response
if isinstance(resp, dict):
content = resp.get("content", [])
if content and isinstance(content, list):
return content[0].get("text", "")
return "[LLM unavailable]"
app-config.toml points the link at Ollama locally, Anthropic or OpenAI in production, or an internal AI gateway in a regulated environment:
# Local development — Ollama
[[service_links]]
name = "llm-link"
url = "http://localhost:11434"
# Production — swap without touching actor code
[[service_links]]
name = "llm-link"
url = "https://api.anthropic.com"
headers = { "x-api-key" = "${ANTHROPIC_API_KEY}" }
Cloudflare Agents SDK ships workflow primitives that checkpoint multi-step sequences. PlexSpaces WorkflowActor gives you the same like Run, Signal, and Query RPCs map to start, inject external events, and inspect state. For example, the payment workflow in examples/go/apps/migrating_cadence/payment_workflow.go shows the shape:
// PlexSpaces Go — examples/go/apps/migrating_cadence/payment_workflow.go
// PaymentWorkflow implements WorkflowActor for idempotent payment processing.
// Steps: validate ? authorize (with retry) ? capture ? settle.
// Signals: refund, cancel. Queries: status, payment_id.
type PaymentWorkflow struct {
plexspaces.BaseActor
PaymentID string `json:"payment_id"`
Status string `json:"status"` // pending ? validated ? authorized ? captured ? settled
Steps []PaymentStep `json:"steps"`
RefundRequested bool `json:"refund_requested"`
}
func (p *PaymentWorkflow) Run(payloadJSON string) string {
// Each step checkpoints via getState/setState before proceeding.
// If the node crashes mid-run, the workflow resumes from the last checkpoint.
p.Status = "validating"
if err := p.validatePayment(); err != nil {
p.Status = "failed"
return marshal(map[string]any{"error": err.Error()})
}
p.addStep("validate")
// Authorize with retries (idempotency key prevents double-charge)
p.Status = "authorizing"
for attempt := 0; attempt < 3; attempt++ {
if err := p.authorizePayment(); err == nil {
break
}
}
p.addStep("authorize")
// ... capture, settle
return marshal(map[string]any{"status": p.Status, "payment_id": p.PaymentID})
}
func (p *PaymentWorkflow) Signal(name, _ string) {
switch name {
case "refund":
p.RefundRequested = true
case "cancel":
p.Status = "cancelled"
}
}
func (p *PaymentWorkflow) Query(name, _ string) string {
return marshal(map[string]any{"status": p.Status, "payment_id": p.PaymentID})
}
For multi-agent orchestration, OrchestratorActor in examples/go/apps/miniclaw/ decomposes a task into sub-tasks, delegates each to a worker agent discovered via process group, and aggregates results through TupleSpace:
// PlexSpaces Go — examples/go/apps/miniclaw/orchestrator.go
func (o *OrchestratorActor) Run(payloadJSON string) string {
task := stringVal(parsePayload(payloadJSON), "task", "")
taskID := fmt.Sprintf("orch-%d", host.NowMs())
o.Status = "running"
o.TaskID = taskID
// Discover available agents via process group membership
agentID, err := pgFirst("svc:agent")
if err != nil {
return marshal(map[string]any{"error": "no agents in svc:agent process group"})
}
// Decompose and delegate sub-tasks
subTasks := decompose(task)
for i, subTask := range subTasks {
o.Progress = (i + 1) * 100 / len(subTasks)
result, err := host.Ask(agentID, "chat", map[string]any{
"message": subTask,
"session_id": fmt.Sprintf("orch-%s-%d", taskID, i),
}, 30000)
if err != nil {
return marshal(map[string]any{"error": "sub-task failed: " + err.Error()})
}
// Store result in TupleSpace for aggregation
host.TS().Write([]any{"orch_result", taskID, i, result})
}
o.Status = "completed"
return marshal(map[string]any{"task_id": taskID, "status": "completed", "sub_tasks": len(subTasks)})
}
# PlexSpaces Python — examples/python/apps/minipi/approval_gate.py
@fsm_actor(states=["idle", "awaiting_approval", "approved", "rejected"], initial="idle")
class ApprovalGateActor:
"""
FSM states: idle ? awaiting_approval ? approved / rejected ? idle
Key insight: the agent can wait for days. DurabilityFacet preserves all state
durably — no polling, no timeouts burning tokens.
"""
fsm_state: str = state(default="idle")
pending_request: dict = state(default_factory=dict)
pending_agent_id: str = state(default="")
@handler("request_approval")
def request_approval(self, agent_id: str = "", action: str = "", context: dict = None) -> dict:
"""An agent requests human approval for a high-stakes action."""
if self.fsm_state != "idle":
return {"status": "busy", "current_agent": self.pending_agent_id}
self.fsm_state = "awaiting_approval"
self.pending_agent_id = agent_id
self.pending_request = {"action": action, "context": context or {}, "requested_at_ms": host.now_ms()}
# Store request for external review (dashboard, Slack notification, etc.)
host.kv.put(f"approval_request:{self.actor_id}", json.dumps(self.pending_request))
return {"status": "pending", "gate_id": self.actor_id}
@handler("approve")
def approve(self, approver: str = "", comment: str = "") -> dict:
"""Human approves — signals the suspended agent to resume."""
agent_id = self.pending_agent_id
self.fsm_state = "approved"
self.decision_history.append({
"action": self.pending_request.get("action"),
"decision": "approved",
"approver": approver,
"decided_at_ms": host.now_ms(),
})
# Signal the waiting agent to resume with the decision
host.send(agent_id, "workflow_signal:resume", {
"decision": "approved",
"approver": approver,
"comment": comment,
})
self.fsm_state = "idle"
self.pending_agent_id = ""
return {"status": "approved", "agent_id": agent_id}
@handler("reject")
def reject(self, approver: str = "", reason: str = "") -> dict:
"""Human rejects — signals the agent with the rejection."""
agent_id = self.pending_agent_id
host.send(agent_id, "workflow_signal:resume", {
"decision": "rejected",
"approver": approver,
"reason": reason,
})
self.fsm_state = "idle"
return {"status": "rejected", "agent_id": agent_id}
The agent on the other side calls host.ask("approval_gate", "request_approval", {...}) then processes the workflow_signal:resume message when it arrives. Because state is checkpointed durably, the agent can wait hours or days with no polling loop and no timeout burning tokens.
Long-Running Agents
Cloudflare’s long-running agent pattern uses alarms to wake a dormant agent on a schedule. PlexSpaces handles this identically, e.g., any actor with the ReminderFacet can schedule work arbitrarily far in the future. The ChatAgentActor summarization alarm is one example; for a true long-running loop:
@actor
class ChatAgentActor:
"""Minimal chat agent: conversation in KV, LLM via service link, alarm for summarization."""
actor_id: str = state(default="")
total_messages: int = state(default=0)
total_summarizations: int = state(default=0)
@init_handler
def on_init(self, config: dict) -> None:
self.actor_id = config.get("actor_id", "")
host.info(f"ChatAgentActor init actor_id={self.actor_id}")
@handler("__alarm__")
def on_alarm(self) -> dict:
"""Durable alarm callback — equivalent to Cloudflare Agents SDK onAlarm().
Summarizes conversation history and stores a summary KV key,
then clears history.
"""
host.info("ChatAgentActor: alarm fired — summarizing history")
history = host.kv.get_json("history") or []
if not history:
return {"status": "ok", "action": "no_history_to_summarize"}
# Summarize via LLM
summary_prompt = (
f"Summarize this conversation concisely (2-3 sentences): "
f"{json.dumps([{'role': m['role'], 'content': m['content']} for m in history])}"
)
summary = self._call_llm([{"role": "user", "content": summary_prompt}])
# Persist summary, clear history — equivalent to: storage.put('summary', s); storage.delete('history')
host.kv.put("summary", summary)
host.kv.delete("history")
self.total_summarizations += 1
host.info(f"ChatAgentActor: summarized {len(history)} messages")
return {
"status": "ok",
"action": "summarized",
"messages_summarized": len(history),
}
What PlexSpaces adds beyond Cloudflare’s model: the actor can also be signalled externally at any time via host.send(actorId, "wake_early", {...}) — you’re not limited to the alarm cadence.
Agents Feature Map
Cloudflare Agents SDK
PlexSpaces
this.storage.get/put (conversation history)
host.kv.get_json / host.kv.put_json
env.AI.run(model, messages)
ServiceHttpClient("llm-link").post(...)
storage.setAlarm / onAlarm()
host.alarm.set() / @handler("__alarm__")
connection.send(msg)
host.send(actorId, op, payload)
Workflow checkpointing
WorkflowActor Run/Signal/Query + durable state
Human-in-the-loop / approval gates
@fsm_actor + workflow_signal:resume
Long-running scheduled agents
ReminderFacet + alarm reschedule
Multi-agent orchestration
OrchestratorActor + process groups + TupleSpace
env.AI binding in wrangler.toml
[service_links] in app-config.toml
Cloudflare edge only
Local, Docker, K8s, on-prem, multi-cloud
Part III: Distributed Computation
Cloudflare Workers and Lambda optimize for millisecond, latency-sensitive request handling. PlexSpaces handles a second problem class: large-scale computation across resources that come and go.
ShardGroups: Scatter-Gather Without the Plumbing
For data-parallel and ML-style workloads, PlexSpaces exposes MPI-style collectives directly through the host API:
// Create a pool of 20 workers, hash-partitioned
let pool_id = client.create_worker_pool(
"worker-pool-1", "worker", 20,
PartitionStrategy::Hash, HashMap::new(),
).await?;
// Bulk update: 10,000 messages routed to the right shard by key
client.parallel_update(&pool_id, updates, ConsistencyLevel::Eventual, false).await?;
// Parallel map: query every shard simultaneously
let results = client.parallel_map(&pool_id, json!({ "action": "get_total_count" })).await?;
// Parallel reduce: aggregate stats across all shards
let stats = client.parallel_reduce(
&pool_id, json!({ "action": "stats" }),
ShardGroupAggregationStrategy::Concat, 20,
).await?;
Idle Browsers as Compute Nodes
The mersenne_prime TypeScript example runs a GIMPS-style distributed primality search: browser tabs connect as thin WebSocket clients, receive worker JavaScript from a CodeServerActor, and run Lucas-Lehmer tests inside Web Workers. A CoordinatorActor running as WASM on the server assigns exponents, tracks per-worker CPU cores, and dispatches the next candidate immediately on each result:
// PlexSpaces TypeScript — examples/typescript/apps/mersenne_prime/mersenne_actor.ts
onResult(payload: ResultPayload): unknown {
const item = this.state.work[String(payload?.p)];
item.status = 'done';
item.is_prime = Boolean(payload.is_prime);
item.duration_ms = payload.duration_ms ?? 0;
// Record every completed candidate in TupleSpace and bump a Prometheus counter
host.ts.write(['result', String(payload.p), item.is_prime ? 'true' : 'false',
String(item.duration_ms), payload.actor_id ?? 'unknown']);
host.incrCounter('ts-mersenne-prime', item.is_prime ? 'primes_found' : 'composites_found');
// Immediately hand the same worker the next pending candidate
const next = this._nextPending(this.state.workers[payload.actor_id!]?.cpu_cores ?? 1);
if (next) {
next.status = 'assigned';
host.send(payload.actor_id!, 'assign_work', { p: next.p, done: false });
}
return { ok: true };
}
Open the same URL in ten browser tabs and you have ten compute shards, coordinated by one WASM actor, with zero additional infrastructure.
Comprehensive Feature Map
Feature
Cloudflare DO / Agents
PlexSpaces
Durable KV
ctx.storage.get/put
host.kv.get/put
Batch KV write
storage.put(new Map)
host.kv.multiPut(entries)
Batch KV read
storage.get([keys])
host.kv.multiGet(keys)
Atomic CAS
Manual retry
host.kv.cas(key, expected, new)
Atomic counter
Manual CAS
host.kv.increment(key, delta)
KV TTL
putWithMetadata
host.kv.putWithTtl(key, val, secs)
Durable alarm
storage.setAlarm(ts)
host.alarm.set(ts)
Alarm query
storage.getAlarm()
host.alarm.get()
Alarm cancel
storage.deleteAlarm()
host.alarm.delete()
Alarm callback
async alarm()
on__alarm__() / @handler("__alarm__")
Get-or-create
env.BINDING.get(id)
getActorRef(type, name, ns)
List actors by namespace
Cloudflare REST API
ListActors gRPC / HTTP REST
Init lifecycle
blockConcurrencyWhile
onInit() / @init_handler / Init()
WebSocket (standard)
DO holds socket
Thin-node session actor per connection
WebSocket hibernation
state.acceptWebSocket
Room actor eviction + getState() restore
LLM calls
env.AI.run(model, msgs)
ServiceHttpClient("llm-link").post(...)
Conversation state
this.storage.get('history')
host.kv.get_json("history")
Durable workflows
Cloudflare Workflows
WorkflowActor Run/Signal/Query
Human-in-the-loop
Manual pause / external call
@fsm_actor + workflow_signal:resume
Long-running agents
scheduleAlarm()
ReminderFacet + alarm reschedule
Multi-agent orchestration
Multiple DO fetches
Process groups + TupleSpace coordination
MCP tool integration
McpAgent class
HTTP service link (client-side)
Routing config
wrangler.toml [[bindings]]
app-config.toml [[children]]
Cross-actor fan-out
Individual fetch() calls
host.send() (in-process or mesh)
Fire-and-forget delay
External queue / DO alarm
host.sendAfter(delayMs, op, payload)
Multi-language
TypeScript only
Go, TypeScript, Python, Rust
Local dev
wrangler dev (simulated)
Same binary, full parity
On-prem / self-host
No
Yes
Multi-cloud
No
Yes, via gRPC mesh
Observability
Cloudflare Analytics
Prometheus + OTLP, self-hosted
Webhooks
Worker fetch()
[[http_routes]] in config
Process groups
Not supported
host.pg.broadcast(group, op, payload)
Data-parallel compute
Not supported
ShardGroups (scatter-gather, allreduce)
A couple of things need real rework, not a find-and-replace:
WebSocket architecture. If your DO holds sockets directly today, plan for a thin node that hosts actors.
Bindings vs children. Cloudflare bindings live in wrangler.toml and show up as env properties. PlexSpaces declares the same relationships as supervision children in app-config.toml.
Running Everywhere
The whole point is that development and production run the identical binary:
# Local development — exact production behavior
plexspaces-node start --config app-config.toml
# Docker — same binary, same config
docker run -v $(pwd):/app plexspaces/node start --config /app/app-config.toml
# Kubernetes — same config via Helm
helm install my-app plexspaces/app-chart \
--set config.path=app-config.toml \
--set persistence.storage=postgres
# Multi-cloud — nodes in GCP + AWS joined over a gRPC mesh; actors route transparently
An alarm that fires in production runs through the exact code path you tested on your laptop. There’s no gap to debug between wrangler dev and prod, because there’s only one runtime.
Guild chat (DO migration pattern): examples/{go,typescript,python,rust}/apps/migrating_cloudflare_workers/: ChatRoomActor with member fan-out, a token-bucket RateLimiterActor backed by host.kv.increment and host.kv.cas, and an AlarmDemoActor mirroring DO’s full alarm lifecycle
WebSocket chat room: examples/{typescript,python,go,rust}/apps/ws_chat_room/: ChatRoomActor plus a PresenceActor that uses a durable reminder to detect idle disconnects
AI chat agent (Cloudflare Agents SDK pattern): examples/{go,typescript,python,rust}/apps/chat_agent/: conversation history in KV, LLM calls through service link, durable summarization alarm
Human-in-the-loop approval gate: examples/python/apps/minipi/approval_gate.py: FSM-based approval workflow, workflow_signal:resume handoff, state durable across multi-day waits
Multi-agent orchestration: examples/go/apps/miniclaw/: OrchestratorActor decomposing tasks, delegating via process groups, aggregating through TupleSpace
Durable payment workflow: examples/go/apps/migrating_cadence/payment_workflow.go: WorkflowActor with Run/Signal/Query, idempotent retry, refund and cancel signals
Mersenne prime search (browser compute): examples/typescript/apps/mersenne_prime/: browser tabs as Lucas-Lehmer worker shards, coordinated by a WASM CoordinatorActor
wasmCloud migration: examples/python/apps/migrating_wasmcloud/session_store.py: capability-based session store showing host.kv.list, inter-actor ask, and timer-driven cleanup
Every abstraction in one place: examples/{go,typescript,python,rust}/apps/abstractions/: durable virtual-actor reactivation, workflow run/signal/query, process-group event delivery, timers, reminders, KV, tuple space, and blob storage
Conclusion
The Cloudflare model is the right model: stateful actors, durable storage, alarms, WebSocket session management, LLM calls baked in. Wire’s post makes the same point from the other direction as they’re not leaving because the model is wrong, they’re leaving because specific architectural ceilings came due at their scale. PlexSpaces keeps the model and removes the ceiling: you write the same actors, get the same alarms and durable storage and observability, and the binary that runs on your laptop is the exact binary that runs in production, on any cloud, on-prem, or across all of them at once. The migration from Cloudflare DO code is mostly mechanical. What you get back is the ability to run anywhere, own your infrastructure and never again debug a divergence between wrangler dev and prod.
I have seen some systems never crash, they start cleanly, swallow every error, and keep running no matter what goes wrong. In my experience, they are also the hardest systems to debug, the most dangerous to operate, and the most expensive to maintain. I worked on a similar legacy system for distributed data platform that routed events between hundreds of thousands of nodes. It had zero unhandled exceptions in production. It also had silent authentication failures, invisible data loss, and configuration divergence that took days to diagnose. This is a follow-up to my earlier posts on building an observability platform in Rust, why DRY becomes a liability, and making bad state impossible with ADTs. Those posts were about the type system but this one is about a habit of mind that no type system fixes on its own: the instinct to catch every error with some fallback or default behavior.
The culprit in that system was never a lack of error handling. It was error handling, applied in the wrong places, for the wrong reasons. I call it defensive programming as a religion: every function protects itself against every possible invalid input by inventing a fallback. Missing config? Use a default. Secret unavailable? Generate a random one. Database write failed? Log it and move on. The result is a system that looks healthy on every dashboard while quietly corrupting its own state underneath. In this post, I will walk through the patterns I found, why each one causes more damage than the crash, and what an alternative looks like instead. The whole argument rests on one idea: Every fallback creates a new source of truth, and two sources of truth always drift apart.
I. Why a Fallback Is Worse Than It Looks
It’s tempting to think of a fallback as just “hiding an error.” It’s worse than that. When a function invents a value because the real one is missing, that invented value doesn’t stay hidden, instead it becomes a fact in the system. From that moment on, the system is carrying two truths: the one that should exist but doesn’t, and the one that got made up and does. These two truths never stay in sync and when they drift apart, the failure almost never shows up where the fallback happened. Instead, it shows up somewhere else entirely, in a component with no obvious connection to the code that invented the value. For example, you’ll spend hours in the authentication layer before realizing the signing key was randomly generated at startup by a config migration function three layers away. To be clear about what I mean by “fallback,” I am not talking about:
Validating input at system boundaries: checking what a user typed, sanitizing data from outside. Correct and necessary.
Graceful degradation with an explicit signal: returning a typed Degraded state that the caller can see and react to.
Retrying transient I/O failures with backoff. Standard practice.
I’m talking about code that silently invents state when the real state is missing, and then carries on as if nothing happened. Three things make a fallback harmful:
It hides the root cause. The missing value was the bug. The fallback makes it disappear.
It persists the invented value. Once it’s written to disk or sent over the wire, every future operation has to succeed against a value that was never correct in the first place.
It relocates the symptom. The failure surfaces hours later, in a different component, in a different log file, with no visible thread connecting it back to the startup code that invented the wrong value.
I am not advocating “crash on every error.” It just means: a function that requires X must fail when X is absent and it must never invent X.
II. Postel’s Law
There’s a reason smart engineers build these fallback-heavy systems: they’re following a respected principle: “be conservative in what you send, be liberal in what you accept.” that Jon Postel wrote as guidance for TCP implementations. That advice made a lot of sense in its original context. But this principle leaked out of the protocol layer and turned into a general design philosophy. Engineers started applying “be liberal in what you accept” to function signatures, config loading, and communication between services inside a system they fully control. A function that takes string | undefined and silently substitutes a random value gets called “being robust.” A startup sequence that swallows errors and keeps going gets called “being tolerant.”
Inside your own system, that assumption doesn’t hold. For example, you can fix the sender because you own the caller. But when you own the migration script that’s supposed to write the auth token and you “liberally accept” a missing auth token by inventing a random one instead, you’re not enabling interoperability with an outside party instead you’re hiding a bug in code you wrote. This misapplication creates a ratchet effect. Every “liberal” receiver makes it harder to notice problems at the source. If every function tolerates missing input, the function that’s supposed to supply that input has no pressure to get it right. People also tend to forget that Postel’s Law has a second half: “be conservative in what you send.”
The corrected version for internal systems is this: be strict with components you control, and liberal only at the boundaries where you genuinely can’t fix the sender like external APIs, user input, third-party integrations. Inside your own codebase, strictness isn’t fragility. A function that rejects invalid input tells you exactly where the bug lives.
III. Inventing Values: The Most Dangerous Pattern
This is the category that caused the most damage and I have seen countless bugs due to this anti-pattern. For example, the code generates a random value, assigns it to a security-critical field, and moves on as if that field were properly populated. The invented value becomes a durable fact somewhere and it’s always wrong.
The Archetype: Inventing a Secret
A function runs at startup and writes a signing secret into every worker group’s configuration. Workers and the coordinator use this secret to authenticate each other. If two groups end up with different values, every authentication attempt between them fails silently, showing up as delivery failures instead of auth failures.
// The bug: if authToken is absent, invent a UUID and persist it
const authToken = settings.distributed?.master?.authToken;
const plaintext = authToken != null && authToken.length > 0
? authToken
: randomUUID(); // <-- this line breaks authentication across the cluster
When authToken is missing from the merged settings, which is a perfectly valid state on a fresh install but this function generates a randomUUID() and writes it as the signing secret for every group it touches. Each call produces a different UUID. Meanwhile the token store writes yet another value through a completely different code path. The function reports success for every group it writes to. The symptom 401 errors between nodes shows up minutes or hours later, in worker logs, pointing investigators toward the wrong layer entirely. This is the archetype of the whole problem: if the required input is missing, invent something plausible-looking and keep going. The fix is three lines:
const authToken = settings.distributed?.master?.authToken;
if (authToken == null || authToken.length === 0) {
logger.warn('authToken absent from settings; cannot write signing secrets');
return; // do not proceed; do not invent
}
The “Disabled” Sentinel That Looks Just Like a Real Key
A secret provider has a three-source fallback chain: encrypted store, config file, random bytes. That last fallback is supposed to act as a “poison key” that never validates:
async getSecret(): Promise<string> {
// Source 1: encrypted store
const fromStore = await secretsMgr.get(KEY_ID).catch(() => null);
if (fromStore) return fromStore;
// Source 2: config file (which may itself contain a well-known default!)
const fromFile = settings.distributed?.master?.authToken;
if (fromFile) return fromFile;
// Source 3: "disable" by returning random bytes — looks perfectly valid to the caller
return random(16);
}
The caller gets back a plain string in all three cases. It has no way to tell “a real secret from the store” apart from “a well-known default from the config file” apart from “random bytes that will never work.” It signs a token with whatever string it received and sends it off. This is a type-system failure because the return type Promise<string> squashes three semantically different outcomes into one shape.
The explicit contract fixes this at the type level:
Now a caller that receives unavailable marks itself as degraded. It doesn’t sign tokens and surfaces a health check failure instead.
The Token Renewal That Signs With Random Bytes
The token authenticator calls getSecret(), and if that fails, it falls back to random bytes anyway:
let keyStr = await this.secretProvider.getSecret().catch(() => undefined);
if (keyStr == null) {
this.logger?.debug('Unable to generate a valid token. Disabling.');
keyStr = random(16); // this "signed" token will never be accepted by any peer
}
const token = jwt.sign(payload, keyStr);
this.cachedToken = token; // cached and reused for every future request
The log message says “Disabling,” but nothing gets disabled. The code signs a token with a random key, caches it, and hands it out to every caller for the rest of the process’s life. Workers receive the token, fail to verify it, and log a 401, with nothing to suggest the root cause of the issue.
The explicit contract: if the secret is unavailable, don’t sign anything. Set this.cachedToken = null. Let callers check for null and surface a real health degradation.
When existing is unexpectedly missing, every call generates a brand-new UUID. The user becomes a ghost: every request creates a fresh identity, invisible to deduplication, audit trails, and rate limiters.
The Config Placeholder That Silently Materializes
if (authToken?.token === 'REPLACE_ME') {
authToken.token = uuidv4(); // silent replacement, no log of the value generated
}
This runs at startup and inside a database migration. If the startup write fails, the generated token is gone.
IV. Swallowed Errors
In one legacy codebase I worked had 656 instances of .catch(NOOP), an empty function attached to a promise rejection that turns any error into undefined and lets execution continue. Many of these were on cleanup paths, which is harmless. But a large number sat on critical data paths like durability, metrics transport, authentication state.
The Durability Guarantee That Wasn’t
A persistent queue exists for exactly one reason: to guarantee that events survive a destination outage. That’s the system’s durability promise.
If the flush fails like disk full, I/O error, permission denied, the buffered events are silently gone. The one component whose entire job is preventing data loss is itself a source of silent data loss.
The explicit contract: treat PQ flush errors as fatal to the ingest path. For example, if the flush fails, apply backpressure and pause ingest. Log at error level with the event count at risk and emit a pq_flush_failure metric. Now the operator gets to choose: fix the disk, add capacity, or consciously accept the loss.
Metrics That Vanish
void saasMetrics.sendPacket(packet).catch(NOOP);
Every metrics-send failure is silently swallowed.Dashboards go blank, and nobody knows why.
The Config Load That Treats Corruption as “Empty”
const groups = await conf.loadSystem('internal-groups').catch(() => ({}));
One line here quietly conflates two very different situations:
“The file doesn’t exist yet”, which is normal on first boot –> return {}
“The file is corrupt, or a parse error, or permission was denied”, which is a real configuration bug –> also return {}
Either way, the loop over groups never runs. The operator has no way to tell “healthy, no groups configured yet” apart from “broken, groups exist but couldn’t be read.”
Startup That Succeeds Despite Total Failure
export async function syncGroupSecrets(conf: Configuration): Promise<void> {
try {
// ... write secrets to all groups ...
} catch (err) {
logger.error('failed to sync secrets', { reason: err });
// swallowed — startup continues, caller receives no signal
}
}
The function catches everything at the top and resolves successfully no matter what. The caller awaits it and gets no signal that anything went wrong. If the sync fails for every group, the coordinator still starts, workers still connect, and authentication fails across the board but startup “succeeded.”
V. Defensive Defaults
This next category is more subtle. Instead of inventing a random value, the system injects a well-known one like a default credential, a default address, which makes it impossible for downstream code to tell that anything is missing at all.
The Well-Known Default Credential
The shared authentication token has a configuration setting, and by default, the settings loader injects a well-known string whenever nothing is configured:
Any caller of getSettings() receives a truthy string for authToken. Code that checks if (authToken) proceeds as if a real token exists. The check passes. Authentication moves forward, using a credential that’s sitting right there in the source cod and every deployment that forgot to override it. The default here is opt-out, not opt-in. Every new call site has to remember to disable the injection.
Workers That Connect to localhost on Config Failure
When a worker fails to load its coordinator address, it silently falls back to localhost:5555 with the default token. On a single-node dev box, this might accidentally work. On a production multi-node deployment, the worker ends up connecting to itself. The symptom looks like a connection timeout, not a config-load failure.
The explicit contract: if conf.distributed.master is missing, throw. A worker cannot function without a coordinator address, and there is no valid default for it. Failing here with a clear message like “coordinator address not configured; set MASTER_URL or add distributed.master to instance.yml“.
VI. Multiple Sources of Truth
This is the pattern that ties everything else together. Every fallback chain creates more than one source of truth and any source of truth that isn’t explicitly designated as the source will eventually drift from the others. In a distributed system, that drift shows up as the hardest class of bug like intermittent or state-dependent failures.
Two Functions, One Secret, Two Different Sources
The bug that inspired this post exists because two functions write the same signing secret to group configs, but read the plaintext from two different physical sources:
Function
Reads from
Runs when
syncAllGroupSecrets
Config file (instance.yml)
Startup for every group
syncNewGroupSecret
Encrypted token store
Group creation for one group
A migration populates the token store before syncAllGroupSecrets runs at boot, so the store is meant to be authoritative. But syncAllGroupSecrets predates the store’s existence and still reads straight from the config file. These two sources drift apart after a token rotation or some race condition.
The explicit contract: one source of truth. Both functions read from the store. If the store is unavailable, both fail instead of silent fallback to a secondary source.
The Three-Source Chain Is Three Sources of Truth
Source 1: Encrypted store (authoritative)
? (unavailable ? silently falls through)
Source 2: Config file (may hold a stale or default value)
? (absent ? silently falls through)
Source 3: Random bytes (structurally valid, semantically useless)
Each fallback quietly downgrades the security posture, and the caller gets back a plain string with no idea which source it came from.
Three branches go into the same sign() call. Only one of them should ever be allowed to reach it, which is the whole argument for making unavailable its own explicit type instead of letting all three collapse into a plain string.
The Cache Nobody Fully Trusts
The settings system keeps a cache for high-availability mode. Some callers pass skipCache: true to bypass it; others don’t, and there’s no documented rule for which is which. This gives the system two sources of truth for the same data: the cache and the disk. If the config file changes between startup and an API call, the API may serve stale data. The skipCache escape hatch is a symptom, not a fix. It means someone stopped trusting the cache’s invalidation and punched a hole through it instead of repairing the underlying mechanism.
The explicit contract: the cache invalidates on every write. Callers never need to know or care whether they’re reading from cache or disk. Remove skipCache as a public option entirely.
VII. Redundant Guards
When a function doesn’t trust its caller’s preconditions, it adds its own guard on top:
// Caller (server.ts):
if (isLeader && featureFlags.check('AUTH_TOKEN_MGMT')) {
await syncGroupSecrets(conf);
}
// Callee (syncGroupSecrets):
export async function syncGroupSecrets(conf: Configuration): Promise<void> {
if (!isFreeTier() && !isRunningInSaaS()) return; // guard 1
if (!Product.isLeader(settings.distributed?.mode)) return; // guard 2 (redundant!)
if (!featureFlags.check('AUTH_TOKEN_MGMT')) return; // guard 3 (redundant!)
// ... actual work ...
}
This function lives in a directory named leader/ and is only ever called from the leader startup path, yet it re-checks isLeader internally anyway. The feature flag gets checked at the call site and again inside the function. Three layers of defense against calling this function in the wrong context.
The checks themselves aren’t the problem. It’s what happens when they trip: nothing. The function returns quietly, and the caller gets no signal either way.
The explicit contract: a function either does its job or throws. Preconditions get asserted, not silently absorbed. If the caller already guarantees the precondition, drop the internal check. If the function really can be called from multiple contexts and some of them are invalid, throw on the invalid ones instead of quietly returning.
VIII. “Best-Effort” Writes to State That Isn’t Optional
The most seductive justification for swallowing an error is: “the primary operation already succeeded so we don’t want a secondary failure to undo it.” That reasoning is correct in isolation and catastrophic in aggregate.
The Store Upsert That Swallows Its Own Failure
export async function mirrorTokenToStore(plaintext: string): Promise<void> {
try {
await store.upsert({ id: LEGACY_TOKEN_ID, token: encrypt(plaintext) });
} catch (err) {
logger.error('failed to mirror token to store', { reason: err });
// swallowed — the caller sees success
}
}
The comment above this function explains the intent: it’s “best-effort” so that a store-side failure doesn’t roll back a config file write that already succeeded. That reasoning holds up on its own but config file is now permanently out of sync.
The explicit contract: add reconciliation. On startup, compare the store’s token to the config file’s. If they differ, update the store. Emit a token_store_divergence counter and surface it in a health check similar to reconciliation loops in Kubernetes.
The comment in this code says: operations get removed from the cache regardless of whether the transaction succeeds. But the fix for “one bad operation might stall the queue” ends up being “discard the entire batch, including the good operations.” That isn’t a tradeoff instead it’s silent data loss.
The explicit contract: only remove items from the cache after a confirmed commit. Isolate the failing operation into a dead-letter queue and retry the rest of the batch without it.
The Config Reload Nobody Acknowledges
await conf.triggerReload().catch(NOOP); // worker continues with old config
After receiving a new config bundle from the coordinator, a worker triggers a reload. If that reload fails, the worker just keeps running the old config. The two sides now disagree about what the worker is actually running.
The explicit contract: report a reload failure back to the coordinator on the next heartbeat. The coordinator marks that worker as “stale config” and can retry or alert. This is how Kubernetes rolling updates work, the controller notices and either retries or halts the rollout.
The Package Install That Saves Despite Partial Failure
for (const op of ops) {
try {
switch (op.type) {
case 'install': await installPackage(op); break;
case 'uninstall': await uninstallPackage(op); break;
}
status.applied.push(op);
} catch (error) {
errors.push(error);
}
}
await this.save(packageManifest); // saves regardless of how many failed
If three out of five packages install and two fail, the manifest still gets saved with those three. Next startup tries again from this partial state but the failed packages may have left behind lock files or half-written artifacts causing conflicts.
The explicit contract: validate that every operation can succeed before running any of them (a dry-run pass). Execute atomically (ACID transactions).
X. Silent Truncation With No Backpressure
The Metrics Buffer That Silently Drops
Workers piggyback metrics onto heartbeat messages sent to the coordinator, with a hard cap of 100,000 packets. Past that cap, excess metrics are silently dropped without any counter, logs or metrics. This is a nasty failure mode specifically because absence of data is itself meaningful data and silent truncation destroys that signal’s reliability.
The explicit contract: when the buffer nears capacity, reduce granularity instead of dropping outright. When truncation does happen, include metrics_truncated: N in the heartbeat so the coordinator knows its picture is incomplete. Better, instead of piggyback metrics on heartbeats at all, give them their own transport.
The TCP Sender That Zeroes Buffers on Disconnect
// On disconnect: all in-transit events silently lost
this.inTransitBufs = [];
this.bufOffset = 0;
this.bufferEventCount = 0;
this.dropBytes += len; // only evidence: a counter increment
On a TCP disconnect, every in-transit event gets zeroed out. The only trace left behind is a dropBytes counter buried in internal metrics. Compare that to Kafka’s producer, where unacknowledged messages stay in the producer’s buffer and get retried on reconnect.
The Unbounded Queue That Becomes an OOM
protected queueBatch(): void {
this.queuedBatches.push({ eventCount, eventsSize, events: this.currentBatch });
// NO CHECK on length, size, or memory pressure
}
When the output destination is unreachable, failed batches get re-queued, and the queue grows without any bound. Memory climbs until the OOM killer steps in and terminates the process. An unbounded in-memory queue isn’t really a data structure, instead it’s a deferred OOM crash.
The explicit contract: bound the queue. Once it’s full, either apply backpressure to ingest, spill overflow to the persistent queue, or trip a circuit breaker that rejects new events with a typed error. Let the pipeline decide from there: drop, buffer to disk, or pause the source.
The failover lease file, the mechanism that’s supposed to guarantee only one coordinator is ever active is written directly with writeFile. On NFS, which is where this system runs in HA mode, writes aren’t atomic. A power failure mid-write leaves behind a truncated or corrupt file. The standby coordinator reads that corrupt lease, fails to parse it, and ends up in an undefined state.
The explicit contract: write to a temp file, fsync, then atomically rename it into place, with a checksum so readers can detect corruption. Every real database does this like SQLite’s WAL.
The Multi-File Config Deploy Without a Journal
Config deployment writes several YAML files in sequence like inputs.yml, outputs.yml, pipelines.yml,, etc. A crash midway through leaves the worker with a partial config and a worker that restarts after a partial deploy loads that inconsistent config.
The explicit contract: write every file to a staging directory first, verify that everything references correctly, then swap atomically like rename the directory, or flip a symlink. This is the same idea behind Docker image layers, Kubernetes ConfigMaps, and Nix store paths.
XII. Six Principles That Cover All of This
Every pattern above breaks one of six well-established principles. They’re standard practice in any system that prioritizes correctness over the appearance of uptime.
1. Fail fast at trust boundaries (Erlang’s “let it crash.”): When a precondition is violated, fail immediately and loudly. Erlang runs telecom infrastructure at 99.9999999% uptime on a philosophy of letting individual processes crash and having a supervisor restart them into known-good state.
2. Make invalid states unrepresentable: If getSecret() can return random(16) as a plain string then every caller is stuck defensively guessing whether it’s “real.” If it returns Secret | Disabled as a discriminated union instead then the type system forces every caller to handle both cases at compile time. I wrote about this pattern in “Making Bad State Impossible: A Practical Guide to ADTs and Algebraic Effects.”
3. Classify errors (transient vs. fatal): Without classification, every catch block faces an impossible choice: rethrow and break “resilience,” or swallow and hide a real bug. For example, gRPC solves this with status codes like UNAVAILABLE means retry, INVALID_ARGUMENT means don’t, INTERNAL means there’s a bug.
4. Define delivery semantics for critical state: “Fire-and-forget” is fine for debug logs. It’s not fine for persistent queue flushes, token store upserts, or config reloads. If an operation mutates state that downstream code assumes succeeded, it needs at-least-once semantics.
5. One source of truth without fallback chains for critical state: For any given piece of state, there should be exactly one authoritative source. A fallback chain isn’t graceful degradation instead it’s an implicit decision that secondary sources are acceptable substitutes for the truth. If that’s genuinely acceptable, make it explicit with TTLs, version vectors, or consistency levels.
6. Atomic state transitions: State changes should be all-or-nothing: temp file, fsync, atomic rename for files; transactions with rollback for databases; staging plus swap for multi-file deployments.
XIII. Cognitive Load
All this conditional logic and fallback behavior creates the cognitive load, e.g., when any function might silently invent a value, you can’t trust a function’s output without reading its implementation. When errors are swallowed, a successful await no longer means the operation actually succeeded. When defaults get injected into config reads, a non-null value stops meaning “configured.”
Debugging a production incident in a system like this means reading every function in the call chain, understanding every fallback along the way, and reconstructing which code path actually ran. crash tells you exactly where and when an invariant broke. New engineers ask why workers sometimes fail to authenticate after a token rotation, and the honest answer involves reading six functions across four files, understanding a three-source fallback chain. Compare that to: “the store upsert threw on failure, the rotation API returned a 500, the operator re-ran it, it worked.
XIV. Conclusion
Defensive programming isn’t inherently wrong, and neither is Postel’s Law like at the boundary it was designed for. Validating input at system boundaries, handling I/O errors gracefully, protecting against malformed external data are correct applications of defensive thinking. The problem shows up when the same techniques get applied to internal code, inside a system where you control both ends of every interface. The alternative, in short:
Throw Error when a required state that’s missing: The function refuses to proceed and the caller finds out immediately.
Explicit return when an optional state that’s missing:null, undefined, Option<T>, a discriminated union.
Transient failures = retry with backoff: Never .catch(NOOP).
One source of truth per piece of state: Not a fallback chain that quietly degrades. Not a cache with no real invalidation.
Bounded queues with backpressure: Not unbounded buffers waiting to OOM.
Atomic state transitions: Not multi-step operations that can half-finish.
Reconciliation loops for distributed state: Not one-shot “best-effort” writes that quietly drift apart.
This mud didn’t accumulate overnight, and it won’t disappear overnight either. But every fallback you remove, every error you refuse to swallow will makes the next incident roughly ten times faster to diagnose.
You may start with a simple agent for demo that calls a tool, gets an answer, prints it. But building a production ready agentic system requires a full agent harness so that it doesn’t crash halfway through a task. For example, you might have an AI coding assistant that debugs production incidents by searching logs, isolating a root cause, creating a pull request for the fix. It might take several minutes with dozens of tool calls. The AI agent may crash mid-run, fail to call a tool reliably or the test suite for eval fails. These are not model problems that you can solve with a better model or a better prompt. Instead, you need a reliable infrastructure that an agent harness provides. This post shows how to build an agent harness and eval pipeline using PlexSpaces, a polyglot actor framework that treats agent infrastructure as a first-class problem instead of an afterthought.
Agent = model + harness
You can think of an agent as model + harness. The harness is everything that isn’t the model.
The harness is the loop that decides when to stop, the tool calling that connects the model to the world, the state that survives a crash and resumes where it left off, the coordination that lets multiple agents share work, and the eval plumbing that tells you whether any of it actually worked. Most teams spend their time on the model like a different temperature here, a different prompt there, a bigger model if budget allows. I have seen teams build a prototype agentic system and then ship it to an entire organization without proper harness resulting in unexpected failures. The harness stays invisible until it breaks, and when it breaks, it looks exactly like a model problem.
There are three levers that move agent quality. Model changes are the most expensive like fine-tuning, RL, moving to a bigger model. Harness changes are nearly free like loop logic, tool schemas, retry policies, agent topology. Memory changes are the cheapest like context window management, retrieval strategy. Teams reach for the model first. They should usually start with the harness.
What the harness actually has to do
Eight responsibilities show up in every serious agent deployment, in every framework, in every language. The only question is whether you build them on purpose or accumulate them by accident after the third production incident.
Harness property
What it does
PlexSpaces primitive
Loop control
Iteration limits, token budget, stop conditions
AgentLoop (max_iterations, token_budget)
Tool calling
Dispatch, schema validation, error capture
ToolRegistryActor + SchemaValidationFacet
State management
Survives crashes, resumes from checkpoint
DurabilityFacet (journal replay)
Memory
Prior context per agent, per run
KV store (host.kv_get / host.kv_put)
Multi-agent coordination
Fan out work, collect results without tight coupling
TupleSpace (write tuple, match pattern)
Supervision
A subagent crash doesn’t take down the orchestrator
Supervision tree (one_for_one)
Observability
Every step captured and queryable mid-run
ExecutionTraceFacet
Eval plumbing
Trajectories –> scores –> regression detection
EvalRunnerActor, ScorerActor
Every one of these is a solved problem in actor frameworks generally. PlexSpaces just wires them together for agent workloads specifically.
Why the actor model fits this problem
The actor model was designed for systems that keep running when individual components fail, which happens to be exactly the property a multi-agent pipeline needs. Each actor is an isolated unit of state and behavior. Actors talk only through messages without sharing memory. There’s no global state, and no way for one actor to corrupt another actor’s state directly.
This lines up with what distributed systems theory already tells us. The FLP theorem says that in a distributed system where even one failure is possible, you cannot guarantee both safety and liveness without explicit coordination. The actor model handles this by making failure a first-class citizen: actors crash, supervisors restart them, the system keeps running. It’s the same design that let Erlang run telecom systems for five nines of uptime.
For agent systems, three consequences follow directly:
Crash isolation. When an AgentActor fails mid-eval, only that actor restarts. EvalRunnerActor and every other running agent keep going. In thread-per-agent or future-based systems, a crash in one agent typically propagates up and takes the rest down with it.
No shared-state corruption. Agents talk through messages and TupleSpace, not shared memory, so they can’t overwrite each other’s context. A hallucinating agent writing garbage stays contained to itself.
Journal replay without application code. The durability journal lives at the framework level, below your actor’s code. You don’t implement checkpointing yourself and the framework journals every message before the actor runs it.
The building blocks
Following PlexSpaces primitives do all the work in this harness.
Actors are the basic unit. Each one owns its state and handles messages one at a time. Actors talk by sending messages and you never reach into another actor’s state directly.
GenServer is a request-reply actor: send it a message, it processes and replies. LLMGatewayActor, ScorerActor, and DashboardActor are all GenServers.
WorkflowActor is a durable workflow. It checkpoints its state before each step, and if the process crashes, it replays from the last checkpoint on restart without application code. EvalRunnerActor and BenchmarkActor are WorkflowActors.
GenFSM is a state machine actor: you define states and transitions, and the state persists across crashes. ApprovalGateActor (human-in-the-loop) is a GenFSM.
Facets are cross-cutting behaviors you attach to any actor without touching its code. You can think them as middleware, but declared in app-config.toml instead of written in application code. Three facets carry the harness:
SchemaValidationFacet validates tool call arguments against JSON Schema before the actor ever sees the message.
DurabilityFacet journals every message before your actor’s code runs, so a crash-and-restart wakes up the actor with exactly the state it had.
ExecutionTraceFacet records every step in order and exports the full trace to KV storage when a workflow completes, which is what feeds eval.
Supervision trees enforce fault isolation. You declare a tree of actors and a restart strategy; one_for_one means one crash restarts only that actor, while the orchestrator and every sibling agent keep running. In LangGraph or CrewAI, a crash typically takes down the whole graph.
TupleSpace is a shared blackboard for multi-agent coordination, built on the Linda coordination model. Actors write tuples (["trajectory", run_id, data]) and read them by pattern (["trajectory", run_id, nil]). Producer and consumer stay decoupled without polling or sharing state.
Two loops, two owners
There’s a useful mental model for agent systems: two concentric loops, with different owners.
The inner loop is the agent trying to accomplish the task: investigate, implement, test, report. The outer loop is the engineer deciding whether the agent’s output deserves trust: decide, verify, approve, own. The harness sits at the boundary. It’s where agent output turns into evidence like diffs, test results, trajectories, scores that the engineer can actually inspect before deciding to approve, redirect, or block.
This framing matters for eval specifically because eval tooling that scores only the final answer misses most of what’s happening. An agent that stumbles onto the right answer through a wrong path scores fine on outcome-only eval, then fails the moment the task shifts slightly. What you actually want to evaluate is the trajectory or the path, not just the destination.
Why pass@k beats pass/fail
Agents are non-deterministic. For example, the same task, same model, same harness will succeed sometimes and fail other times. A single binary pass/fail on one run gives you noise, not signal. The right metric is pass@k: run the same scenario k times and count how many succeed. A score of 0.9 means the agent solved it 9 out of 10 tries. This is well established in code-generation benchmarks like SWE-bench, HumanEval, and MBPP. The same logic carries over to agent harnesses: you need pass@k across your own task distribution, not a one-shot score you happened to get lucky on. Comparing pass@k between two harness configurations gives you real evidence about which one is more reliable, without touching the model at all.
ScorerActor produces the 0–1 signal pass@k needs, using rubric-based scoring:
Two rubrics run against the same trajectory. task_completion asks whether the agent reached the goal. tool_use asks whether it used the right tools in the right order. These can diverge, e.g., an agent might complete a task through a lucky shortcut that would fail on a harder variant. The trajectory rubric catches that divergence; the outcome-only rubric never sees it.
In-runtime eval versus post-hoc eval tools
The popular eval tools like LangSmith, DeepEval, Braintrust, Phoenix/Arize work the same way: run the agent, export traces or logs, evaluate afterward. That model has a structural flaw: eval doesn’t run in the same environment as production. Agent configuration, tool schemas, retry logic, and context management can all drift between the eval harness and the production deploy. When eval passes and production fails, there’s no way to tell whether the failure is in the model or just in the mismatch between the two setups.
MiniPi’s eval runs inside the same PlexSpaces node, against the same actors, with the same tool schemas, under the same supervision tree as production. EvalRunnerActor isn’t a separate process logging to an external service, instead it’s an actor in the same supervision tree as the AgentActor it’s testing. If you change the schema in production, and eval will pick it up automatically, because it’s the same schema.
That also makes eval a first-class durable workflow instead of a batch job. EvalRunnerActor is a WorkflowActor with DurabilityFacet attached. Kill it mid-suite, restart it, and it resumes from the last checkpoint, skipping every scenario already scored. Long eval suites survive node restarts, which is a property that no external eval tool offers.
MiniPi: the example
MiniPi is a 12-actor agent eval pipeline, ported five ways: Go, Python, TypeScript, Rust WASM, and Rust embedded. They all produce the same output against the same PlexSpaces node:
All 12 are declared in app-config.toml under a one_for_one supervision strategy. The framework starts them, watches them, and restarts individual actors on crash. Here’s how they connect. Notice there’s no separate “eval environment” bolted on the side and EvalRunnerActor calls the exact same AgentActor that production traffic calls:
You can swap the debugging assistant for a support-ticket triager, a claims processor, or a code-review bot, and the same 12 actors still apply, only ScenarioStoreActor‘s scenarios and the tool schemas change.
The OODA loop
The agent itself is an AgentActor, a WorkflowActor running an OODA loop (Observe, Orient, Decide, Act).
Here’s the core loop, from the Go implementation:
// agent.go — the OODA loop
// DurabilityFacet (priority 90) journals every message before this code runs.
// Kill the process mid-loop. Restart. It picks up from the last checkpoint.
for !loop.IterationLimitReached() {
if loop.BudgetExceeded() {
// Over token budget — finalize trajectory and return cleanly
traj := loop.FinalizeTrajectory("budget_exceeded", iterations)
a.exportTrajectory(traj)
return result("budget_exceeded", traj)
}
// OBSERVE: load prior context from KV memory
observations := a.doObserve(loop, task)
// ORIENT: ask LLM gateway what to do next
// LLM gateway tries Ollama first, falls back to mock, caches in KV
plan := a.doOrient(loop, observations)
// DECIDE: pick action. Does it need human approval?
action := a.doDecide(loop, plan)
if needsApproval(action) {
loop.Suspend("action_needs_approval")
return result("suspended", nil)
}
// ACT: run the tool through ToolRegistryActor
// SchemaValidationFacet (priority 95) validates the call before the tool runs
a.doAct(loop, action)
loop.IncrementIteration()
}
traj := loop.FinalizeTrajectory("completed", iterations)
a.exportTrajectory(traj) // writes to KV + posts TupleSpace tuple for eval collection
Four things happen here that you’d otherwise have to build by hand:
Crash recovery is automatic.DurabilityFacet journals each message before the actor runs. Kill the node at iteration 7, restart it, and the loop resumes at iteration 8 without re-burning tokens.
Budget enforcement lives in AgentLoop. It counts tokens across every LLM call and stops the loop before you overspend.
Trajectory capture happens in exportTrajectory. Every Observe/Orient/Decide/Act step gets recorded with timing and token counts, written to KV storage, and posted as a TupleSpace tuple so EvalRunnerActor can find it.
Human approval is a durable suspend, not a poll. The agent serializes its full state and returns. ApprovalGateActor holds the request. When a human approves, the signal resumes the agent exactly where it paused even in the middle of a multi-hour run.
The durability property is worth slowing down on, because it’s categorically different from checkpointing you write yourself. When DurabilityFacet journals a message, it does so at the actor framework level, below your code. On restart, the journal replays those messages and your actor’s state comes back exactly as it was without application code to handle “resume from crash”.
There are a few differences compared to Temporal, which also relies on replay. First, Temporal requires you to write workflow code as a deterministic function that can be safely replayed; PlexSpaces lets the actor’s message handling look like ordinary code, because the framework journals at the message boundary instead. Second, Temporal has no supervision tree so a crashing activity gets retried by the workflow, but nothing independently restarts just that piece while the rest keeps running. In PlexSpaces, one_for_one means a crashed AgentActor on scenario 3 restarts in isolation while EvalRunnerActor, ScorerActor, and every other scenario agent keep going.
In LangGraph or CrewAI, one agent crashing typically kills the whole graph. Temporal can be made to handle this, but it takes explicit error-handling code. In PlexSpaces, independent crash isolation is just the default. For example, you might have have 20 tool calls into a host that gets OOM-killed. With DurabilityFacet, the node restarts, the journal replays, and the agent picks up at tool call 21.
Validating tool calls without touching agent code
Agents call tools with malformed arguments, which is unavoidable because models make mistakes. The real question is where you catch it. Catching it inside the tool handler is too late; execution has already started, and now you’re cleaning up a half-run call.
SchemaValidationFacet catches it before the actor sees the message at all. An empty web_search query never reaches the tool registry because the facet returns a structured error, the agent corrects the call, and retries. The schema itself lives in app-config.toml, not in code:
Nothing about the tool actor’s code changes. The guardrail lives entirely in configuration.
Step 6: SchemaValidationFacet — reject invalid method input
reject empty query
Schema validation: REJECTED (before actor sees it)
Error contains: validation or schema
valid call still works
Valid call accepted: "web_search" executed successfully
For example, you might have a billing support agent with a refund_customer tool. The model occasionally hallucinates a negative amount, a missing currency code, or an order ID that isn’t a string. Without a facet catching this, that call reaches your payments system and either throws an ugly stack trace or, worse, silently coerces bad input. With the schema in app-config.toml, the malformed call never leaves the tool registry. Instead, it bounces back to the agent as a structured error it can correct on the next turn, and your payments code never has to defend against it.
Eval, running in the same runtime as production
EvalRunnerActor is a WorkflowActor. It fans out one AgentActor per scenario, collects trajectories through TupleSpace, and scores them.
// eval_runner.go — fan out and collect
for i, scenario := range scenarios {
// Spawn a fresh AgentActor for each scenario
agentID := fmt.Sprintf("eval-agent-%s-%d", evalRunID, i)
spawnedID, _ := host.Spawn("minipi_wasm", agentID, "agent_runner", map[string]string{
"eval_run_id": evalRunID,
"scenario_id": scenario.ID,
})
// Run the agent — same OODA loop as production
resp, _ := host.Ask(spawnedID, "workflow_run", map[string]any{
"task": scenario.Input,
"eval_run_id": evalRunID,
}, 60000)
// Collect trajectory directly from response
if traj, ok := resp["trajectory"]; ok {
trajectories = append(trajectories, traj)
}
}
// Score each trajectory against the scenario's rubric
for _, traj := range trajectories {
score, _ := host.Ask("scorer", "score", map[string]any{
"trajectory": traj,
"rubric": scenario.Rubric,
}, 10000)
scores = append(scores, score)
}
Because EvalRunnerActor is a WorkflowActor, killing it mid-eval is safe. Restart it and it skips scenarios that already finished. Long eval suites survive node restarts without losing progress. Real output from a 5-scenario run (Go):
The TypeScript port tracks real token cost per scenario:
Step 10: EvalRunnerActor — 5-scenario standard suite
Pass rate: 0.4 Avg score: 0.818 Completed: 5 / 5
Tokens: 311 in / 223 out (est. cost: $0.00018)
sc-math-01 0.92 (53 in / 41 out)
sc-search-01 0.92 (63 in / 45 out)
sc-calc-01 0.76 (60 in / 44 out)
sc-reason-01 0.79 (62 in / 44 out)
sc-budget-01 0.70 (73 in / 49 out)
coord_overhead is the harness’s own overhead like spawning agents, collecting via TupleSpace, scoring. Across a 5-agent parallel run, it stays flat while compute scales, which is exactly the property you want: harness cost shouldn’t grow with the number of agents. For example, a legal team may need to run a contract review nightly across 200 incoming documents. Each document gets its own AgentActor, spawned by EvalRunnerActor the same way scenarios are spawned here. Because coordination happens through TupleSpace instead of a shared in-memory queue, one document’s agent hanging on a malformed PDF doesn’t block the other 199 and the batch survives a restart if the node needs to redeploy halfway through the night.
Regression detection
A single eval score doesn’t tell you much on its own. What matters is whether it’s better or worse than last time. RegressionDetectorActor stores baseline scores and flags any scenario that drops more than 5%:
Step 11: RegressionDetectorActor
set_baseline
Baseline set from eval-smoke-001 actual scores
compare
Regressions: 1 (sc-search-01 degraded by 0.20)
Improvements: 1 (sc-reason-01 improved by 0.05)
Regression detector caught degradation in search scenario
The search scenario dropped by 20-point regression that would be invisible if the only thing you watched was the aggregate pass rate.
Benchmarking harness configs, not just models
The most underused insight in agent engineering: harness changes are often cheaper and more impactful than model changes. BenchmarkActor runs the same scenarios against multiple harness configurations. Here’s the Python implementation:
# benchmark.py
@handler("run_benchmark")
def run_benchmark(self, scenario_suite: str = "smoke", configs: list = None) -> dict:
results = []
for config in (configs or self._default_configs()):
# Run a full eval with this config
eval_result = host.ask("eval_runner", {
"action": "run_suite",
"suite": scenario_suite,
"eval_run_id": f"bench-{config['name']}",
"agent_config": config,
}, timeout_ms=120000)
results.append({
"config": config["name"],
"score": eval_result.get("avg_score", 0),
"pass_rate": eval_result.get("pass_rate", 0),
"tokens": config.get("token_budget", 0),
"max_iter": config.get("max_iterations", 0),
})
winner = max(results, key=lambda r: r["score"])
return {"configs": results, "winner": winner["config"]}
Output from the Rust WASM port, on harder multi-step scenarios:
Same model, same scenarios, same prompt but the harness config alone moves quality by 14%. That’s the case for measuring this before reaching for a bigger model. The Python port, on simpler scenarios, tells a different story:
Step 12: BenchmarkActor — 3-config comparison
Configs tested: 3 Winner: conservative Best score: 0.7
conservative [XXXXXXX---] score=0.700 budget=1024tok max_iter=3
balanced [XXXXXXX---] score=0.700 budget=4096tok max_iter=10
aggressive [XXXXXXX---] score=0.700 budget=8192tok max_iter=20
(on simple arithmetic tasks, all configs tie — benchmark your actual tasks)
On simple arithmetic, budget doesn’t matter, the task fits in 3 iterations no matter what you give it. On multi-step research tasks, the loop limit starts to bite. Benchmark your own workload rather than trusting either result blindly. For example, a content-moderation team may need to decide how much iteration budget to give a policy-review agent. A conservative config (low budget, few iterations) is cheap but might miss nuance in a borderline post. An aggressive config catches more edge cases but costs more per review. BenchmarkActor runs last month’s flagged-content scenarios against both configs and reports the actual quality delta.
Two-tier LLM: the advisor pattern
Not every turn of the OODA loop needs the expensive model. Most turns are routine; only a handful demand deep reasoning. AdvisorActor implements a two-tier pattern: a fast, cheap model handles everything by default, and escalates to the expensive model only when its own confidence drops below a threshold.
# advisor.py
@handler("advise")
def advise(self, prompt: str = "", context: dict = None) -> dict:
# Always try the cheap model first
fast_result = self._call_executor(prompt, context)
self.total_requests += 1
if fast_result.get("confidence", 1.0) >= self.confidence_threshold:
# Confident enough — return fast result
return fast_result
# Low confidence — escalate to expensive advisor
self.escalated += 1
self.advisor_tokens += fast_result.get("tokens", 0)
advisor_result = self._call_advisor(prompt, context, fast_result)
self.advisor_tokens += advisor_result.get("tokens", 0)
return advisor_result
Rust, with harder prompts and a 60% escalation rate:
The two metrics that matter are escalation rate and advisor token share. Run your eval suite at threshold 0.9, then 0.7, then 0.5, and feed each result into BenchmarkActor. That’s how you find where the quality/cost tradeoff actually sits for your own tasks, instead of guessing. For example, a support-ticket classifier handling 10,000 tickets a day. Most are routine like “reset my password,” “where’s my order” and a cheap model nails them at near-100% confidence. The 5–10% that involve conflicting account details or ambiguous intent escalate to the expensive advisor. Routing every ticket through the expensive model would be needlessly costly; routing none of them through it would tank accuracy on the hard cases. The advisor pattern gets you both.
Human-in-the-loop, without polling
High-stakes actions need approval before they execute. The naive approach polls a status endpoint, which holds resources open, doesn’t survive a restart, and forces the agent to stay running the whole time. ApprovalGateActor is a GenFSM: idle –> awaiting_approval –> idle. The state is durable, e.g., kill the node while a request is pending, restart it, and the request is still there because the FSM state was journaled before the crash ever happened.
The agent never polls. It calls loop.Suspend(), serializes its state, and returns. When approval comes through, the PlexSpaces runtime sends a resume signal, and the agent wakes up exactly where it paused without loss of state or rerun. For example, your debugging assistant runs 30 tool calls, identifies the root cause, and proposes a deploy. At the deploy_to_production call, it suspends. An on-call engineer reviews the trajectory in the dashboard and clicks approve. The agent resumes, and only the deployment step runs.
The aggregate view
After several eval runs, DashboardActor rolls everything up — scores, pass rates, trends over time:
Four runs in this session: two smoke evals, one benchmark, one direct test. test-999 at 0.880 is a single high-confidence call. The smoke runs sit at 0.730–0.760, dragged down by the harder search scenario that consistently scores 0.40.
How PlexSpaces compares
Feature
LangGraph
AutoGen
CrewAI
Restate
Temporal
PlexSpaces
Crash recovery
No
No
No
Journal replay
Journal replay
Journal replay
Supervision trees
No
No
No
No
No
Yes (one_for_one, one_for_all, rest_for_one)
Eval in same runtime
No (LangSmith)
No
No
No
No
Yes (same actors, same facets)
Tool schema validation
App code
App code
App code
App code
App code
SchemaValidationFacet (config only)
Human-in-the-loop
Interrupt
No native support
No native support
Signal
Signal
GenFSM (durable state)
Polyglot
Python
Python
Python
TS/Java/Python/Go/Rust
TS/Java/Python/Go
Go/Python/TS/Rust via WASM
Multi-agent coordination
Graph edges
Shared memory
Role handoff
Keyed state
Workflow steps
TupleSpace (Linda model)
WASM sandboxing
No
No
No
No
No
Yes
What MiniPi tests covers
Each language port runs the same 15-step integration test. Every step exercises a production pattern, not a mock shortcut:
Step
What it tests
Key metric
1
ScenarioStore: seed 10 built-in scenarios
scenarios_stored=10
2
LLMGateway: Ollama with mock fallback and KV cache
provider=ollama or mock
3
ToolRegistry: 4 tools with JSON Schema registered
tools_registered=4
4
SchemaValidation: empty query rejected before actor runs
rejected_before_actor=true
5
AgentActor: full OODA loop, 10 iterations, budget enforced
outcome=completed, steps=27–40
6
TrajectoryStore: persist and retrieve by ID
trajectory_id=traj-…
7
ScorerActor: two rubrics on the same trajectory
task_completion=0.85, tool_use=0.80
8
EvalRunnerActor: 5-scenario smoke suite, parallel
pass_rate=0.40–0.83, avg=0.76–0.82
9
RegressionDetector: compare against baseline
regressions=1, improvements=1
10
BenchmarkActor: 3 harness configs, same scenarios
winner by score
11
ApprovalGateActor: durable FSM wait and resume
idle –> awaiting –> idle
12
Second eval suite: drift detection
pass_rate compared to step 8
13
DashboardActor: first aggregate view
total_evals=2
14
AdvisorActor: two-tier routing, token split
escalation_rate=40%–60%
15
DashboardActor: final aggregate across all runs
total_evals=2–4, avg_score=0.767–0.81
Running it
You need a PlexSpaces node on port 8091, plus the toolchain for whichever port you want to run. Ollama with llama3.2 pulled is optional and test.sh falls back to a deterministic mock if Ollama isn’t running.
# Using Docker (recommended)
docker run -p 8000:8000 plexspaces/node:latest
# Or build from source
git clone https://github.com/plexobject/plexspaces.git
cd plexspaces && make build
# Go — 1.5M WASM, 5x parallel speedup, fastest eval
cd examples/go/apps/minipi
./build.sh && ./test.sh 8091
# Python — 47M WASM, most readable actor code, full object registry
cd examples/python/apps/minipi
./build.sh && ./test.sh 8091
# TypeScript — 13M WASM, token cost tracking per scenario
cd examples/typescript/apps/minipi
./build.sh && ./test.sh 8091
# Rust WASM — 6.3M WASM, most complete benchmark scoring
cd examples/rust/apps/minipi
./build.sh && ./test.sh 8091
# Rust embedded — no WASM, in-process node, fastest startup
cd examples/rust/embedded/minipi
./test.sh
Summary
Agents fail for harness reasons more often than model reasons. For example, the loop exits too early; a malformed tool call crashes the agent; an eval suite runs against mocks, passes, and hands you false confidence. These are infrastructure problems, and infrastructure problems have infrastructure solutions. For example, in a debugging assistant mentioned above, the harness is what lets you restart a 40-tool-call investigation from step 37 instead of step 1. It’s what lets you gate a deployment on human approval without holding a thread open for ten minutes. It’s what lets you run ten scenarios in parallel and get a pass@k score you can actually trust before you ship.
PlexSpaces brings together four decades of actor-model thinking like supervision trees from Erlang, TupleSpace coordination from Linda, durable workflows in the spirit of Temporal and wires them together specifically for agent workloads. The same runtime runs on a laptop and in production. The same actors used for eval are the actors that run in production. There’s no mismatch between the two. The harness is half the agent so build it like infrastructure.
Observability is a key part of any infrastructure but I’ve watched teams repeat the same mistakes around measuring availability. For example, they track uptime and watch average latency. They run a TCP health check on port 80 and call it good. Then support learns about the availability issues from customers but the health dashboard shows everything is green. This post covers how to measure availability correctly: what signals to collect, how monitoring tools compute the rolling statistics you see, why percentiles beat averages and what happens to tail latency at scale in microservices.
1. What Availability Actually Means
The textbook definition of availability is uptime, e.g., the fraction of time a service is running. This splits into two independent questions:
Availability = P(request succeeds) AND P(request completes within SLA)
A service can answer every request successfully but take 30 seconds per response then that’s functionally unavailable. Conversely, a service can respond in 5ms but return errors to 50% of requests is also functionally unavailable.
2. User Errors vs Server Errors — Why the Distinction Matters
This is the most commonly conflated measurement in production monitoring. HTTP status codes carry clear semantic meaning that should drive entirely different alert responses:
Code Range
Meaning
Whose Fault?
Include in Availability?
2xx
Success
—
Yes (success)
3xx
Redirect
—
Usually ignored
4xx
Client/user error
The caller
No
5xx
Server error
Your service
Yes
4xx errors are client/user errors like 400/Bad Request, 401/Unauthorized. 5xx errors means service is failing like 500/Internal Server, 503/Service Unavailable. There is one gray area: client timeouts. If your client times out after 5s waiting for your 10s response, the client sees a 408 or a network error, which look like a 4xx but the root cause is server-side latency. This is why tracking latency separately from error codes is essential.
A spike in 4xx that isn’t paired with a 5xx spike is almost certainly a misbehaving client, not your service. Alert on them differently: 5xx pages your on-call, 4xx goes to a ticket queue for review.
3. SLAs, SLOs, and Error Budgets
These three terms are used interchangeably in many organizations and they shouldn’t be.
SLA (Service Level Agreement) is a contractual commitment to external customers. Violating it has legal or financial consequences. Example: “We guarantee 99.9% availability per calendar month. If we breach this, we issue service credits.”
SLO (Service Level Objective) is an internal engineering target, usually tighter than the SLA. Example: “We target 99.95% availability.” The gap between SLO and SLA is your buffer.
Error Budget is what you get to spend before you breach your SLO. For a 99.9% SLO over 30 days:
Total minutes in 30 days = 30 × 24 × 60 = 43,200 minutes
Allowed downtime = 43,200 × (1 - 0.999) = 43.2 minutes
The error budget is your 43.2 minutes. Every minute of downtime spends from it. This reframes the conversation from “is the service up?” to “how fast are we burning through our budget?”
from datetime import datetime, timedelta
class ErrorBudget:
"""
Track error budget consumption in real time.
Example: 99.9% SLO over 30 days = 43.2 minutes of allowed downtime.
"""
def __init__(self, slo_target: float, window_days: int = 30):
self.slo_target = slo_target # e.g., 0.999 for 99.9%
self.window_minutes = window_days * 24 * 60
self.allowed_downtime_minutes = self.window_minutes * (1 - slo_target)
self.downtime_minutes_spent = 0.0
self.start_time = datetime.now()
def record_downtime(self, minutes: float):
self.downtime_minutes_spent += minutes
def budget_remaining_minutes(self) -> float:
return max(0, self.allowed_downtime_minutes - self.downtime_minutes_spent)
def budget_remaining_pct(self) -> float:
return (self.budget_remaining_minutes() / self.allowed_downtime_minutes) * 100
def burn_rate(self) -> float:
"""How fast are we burning budget vs. expected rate? 1.0 = on track, >1.0 = burning fast."""
elapsed = (datetime.now() - self.start_time).total_seconds() / 60
expected_spent = (elapsed / self.window_minutes) * self.allowed_downtime_minutes
if expected_spent == 0:
return 0.0
return self.downtime_minutes_spent / expected_spent
def summary(self) -> str:
return (
f"SLO: {self.slo_target*100:.2f}% | "
f"Budget: {self.allowed_downtime_minutes:.1f} min | "
f"Spent: {self.downtime_minutes_spent:.1f} min | "
f"Remaining: {self.budget_remaining_pct():.1f}% | "
f"Burn rate: {self.burn_rate():.2f}x"
)
# Usage
budget = ErrorBudget(slo_target=0.999, window_days=30)
budget.record_downtime(minutes=12.5) # incident on day 3
budget.record_downtime(minutes=8.0) # incident on day 11
print(budget.summary())
# SLO: 99.90% | Budget: 43.2 min | Spent: 20.5 min | Remaining: 52.5% | Burn rate: ...
A burn rate above 1.0 means you’ll exceed your error budget before the window closes. Burn rate above 14.4x means you’ll exhaust it within 48 hours, which is a PagerDuty alert.
4. The Health Check Anti-Pattern
I need to address something I’ve seen sink production deployments before we even get to metrics: health checks that only verify the process is listening on a port. A port check tells you the process hasn’t crashed. It tells you nothing about whether the process can serve traffic. I’ve seen this exact scenario: database connection pool was exhausted, port was open, load balancer marked the instance healthy, every request returned a 500. The monitoring was dark green the whole time.
A real health check must exercise the actual request path: connect to dependencies, perform a lightweight but genuine operation, return structured status. In Kubernetes this means a readiness probe hitting a /health endpoint that checks dependency connectivity. Critically, readiness and liveness are different probes:
Liveness: Is the process deadlocked? If not, keep it alive. If yes, kill and restart it.
Readiness: Can it serve traffic right now? If not, remove it from the load balancer pool, but don’t kill it.
A process that is alive but not ready (warming up a cache, waiting for a dependency) should fail readiness but pass liveness. Confusing these two causes cascading restarts during startup under load is a failure mode I’ve seen multiple times in prod. See my Zero-Downtime Services on Kubernetes and Istio post for the full treatment.
5. Why Average Latency Lies
Here’s a production story I’ve seen more than once. The team does an efficiency push: optimizes the hot path, ships a 30% improvement in p50 latency. Dashboards celebrate but three weeks later, the p99 is back to where it started. The answer is queuing theory. Consider a server with a queue in front of it. Define utilization P as:
P = arrival rate / service rate
The average number of items in the system in queue plus being served is:
E[N] = P / (1 - P)
This is not a linear relationship. It’s an asymptote that goes vertical as you approach full utilization:
P (utilization)
E[N] (avg items in system)
0.50 (50%)
1
0.80 (80%)
4
0.90 (90%)
9
0.95 (95%)
19
0.99 (99%)
99
When you make the code faster (higher service-rate), P drops, and you slide left on this curve, i.e., fewer items queuing with lower tail latency. But then traffic grows or you reduce servers to “realize the savings.” P climbs back to where it was, and latency returns with it. The key lesson is that the average latency reflects the fast path but high-percentile latency (p99, p99.9) is extremely sensitive to queue depth. High percentile latency is a leading indicator that you’re approaching overload.
There’s a counterintuitive implication from this: p99 is a terrible way to measure whether your efficiency work succeeded. It’s so sensitive to the queuing nonlinearity that changes in utilization will swamp the signal from your actual code changes. For measuring efficiency, mean latency is actually better because it tracks the true cost of processing one request without queue effects. Use percentiles for alerting and use mean for efficiency measurement.
6. Percentiles From First Principles
Let’s go over percentiles from scratch, because monitoring tools throw around “p50”, “p99”, “p99.9” without ever explaining what they actually represent, and misunderstanding them leads to misreading dashboards. Given a set of N latency measurements, sort them from fastest to slowest. The Nth percentile is the value at position N% in that sorted list.
Latencies (ms): [5, 7, 8, 9, 10, 11, 12, 13, 250, 400]
Sorted: [5, 7, 8, 9, 10, 11, 12, 13, 250, 400]
^ ^ ^
p10 p50 p90
p50 = 10ms (50% of requests were at or below this speed)
p90 = 13ms (90% of requests were at or below this speed)
p99 = 400ms (99% of requests were at or below this speed)
What p99 tells you is: at most 1% of your requests see latency worse than this number. Equivalently, 999 out of every 1000 requests complete faster than p99. The catch is that p99 is a single value and it summarizes nothing about the shape of the distribution between p90 and p99. Latency can get dramatically worse for customers in that range without your p99 alarm firing.
import numpy as np
def explain_percentile(latencies_ms: list[float]):
"""Show what percentiles mean in plain English."""
arr = np.array(sorted(latencies_ms))
n = len(arr)
stats = {
"mean": np.mean(arr),
"p50": np.percentile(arr, 50),
"p90": np.percentile(arr, 90),
"p95": np.percentile(arr, 95),
"p99": np.percentile(arr, 99),
"p99.9": np.percentile(arr, 99.9),
"max": np.max(arr),
}
print(f"{'Statistic':<10} {'Value':>10} Plain English")
print("-" * 65)
print(f"{'mean':<10} {stats['mean']:>10.1f}ms Average — hides bimodal distributions")
print(f"{'p50':<10} {stats['p50']:>10.1f}ms Half of requests faster than this")
print(f"{'p90':<10} {stats['p90']:>10.1f}ms 90% of requests faster than this")
print(f"{'p95':<10} {stats['p95']:>10.1f}ms 95% of requests faster than this")
print(f"{'p99':<10} {stats['p99']:>10.1f}ms 99% of requests faster than this")
print(f"{'p99.9':<10} {stats['p99.9']:>10.1f}ms 999/1000 requests faster than this")
print(f"{'max':<10} {stats['max']:>10.1f}ms Worst single request (very noisy)")
# Simulate a bimodal latency distribution
# 95% fast requests (cache hit), 5% slow (cache miss + DB query)
import random
random.seed(42)
latencies = [
random.gauss(10, 2) if random.random() > 0.05 else random.gauss(300, 40)
for _ in range(1000)
]
explain_percentile(latencies)
Statistic Value Plain English
-----------------------------------------------------------------
mean 24.8ms Average — hides bimodal distributions
p50 10.4ms Half of requests faster than this
p90 12.1ms 90% of requests faster than this
p95 17.9ms 95% of requests faster than this
p99 302.1ms 99% of requests faster than this
p99.9 375.8ms 999/1000 requests faster than this
max 392.4ms Worst single request (very noisy)
7. Moving Averages and Rolling Percentiles
When Grafana shows you a p99 or Datadog shows you an error rate, it’s not summing up all-time data. It’s computing over a rolling time window.
Simple Moving Average vs EWMA
A Simple Moving Average (SMA) gives equal weight to every sample in the window:
from collections import deque
import statistics
class SMA:
"""Simple Moving Average — every sample in the window weighted equally."""
def __init__(self, window: int):
self.buf = deque(maxlen=window)
def add(self, v: float) -> float:
self.buf.append(v)
return statistics.mean(self.buf)
An Exponentially Weighted Moving Average (EWMA) gives more weight to recent samples, fading older ones smoothly:
Sample Value alpha=0.05 alpha=0.30
0 10 10.0 10.0
1 10 10.0 10.0
4 250 21.9 82.0 --> fast alpha sees the spike much louder
5 10 21.3 58.4 --> slow alpha recovers faster
9 10 18.5 17.2
Rolling Percentile
Computing exact percentiles over a moving window requires keeping raw samples and re-sorting. For production scale, the T-Digest algorithm computes approximate percentiles with bounded memory. Here’s the conceptual version first:
import numpy as np
from collections import deque
class RollingPercentile:
"""
Rolling percentile over a fixed window of recent samples.
Production note: At high throughput, use T-Digest or DDSketch instead.
Prometheus uses pre-defined histogram buckets + linear interpolation.
"""
def __init__(self, window: int, pctile: float):
self.buf = deque(maxlen=window)
self.pctile = pctile
def add(self, v: float) -> float | None:
self.buf.append(v)
if len(self.buf) < 2:
return None
return float(np.percentile(list(self.buf), self.pctile))
# Show how window size affects sensitivity
import random
random.seed(7)
data = [random.gauss(10, 2) for _ in range(90)] + \
[random.gauss(200, 20) for _ in range(10)] # degradation at t=90
p99_small = RollingPercentile(window=20, pctile=99)
p99_medium = RollingPercentile(window=100, pctile=99)
print("How window size affects p99 detection of a latency spike:")
print(f"{'t':>4} {'value':>8} {'p99 w=20':>12} {'p99 w=100':>12}")
for t, v in enumerate(data[80:]): # show the transition region
small = p99_small.add(v)
medium = p99_medium.add(v)
marker = " --> spike starts" if t == 10 else ""
if small and medium:
print(f"{t+80:>4} {v:>8.1f} {small:>12.1f} {medium:>12.1f}{marker}")
Prometheus histogram vs. summary: Prometheus offers two ways to track latency. A Summary computes quantiles client-side over a rolling window but you can’t aggregate across instances. A Histogram records counts in pre-defined buckets and approximates quantiles server-side, which is slightly less accurate, but fully aggregatable. For microservices with multiple replicas, always use Histogram.
8. Trimmed Mean: More Signal, Real Tradeoffs
Here’s the core difference between a percentile and a trimmed mean, using the product review analogy:
100 latency measurements, sorted by speed:
p99 = the single worst measurement in the best 99%
(the 99th measurement out of 100, sorted fastest-to-slowest)
tm99 = the average of all 99 measurements in the best 99%
(discard the 1 slowest, average the remaining 99)
tm99 summarizes 99 times more data than p99. That makes it more stable (less spiky under low traffic), harder to game (a gradual degradation can hide between percentile checkpoints, but tm99 will catch it), and more representative of typical customer experience.
tm99 tracks the average experience of your bulk of customers
TM(99%:) tracks the average of your slowest 1%; ensures outlier experience doesn’t silently worsen
Together these two numbers cover 100% of your requests with just two metrics.
import numpy as np
def compute_tm_stats(samples: list[float]) -> dict:
"""
Compute a full suite of trimmed mean statistics.
Syntax mirrors CloudWatch / AWS Embedded Metrics Format:
tm99 = TM(0%:99%) = average of fastest 99%
TM(99%:) = TM(99%:100%) = average of slowest 1%
TM(1%:99%) = drop both extremes (handles unbounded latency)
IQM = TM(25%:75%) = Interquartile Mean
"""
arr = np.sort(np.array(samples))
n = len(arr)
def tm(lower_pct: float, upper_pct: float) -> float:
lo = np.percentile(arr, lower_pct)
hi = np.percentile(arr, upper_pct)
trimmed = arr[(arr >= lo) & (arr <= hi)]
return float(np.mean(trimmed)) if len(trimmed) else float('nan')
return {
"mean": float(np.mean(arr)),
"p50": float(np.percentile(arr, 50)),
"p99": float(np.percentile(arr, 99)),
"tm99": tm(0, 99), # avg of fastest 99%
"TM(99%:)": tm(99, 100), # avg of slowest 1% --> watch your outliers here
"TM(1%:99%)": tm(1, 99), # drop both extremes (use for unbounded latency)
"IQM": tm(25, 75), # interquartile mean
}
# Scenario: a cache-miss spike where 2% of requests are slow
rng = np.random.default_rng(42)
fast = rng.normal(10, 1.5, 980)
slow = rng.normal(350, 30, 20)
samples = np.concatenate([fast, slow]).tolist()
stats = compute_tm_stats(samples)
print(f"{'Metric':<14} {'Value':>10} Notes")
print("-" * 65)
for k, v in stats.items():
notes = {
"mean": "Pulled up by slow tail — misleading",
"p50": "Median — fine but ignores tail",
"p99": "Single value at 99th position",
"tm99": "Average of 98% of customers --> primary SLO metric",
"TM(99%:)": "Average of slowest 2% --> outlier watchdog",
"TM(1%:99%)": "Drops both extremes — good for browser metrics",
"IQM": "Middle 50% average — robust to both extremes",
}.get(k, "")
print(f"{k:<14} {v:>10.1f}ms {notes}")
Metric Value Notes
-----------------------------------------------------------------
mean 16.8ms Pulled up by slow tail — misleading
p50 9.9ms Median — fine but ignores tail
p99 335.2ms Single value at 99th position
tm99 10.1ms Average of 98% of customers --> primary SLO metric
TM(99%:) 351.4ms Average of slowest 2% --> outlier watchdog
TM(1%:99%) 10.1ms Drops both extremes — good for browser metrics
IQM 9.8ms Middle 50% average — robust to both extremes
Bounded vs. unbounded latency:
Bounded latency (server-side, with request timeouts): use tm99 + TM(99%:). Since latency is capped by your timeout, even the worst measurements are meaningful.
Unbounded latency (client-side browser metrics, user-perceived time): use TM(1%:99%). A user who closes their laptop mid-request and reopens it days later may log a latency of 230,400 seconds. These shouldn’t contaminate your outlier statistics. Drop the top and bottom extremes.
I have seen in a real-life production services where teams work towards improving p50/median but everything else gets worse. You only find this out when you examine tm95 because latency was consistently worse for a growing number of customers. The key lesson is that percentiles create blind spots “between the checkpoints.” A degradation that affects the 40th–60th percentile range will move neither p25 nor p75 much. Trimmed mean, because it averages across the entire range, catches these shifts. However, trimmed mean has its own blind spot. It deliberately removes the part of the distribution that dominates user experience in fan-out architectures. The right answer is not to choose between percentiles and trimmed mean but use both.
10. Winsorized Mean, Percentile Rank, and IQM
These statistics show up in CloudWatch and modern observability platforms, and they each solve a specific problem.
Winsorized Mean (WM)
Like trimmed mean, but instead of discarding outliers, it replaces them with the boundary value. For wm99:
Find the value at the 99th percentile (= p99)
Treat all 1% outliers as if they had exactly that p99 value
Average all 100% of samples
def winsorized_mean(samples: list[float], lower_pct: float = 0, upper_pct: float = 99) -> float:
arr = np.array(samples, dtype=float)
lo = np.percentile(arr, lower_pct)
hi = np.percentile(arr, upper_pct)
# Clip: anything below lo becomes lo, anything above hi becomes hi
winsorized = np.clip(arr, lo, hi)
return float(np.mean(winsorized))
Winsorized mean gives some weight to outliers without letting extreme values skew the average. The difference between tm99 and wm99 is subtle at high percentages and wm99 will be slightly higher because it includes the outliers rather than dropping them.
Percentile Rank PR()
Percentile rank answers the inverse question from percentile. Percentile says: “What latency value marks the Nth percent?” Percentile rank says: “What percent of requests are below a given latency value?”
If you have an SLA of “respond within 500ms to 99% of users,” you’d normally monitor p99 and check it’s <= 500ms. With Percentile Rank, you instead plot PR(:500ms, i.e., the percentage of requests completing within 500ms and drive that number toward 99% or higher. This is more directly action-oriented: you always know exactly how far below your SLA you are.
def percentile_rank(samples: list[float], threshold: float) -> float:
"""What fraction of samples are at or below threshold?"""
arr = np.array(samples)
return float(np.mean(arr <= threshold) * 100)
# Example: SLA is p99 < 500ms
samples_ms = [10, 12, 9, 11, 450, 10, 13, 600, 11, 10] # small sample
pr_500 = percentile_rank(samples_ms, 500)
print(f"PR(:500ms) = {pr_500:.1f}% (SLA requires 99%)")
# PR(:500ms) = 90.0% (SLA requires 99%) — you're 9 percentage points short
IQM (Interquartile Mean)
IQM is simply TM(25%:75%), the average of the middle 50% of samples, discarding the top and bottom 25%. It’s extremely robust to outliers in both directions, useful when you expect noise from both ends of the distribution (e.g., some requests are trivially fast cache hits, others are pathologically slow).
11. The Inspection Paradox: Your Users Experience Worse Than Your Metrics Show
As Marc Brooker’s explained in his blog, this is the most underappreciated gap in distributed systems reliability. For example, say your service has outages with very different durations: some resolve in 30 seconds, but occasionally one runs for 3 hours. Your MTTR (Mean Time to Recovery) might calculate to 5 minutes. But when a user hits your service during an outage, they’re more likely to land in a long outage than a short one because long outages have more time-slots for users to arrive in.
Customer-experienced mean recovery = (1/2) × (MTTR + Variance/MTTR)
The second term is what kills you. If your outage duration has high variance, e.g., fast recovery most of the time, but occasional 3-hour events then that variance term dominates. Your customers experience something dramatically worse than your MTTR.
import random
import math
import statistics
def inspection_paradox_demo(
median_recovery_min: float,
p99_recovery_min: float,
arrivals_per_min: float = 100,
n_outages: int = 2000
) -> dict:
"""
Simulate the gap between operator MTTR and customer-experienced recovery.
Key insight: customers are t-weighted samplers of your outage distribution.
A 10-minute outage gets sampled by ~10x as many clients as a 1-minute outage.
"""
# Fit lognormal to median and p99
mu = math.log(median_recovery_min)
sigma = (math.log(p99_recovery_min) - mu) / 2.326
server_durations = []
client_wait_times = []
for _ in range(n_outages):
duration = random.lognormvariate(mu, sigma)
server_durations.append(duration)
# Clients arrive as a Poisson process during the outage
t = 0.0
while True:
gap = random.expovariate(arrivals_per_min)
if t + gap > duration:
break
# This client arrived at time t, waits until outage ends
client_wait_times.append(duration - t)
t += gap
return {
"operator_mttr": statistics.mean(server_durations),
"operator_p99": sorted(server_durations)[int(len(server_durations) * 0.99)],
"customer_mean_wait": statistics.mean(client_wait_times) if client_wait_times else 0,
"customer_p99_wait": sorted(client_wait_times)[int(len(client_wait_times) * 0.99)] if client_wait_times else 0,
"experience_gap_ratio": (statistics.mean(client_wait_times) / statistics.mean(server_durations)) if client_wait_times else 0,
}
result = inspection_paradox_demo(
median_recovery_min=1, # median outage resolves in 1 minute
p99_recovery_min=60, # but 1% of outages take an hour
)
print("Scenario: 1-minute median recovery, 60-minute p99 recovery")
print()
print("What your on-call dashboard shows:")
print(f" MTTR: {result['operator_mttr']:.1f} minutes")
print(f" p99 recovery: {result['operator_p99']:.1f} minutes")
print()
print("What your customers actually experience:")
print(f" Mean recovery: {result['customer_mean_wait']:.1f} minutes")
print(f" p99 recovery: {result['customer_p99_wait']:.1f} minutes")
print(f" Experience gap: {result['experience_gap_ratio']:.1f}x worse than MTTR")
Scenario: 1-minute median recovery, 60-minute p99 recovery
What your on-call dashboard shows:
MTTR: 4.9 minutes
p99 recovery: 56.6 minutes
What your customers actually experience:
Mean recovery: 60.0 minutes
p99 recovery: 797.3 minutes
Experience gap: 12.1x worse than MTTR
This is why tail recovery time matters more than averages suggest. Timeout-and-retry can hide individual request latency, but it cannot hide recovery time. Once a client gets stuck in an outage, retries don’t shorten the outage, they just add load to an already struggling service. The right takeaway: minimize variance in recovery time, not just its mean. Bounded, predictable recovery is far better for customers than fast-average-but-occasional-disaster.
12. Tail Latency Amplifies in Microservices
Modern architectures decompose user requests into many service calls. This creates two topologies, and both amplify tail latency:
Fan-out math: If each service has a 1% probability of a slow response, the probability that at least one is slow when calling N services in parallel is:
P(at least one slow) = 1 - (1 - 0.01)^N
N (services called)
% of user requests seeing a slow response
1
1.0%
5
4.9%
10
9.6%
25
22.2%
50
39.5%
100
63.4%
What was a rare 1% tail now affects the majority of user interactions. And here’s the pernicious part: your per-service p99 metric looks perfectly fine. The damage is invisible at the service level, only visible at the user-experience level.
import numpy as np, random
def simulate_fanout(n_backends: int, tail_prob: float = 0.01, n_reqs: int = 20_000):
"""
Simulate client experience when calling n_backends in parallel.
Each backend: (1-tail_prob) chance of fast, tail_prob chance of slow.
"""
results = []
slow_count = 0
for _ in range(n_reqs):
latencies = []
for _ in range(n_backends):
if random.random() < tail_prob:
latencies.append(random.gauss(250, 25))
slow_count += 1
else:
latencies.append(random.gauss(10, 2))
results.append(max(latencies)) # fan-out: wait for slowest
arr = np.array(results)
return {
"p50": np.percentile(arr, 50),
"p99": np.percentile(arr, 99),
"mean": np.mean(arr),
"pct_slow_user_requests": np.mean(arr > 50) * 100,
}
print(f"{'N':>4} {'p50 (ms)':>10} {'p99 (ms)':>10} {'mean (ms)':>10} {'% users hit slow':>18}")
for n in [1, 5, 10, 25, 50, 100]:
r = simulate_fanout(n)
print(f"{n:>4} {r['p50']:>10.1f} {r['p99']:>10.1f} {r['mean']:>10.1f} {r['pct_slow_user_requests']:>18.1f}%")
The trimmed mean blind spot revisited. At N=50, nearly 40% of user requests are slow. But your per-service tm99 (averaging the best 99% of individual service calls) still looks great because it’s averaging the fast cluster. This is exactly the case where trimmed mean gives you false comfort. You need explicit end-to-end latency tracking at the user-request level, not just per-service tail tracking.
13. The Pooling Dividend: Why Redundancy Is Non-Linear
Adding servers doesn’t just increase capacity linearly but it also improves latency through pooling. This comes from the Erlang C model in queuing theory. For example, two designs, both handling the same total load:
Design A: 1 server at 80% utilization
Design B: 10 servers sharing load, each at 80% utilization
Design A has roughly a 13% chance of any incoming request finding the server busy and joining a queue. Design B has roughly a 3.6% chance. Double the fleet to 20 servers at the same 80% per-server utilization, and the queueing probability drops toward 1%. You’re getting better latency and better tail behavior at the same per-server cost.
import math
from functools import lru_cache
def erlang_c(c: int, rho: float) -> float:
"""
Erlang C formula: probability an arriving request must queue
(rather than being served immediately) in an M/M/c system.
c: number of servers
rho: per-server utilization (0 < rho < 1)
"""
a = c * rho # total offered load
@lru_cache(maxsize=None)
def factorial(n: int) -> int:
return 1 if n <= 1 else n * factorial(n - 1)
# Sum term for the denominator
sum_term = sum(a**k / factorial(k) for k in range(c))
last_term = (a**c / factorial(c)) * (1 / (1 - rho))
ec = last_term / (sum_term + last_term)
return ec
print("Probability a request must queue before being served:")
print(f"{'Servers':>8} {'Utilization':>12} {'Queue prob':>12} {'Queue %':>8}")
for c in [1, 2, 5, 10, 20, 50]:
ec = erlang_c(c=c, rho=0.8)
print(f"{c:>8} {'80%':>12} {ec:>12.4f} {ec*100:>7.1f}%")
Probability a request must queue before being served:
Servers Utilization Queue prob Queue %
1 80% 0.8000 80.0%
2 80% 0.7111 71.1%
5 80% 0.5541 55.4%
10 80% 0.4092 40.9%
20 80% 0.2561 25.6%
50 80% 0.0870 8.7%
Most of the benefit materializes at modest fleet sizes. You don’t need to be at hyperscale to get pooling gains. A fleet of 5-10 servers sharing load through a proper load balancer will have dramatically better tail latency behavior than the same compute running as independent instances.
14. Retries, Circuit Breakers, and the Amplification Trap
Retries protect against transient failures like a GC pause, a brief network glitch, a thundering herd. In past production deployment, I use up to 3 retries with exponential backoff for idempotent read operations. The protection against false positives is real and worthwhile. But retries have a catastrophic failure mode: retry amplification.
A single user request can generate 3 × 3 × 3 = 27 actual requests to a struggling downstream service. This turns a partial overload into a total collapse. I’ve watched this happen in production, e.g., a service that was at 60% capacity receives a burst of retries from a misbehaving upstream and immediately spikes to 200% load, failing every request, causing more retries, a feedback loop.
The mitigations:
import time
import threading
from collections import deque
class RetryBudget:
"""
Limit total retry rate as a fraction of total traffic.
If retries exceed the budget, fail fast instead of retrying.
Classic mitigation for retry amplification.
"""
def __init__(self, budget_fraction: float = 0.10, window_seconds: int = 60):
self.budget_fraction = budget_fraction
self.window = window_seconds
self.total_requests: deque = deque()
self.retry_requests: deque = deque()
self._lock = threading.Lock()
def _prune(self):
cutoff = time.monotonic() - self.window
while self.total_requests and self.total_requests[0] < cutoff:
self.total_requests.popleft()
while self.retry_requests and self.retry_requests[0] < cutoff:
self.retry_requests.popleft()
def record_request(self):
with self._lock:
self.total_requests.append(time.monotonic())
def should_retry(self) -> bool:
"""Returns True if we have retry budget remaining."""
with self._lock:
self._prune()
total = len(self.total_requests)
retries = len(self.retry_requests)
if total == 0:
return True
current_rate = retries / total
if current_rate < self.budget_fraction:
self.retry_requests.append(time.monotonic())
return True
return False # budget exhausted — fail fast, don't amplify
class CircuitBreaker:
"""
Stop sending requests to a failing downstream.
Transitions: CLOSED -> OPEN -> HALF_OPEN -> CLOSED
"""
CLOSED, OPEN, HALF_OPEN = "CLOSED", "OPEN", "HALF_OPEN"
def __init__(self, failure_threshold: float = 0.5, cooldown_seconds: float = 30):
self.failure_threshold = failure_threshold
self.cooldown = cooldown_seconds
self.state = self.CLOSED
self.failures = 0
self.total = 0
self.opened_at: float | None = None
def call_allowed(self) -> bool:
if self.state == self.CLOSED:
return True
if self.state == self.OPEN:
if time.monotonic() - self.opened_at > self.cooldown:
self.state = self.HALF_OPEN
return True # let one probe through
return False # fail fast
return True # HALF_OPEN: let one probe through
def record_success(self):
self.failures = 0
self.total = 0
self.state = self.CLOSED
def record_failure(self):
self.failures += 1
self.total += 1
if self.total >= 10 and self.failures / self.total >= self.failure_threshold:
self.state = self.OPEN
self.opened_at = time.monotonic()
Hedge requests are often better than retries for latency problems. Instead of waiting for a timeout and retrying, fire a second request after a short delay (say, the p90 latency). Accept whichever responds first, cancel the other. This cuts your tail exposure without amplifying load as aggressively, because typically one of the two requests will succeed quickly.
15. Synthetic Canaries in Production
Error rates and latency percentiles tell you what’s happening to real traffic but only after users are affected. Synthetic canaries fill the gap: background processes that continuously exercise your API end-to-end, giving you availability signal even at 3am when real traffic is low.
Key design decisions from production experience:
Test the full workflow, not just the health endpoint. A canary for a data API should create, read, update, and delete a record. One for an auth service should issue a token, validate it, and revoke it. Shallow canaries that only call GET /health will miss the exact failures that health check anti-patterns also miss.
Track first-attempt and final success separately. If your canary succeeds on retry 2 90% of the time, the final success rate looks fine but something is quietly broken. First-attempt success rate catches this.
Keep canary observability separate from production. Mixing them has two failure modes: canary failures inflate your production error rate, and canary successes can mask production degradation if canaries hit warm caches or a separate code path.
Account for canary bias. Canaries hit warm caches and have predictable access patterns. Their p99 is almost always better than real user p99. Use canary latency to detect regressions relative to a baseline, not to claim absolute performance numbers.
Use retries in canaries, but with a limit. Up to 3 retries prevents false positives from transient network blips. But record the retry count per run, e..g, a canary that regularly needs 2+ retries is a signal worth investigating even if it eventually succeeds.
16. Putting It All Together: A Layered Monitoring Strategy
After decades of building and operating distributed systems, here’s the monitoring architecture I’d deploy for any production service from day one:
Metric
Why
Window
Alert Threshold
5xx rate
Server failures
1 min
> 0.1%
p99 latency
Tail experience, SLA
1 min
> SLA value
Request volume
Silent failures
1 min
Drop > 50%
tm99 latency
Bulk experience
5 min
Trending up
TM(99%:) latency
Outlier watchdog
5 min
Trending up
Error budget burn
SLO health
1 hr
> 2x expected rate
p99.9 latency
Overload early warning
15 min
Trending
Retry rate
Amplification risk
5 min
> 10% of traffic
Canary first-attempt
End-to-end health
60s
< 95%
Closing: The Number That Matters Most
After all of this, the insight that has most changed how I think about availability is this: your users don’t experience your MTTR. They experience a version of it weighted by how long outages last, which skews dramatically toward your worst events. A service with a 1-minute median recovery but occasional 2-hour outages will have customers experiencing something closer to hours, not minutes. The variance in your tail events matters more than the central tendency. This is why the tail cannot be trimmed away from your visibility. Build observability that shows you the tail. Use redundancy and retries but understand how they amplify under pressure. Run canaries that exercise the whole path. Track user errors and server errors separately. Keep SLO burn rate visible so you always know how much budget you’ve spent. And when your customers say the service is slow and your dashboard says everything is green then believe the customers.
Comments Off on Measuring Availability Properly: Percentiles, Tail Latency, and the Production Traps
Debugging a production incidents is much harder when dealing with a system with complex state management. For example, you might see a worker node is simultaneously “draining” and “upgrading” while flagged as “ready to restart.” or the heartbeat buffer filled with 100,000 metrics and silently dropped the overflow. In other cases, you might see a config deployment shows “success” in the database but never actually deployed because the error got swallowed by .catch(NOOP) somewhere. I’ve seen it in most legacy codebase I’ve worked on, e.g., in one system I found:
441 instances of .catch(NOOP): errors silently swallowed
506 mode checks: scattered everywhere, e.g., if (isLeader)... else if (isWorker)...
64 possible boolean combinations: for worker state, of which only 5 are valid
Race conditions: in shared state with no synchronization
816 files: coupled to global singletons
Here is the core thesis: most production incidents aren’t algorithmic bugs. They’re states that shouldn’t exist. The system entered a configuration nobody intended, no test covered, and no monitoring caught. Algebraic Data Types (ADTs) and Algebraic Effects are the tools that make those impossible states unrepresentable in code. Not “less likely.” or “caught by tests.” but impossible to express.
II. What Are Algebraic Data Types?
Forget the word “algebraic” for a moment. It just means “composed of parts using AND and OR.” That’s it.
Product Types: AND
A product type is a structure where ALL fields must be present at the same time. You use these every day:
Every WorkerConnection has an id AND an address AND a port AND a last_heartbeat. It’s called “product” because the number of possible values is the product of each field’s possibilities.
Sum Types: OR
A sum type is a value that is ONE of several variants. This is the powerful one most codebases miss:
enum TrafficLight {
Red,
Yellow,
Green,
}
A traffic light is Red OR Yellow OR Green. It is never Red AND Green at the same time. It’s called “sum” because the number of possible values is the sum of each variant. The critical feature is exhaustiveness checking. When you pattern-match on a sum type, the compiler forces you to handle every variant. Add a new one and the compiler shows you every place that needs updating:
fn action(light: &TrafficLight) -> &str {
match light {
TrafficLight::Red => "stop",
TrafficLight::Yellow => "caution",
TrafficLight::Green => "go",
// Add FlashingRed and this won't compile until you handle it here
}
}
Why This Matters: Making Illegal States Unrepresentable
Here’s the practical payoff. Look at actual legacy code managing worker nodes:
Six independent boolean fields. That’s 2^6 = 64 possible combinations. But the system only has about 5 valid states: idle, configuring, upgrading, draining, or restarting. The other 59 combinations are bugs waiting to happen. What does upgrade_in_progress = true AND draining = true AND reconfig_in_progress = Some(request) mean? Nobody knows and no test covers it. Now the same thing as a Rust enum:
Five states but the 59 impossible combinations literally cannot be expressed. You cannot write code that puts the worker in an invalid state because the type won’t compile. This isn’t about “good practice.” It’s about making an entire class of bugs impossible at compile time. The compiler becomes your 24/7 code reviewer, rejecting every impossible state before the code ever runs.
It Costs Real Money
Double settlement in banking: A payment system tracks settlement with isAuthorized, isSettled, isReversed. A race condition sets both isSettled = true and isReversed = true at the same time. Result: the same transaction is both settled and reversed so money moves twice. With a sum type (Authorized | Settled | Reversed | Disputed), that combination cannot exist.
Ghost billing in telecom: A session tracker uses isActive, isBilled, isTerminated. A network glitch terminates the session but the billing flag was set a millisecond before termination. Result: terminated sessions generate charges for hours. With a sum type (Active { startTime } | Terminated { endTime } | Billed { amount, endTime }), a terminated session cannot be in a billable state.
These aren’t hypothetical. They’re the kind of bugs that cost millions in reconciliation and regulatory fines. The root cause is always the same: boolean flags that allow impossible combinations.
Immutability Makes This Even Better
When state is immutable, you can’t accidentally corrupt it from another part of the code. But how do you “change” immutable data? You copy it:
fn update_progress(state: &JobState, new_progress: u8) -> JobState {
JobState {
progress: new_progress,
updated_at: Instant::now(),
..state.clone() // copy everything else
}
}
let state1 = JobState { phase: Phase::Running, progress: 50, worker_id: "w-1".into() };
let state2 = update_progress(&state1, 75);
// state1.progress is still 50 — no other code sees a half-updated state
In Rust, this is enforced by the ownership system: you can have either one mutable reference OR many immutable references. Race conditions on shared state become a compile error, not a runtime bug.
III. ADTs Applied to Real Problems
Problem 1: Mode Detection Hell
Production systems support multiple deployment modes: leader, worker, edge, standalone. The result in the legacy codebase? Mode checks everywhere:
// 500+ instances of this scattered throughout
const configHelperMode = ProcessInfo.isConfigHelperMode();
const workerProcessMode = ProcessInfo.isWorkerMode();
const apiProcessMode = !configHelperMode && !workerProcessMode;
if (configHelperMode) { return runConfigHelper(...); }
if (workerProcessMode) { return ProcessMgr.initWorkerProcess(...); }
if (ServiceInfo.isService(role)) { return Service.initServiceProcess(...); }
if (isProxyNode(distMode)) { /* ... */ }
if (isSearchSupervisor(distMode)) { /* ... */ }
if (isLeader) { /* ... */ }
else if (isManaged(distMode)) { /* ... */ }
else if (isStandalone(distMode)) { /* ... */ }
The problems: adding a new mode requires finding and updating all 506 sites, missing one means silent incorrect behavior, and it’s easy to create contradictory states (isLeader && isWorker). The fix: one decision point at startup, exhaustive matching everywhere else:
Add a new mode and the compiler immediately shows you every match that needs a new arm. Miss one? Compilation fails. This is what “compiler-guided refactoring” means in practice.
// Config updated BEFORE deployment succeeds
groupConf.configVersion = hash; // Step 1: mutate config
await this.update(groupConf); // Step 2: persist to database
await cm.deploy(); // Step 3: actually deploy
// If step 3 fails: database says "deployed" but nothing deployed.
// State is permanently inconsistent. Nobody notices until 2am.
Another version of the same problem:
// Package manager — loop continues after failure
for (const op of ops) {
try {
switch (op.type) {
case 'install': await this.install(op.pack); break;
case 'uninstall': await this.uninstall(op.pack); break;
}
} catch(e) {
errors.push(e); // collect error but CONTINUE the loop
}
}
await this.save(); // save regardless — partially applied state!
The typestate pattern uses types to enforce operation ordering. Each step produces a different type, and the next step only accepts the correct input type:
// Each phase is a distinct type — not an enum, separate structs
struct Planned { operations: Vec<Operation> }
struct Validated { operations: Vec<ValidOperation>, checks: Vec<CheckResult> }
struct Applied { results: Vec<OperationResult> }
struct Committed { hash: String, timestamp: Instant }
// Functions consume one type, return the next
fn validate(tx: Planned) -> Result<Validated, Vec<ValidationError>> { ... }
fn apply(tx: Validated) -> Result<Applied, ApplyError> { ... }
fn commit(tx: Applied) -> Result<Committed, CommitError> { ... }
// You cannot call commit() on a Planned transaction.
// The types won't allow it.
// And because validate() CONSUMES Planned, you can't reuse the old value.
If apply fails, you have a Validated, not an Applied. You can retry or abort cleanly. There’s no half-committed state because the type system won’t let you call commit without a successful apply.
Problem 3: Silently Swallowed Errors
441 instances of .catch(NOOP) in production. Each one is a failure that nobody notices until the system is in an inconsistent state:
The problem isn’t laziness. Promise/exception-based error handling makes it easy to ignore errors and hard to handle them consistently. Rust’s Result type inverts this: handling errors is the default path, and ignoring them requires explicit effort:
// Every operation returns Result — no hidden exceptions
async fn reconcile_lb(body: &Request) -> Result<LbState, ReconcileError> {
let state = do_reconcile(body).await
.map_err(|e| classify_error(e))?; // ? propagates errors up — visible in the code
Ok(state)
}
// Caller MUST handle the Result
let lb_state = reconcile_lb(&req.body).await?;
// If we reach this line, it succeeded. Guaranteed.
// Want to explicitly ignore? You have to WRITE that intention:
let _ = reconcile_lb(&req.body).await; // "I know this can fail and I don't care"
The key insight: with Result, ignoring an error requires writing code to ignore it. With exceptions, ignoring an error requires writing nothing. Defaults matter enormously. The ? operator makes propagating errors as easy as typing one character, no try/catch boilerplate, no .catch(NOOP) temptation.
Problem 4: Swapped Arguments and Primitive Obsession
The legacy codebase uses raw strings and numbers for everything like IDs, tokens, keys. Nothing stops you from passing arguments in the wrong order:
// 4,000+ uses of untyped parameters
fn send_request_to_worker(wid: u64, req: &str, body: &[u8]) { ... }
// What stops you from passing (request_id, worker_id, wrong_body)? Nothing.
Rust newtypes create distinct types with zero runtime cost:
And smart constructors validate at the boundary, so the type carries the guarantee everywhere:
impl WorkerId {
pub fn new(raw: &str) -> Result<Self, ValidationError> {
if !WORKER_ID_PATTERN.is_match(raw) {
return Err(ValidationError::InvalidFormat("worker ID"));
}
Ok(WorkerId(raw.to_string()))
}
}
// Once you have a WorkerId, you KNOW it's valid. No re-validation needed anywhere.
Problem 5: Every Process Carries Everything
The legacy system scaled by spawning full OS processes because there was no type-safe way to separate workloads:
// Every worker loads the FULL binary — all 150 connectors, all modes
// Even edge nodes carry leader code they'll never use
// Default: 2GB heap per worker
this.env.NODE_OPTIONS = `--max-old-space-size=${heapSizeMB || 2048}`;
// 4 workers × 2GB = 8GB minimum. Plus API process, services...
// Competitors: Fluent Bit (10-30MB), Vector (30-50MB)
With typed resource boundaries, each workload declares exactly what it needs:
Instead of “every process gets everything,” each workload gets exactly what it declares. Resource requirements are now visible, auditable, and enforced by the type system.
Problem 6: Inheritance Hierarchies Nobody Understands
The legacy codebase had class hierarchies 7 levels deep:
BaseServiceable // 100+ subclasses, forces EventEmitter
--> BaseInput
--> TcpInput
--> FramedProtocol // Framing, auth, metrics, load balancing — all mixed
--> ControlListener
--> ProxyListener // 760 lines of proxy logic inheriting ~4,500 lines it doesn't use
Reading ProxyListener meant understanding 6 parent classes first. And there were 12 cloud storage subclasses that were entirely empty and they inherited ~5K lines and added exactly zero:
export class ProviderAOut extends CloudStorageOutput {} // empty
export class ProviderBOut extends CloudStorageOutput {} // empty
export class ProviderCOut extends CloudStorageOutput {} // empty
The fix: composition with enums instead of inheritance:
No inheritance and no empty subclasses. Adding a new provider means adding a variant to the enum and the compiler shows you every match that needs a new arm. See my earlier blog The Reusability Trap: When DRY Becomes a Liability for more details on this anti-pattern.
IV. ADTs Applied to Concurrency
Race Conditions in Shared Mutable State
Here’s actual production code where multiple async operations read and write the same map:
private conns: { [key: string]: Connection } = {};
// Called by the service loop (runs periodically)
private async _service() {
const values = Object.values(this.conns);
for (const conn of values) {
if (conn.isStale()) {
delete this.conns[conn.key]; // Mutate while potentially being read elsewhere
}
}
}
// Called when a new node connects (can happen any time)
private addConnection(connKey: string, data: INodeEntry): boolean {
this.conns[connKey] = conn; // Race with _service()!
this.assignToGroup(conn)
.catch(LOG_ERR(logger, 'failed to assign'));
return true;
}
And the classic read-modify-write race:
prevState = await this.getState(key); // Process A reads state
// ... Process B also reads state here ...
// ... Process A modifies and writes ...
await this.store.set(key, newState); // Process B writes — A's changes LOST
The fix: a single owner of state, communicating through typed messages like actor model:
No mutexes, locks or data races. Rust’s ownership system guarantees conns is owned by exactly one task. Other tasks communicate through the channel, they physically cannot access the HashMap directly because they don’t own it.
Backpressure: Making Buffer Overflow Impossible to Ignore
The legacy heartbeat system silently dropped metrics when its buffer filled:
add(metric: MetricPacket, doNotDrop: boolean): void {
if (this.hbMetrics.length > this.maxHbMetrics) {
this.packetCounter.onDroppedMetric(); // Increment a counter nobody watches
return; // Data gone forever. No error. No signal to sender.
}
this.hbMetrics.push(metric);
}
The sender had no idea data was being lost. It kept sending happily while the system silently degraded. With Rust’s bounded channels, backpressure is built in. When the buffer is full, you must decide what to do:
match tx.try_send(metric) {
Ok(()) => { /* sent */ }
Err(TrySendError::Full(metric)) => {
// Channel is full — you MUST decide:
// Option 1: wait (applies backpressure to sender)
tx.send(metric).await?;
// Option 2: spill to disk
// disk_buffer.write(metric)?;
// Option 3: drop with explicit acknowledgment
// warn!("Metric dropped due to backpressure");
}
Err(TrySendError::Closed(_)) => {
error!("Metrics channel closed unexpectedly");
return Err(ChannelError::Closed);
}
}
The type system forces the conversation: “What should happen when the buffer is full?” You can’t accidentally drop data and you must write explicit code to ignore it.
Event Sourcing: Eliminating Lost Updates
Instead of mutable state that can be overwritten by concurrent operations, event sourcing treats state as a derived value from an append-only log:
No lost updates because events are appended, never overwritten. Invalid transitions are no-ops and the reduce function simply ignores events that don’t make sense for the current state.
Message Ordering: Protocol State Machines
The legacy system sent commands from leader to worker with no ordering guarantees:
// Leader sends: 1. configure, 2. upgrade
// Worker may RECEIVE: 1. upgrade, 2. configure (reversed!)
// Result: config applied AFTER upgrade — potential data corruption
// Current "fix": reject conflicting operations
private failOnConflictingOperation() {
if (this.currentAction) {
throw new ConflictingActionError(); // Command REJECTED, not queued!
}
}
// No command queue. No ordering. No acknowledgment.
// Leader has NO WAY to know if the worker processed the command.
A typed protocol state machine makes invalid command sequences unrepresentable:
The system cannot apply an upgrade before configuration because the match on (current_phase, command) rejects it. The exhaustive match means there’s no way to accidentally leave a case unhandled.
RAII: Locks That Can’t Leak
The legacy system used file-based locks with no timeouts or heartbeats:
// If the process crashes while holding this lock, it's stuck forever
static async acquireConfigUpdateLock(dir: string): Promise<void> {
if (!(await acquireLock(dir, CONFIG_UPDATE_LOCK_NAME))) {
throw new AppError('Failed to acquire config update lock.');
}
// No timeout. No heartbeat. Crash = lock held forever.
}
In Rust, RAII (Resource Acquisition Is Initialization) makes forgotten locks a compile-time impossibility:
struct ConfigLock {
path: PathBuf,
acquired_at: Instant,
ttl: Duration,
}
impl Drop for ConfigLock {
fn drop(&mut self) {
// Automatically called when ConfigLock goes out of scope — even on panic!
let _ = std::fs::remove_file(&self.path);
}
}
async fn with_config_lock<T, F>(resource: &str, ttl: Duration, f: F) -> Result<T, LockError>
where F: FnOnce(&ConfigLock) -> Result<T, LockError>
{
let lock = acquire_lock(resource, ttl).await?;
f(&lock)
// lock dropped here automatically — file released no matter what
}
let result = with_config_lock("config-update", Duration::from_secs(30), |_lock| {
extract_bundle(&dir)?;
save_system(&dir)?;
Ok("deployed")
}).await?;
// Lock released here — even if any step panicked
The lock cannot leak because Drop::drop() runs when the guard goes out of scope and it’s a compiler guarantee.
Serialization: Schema Evolution as an ADT
The legacy heartbeat system used JSON serialization for 100,000+ metrics per heartbeat:
// JSON.parse for 100K metrics: ~500ms–1s
// With a 10s heartbeat interval, serialization alone eats 5–10% of your cycle time
// And there's no versioning — if the schema changes, old and new nodes break silently
With Rust enums, the protocol schema is defined once and versioning is a first-class concern:
enum HeartbeatMessage {
V1 { metrics: Vec<MetricV1> },
V2 { metrics: Vec<MetricV2>, deltas: Vec<DeltaMetric> }, // added delta support
}
// Schema evolution is an enum — every version must be explicitly handled
fn parse_heartbeat(data: &[u8]) -> Result<HeartbeatMessage, ParseError> {
let version = data[0];
match version {
1 => parse_v1(&data[1..]),
2 => parse_v2(&data[1..]),
_ => Err(ParseError::UnknownVersion(version)),
// Add v3? The compiler shows you every match that needs updating.
}
}
With protobuf or flatbuffers: zero-copy deserialization runs 10–100x faster than JSON. And schema evolution is no longer an afterthought and the enum ensures every protocol version is explicitly handled.
V. What Are Algebraic Effects?
ADTs solve the problem of representing valid states. Algebraic Effects solve a different but related problem: how to separate what code needs from how those needs are fulfilled without forcing that separation to infect every caller in the chain.
The Intuition: Exceptions That Can Resume
You already understand exceptions, e.g., when you throw, execution stops and the stack unwinds:
function getName() {
throw new Error("need a name"); // Execution stops. Stack unwinds. Gone.
}
try {
getName();
} catch (e) {
// We're here, but getName() is DEAD. We can't go back.
}
Now imagine if, instead of killing getName(), the handler could answer the question and let it continue:
function getName() {
const name = perform AskUser("What's your name?"); // Pause, don't die
return `Hello, ${name}`; // Continues after handler responds!
}
handle(getName(), {
AskUser: (question, resume) => {
const answer = prompt(question);
resume(answer); // Jump BACK into getName() with the answer
}
});
That’s algebraic effects in one sentence: exceptions that can resume. The code that performs an effect doesn’t die instead it pauses, gets an answer, and continues where it left off. You can think of it this way: regular exceptions are like quitting your job when you have a question. Effects are like asking your manager, you pause, they answer, you continue.
The Function Coloring Problem
Here’s why effects matter for real systems. Once a function is async, everything that calls it must also be async:
async function getConfig(): Promise<Config> { ... }
async function processEvent(e: Event): Promise<void> { // must be async because getConfig is
const config = await getConfig();
// ...
}
async function handleRequest(req: Request): Promise<Response> { // must be async because processEvent is
await processEvent(req.body);
// ...
}
One async function forces asyncness through the entire call stack. This is generally called “function coloring“, async and sync functions are different “colors” and they can’t mix freely. The same problem applies to error handling (once you use Result, every caller must handle it), to dependencies (once you need config, every caller must thread it through), and to logging (once you need a logger, every intermediate function must pass it along). Effects solve this by separating what a function needs from who provides it. Intermediate functions stay uncolored:
// With effects (conceptual syntax):
function getConfig(): Effect<ConfigService, Config> {
return perform GetConfig;
}
function processEvent(e: Event): Effect<ConfigService, void> {
const config = getConfig(); // NOT async! Just performs an effect.
transform(e, config);
}
// Only the TOP-LEVEL handler knows how config is provided:
handle(processEvent(event), {
GetConfig: (resume) => {
const config = loadFromDisk(); // or from env, or hardcoded for tests
resume(config);
}
});
processEvent doesn’t know or care whether config comes from disk, network, or a test fixture. The handler at the boundary decides. Intermediate functions don’t need to thread the dependency through.
You Already Use Effects
If you use React, you’re already working with algebraic effects in disguise. React Hooks are effects:
useState doesn’t tell the component where state lives. It performs an effect (“I need state”), and the React runtime acts as a handler and then provides it. The component doesn’t know if state is in memory, in a reducer, or synced to a server. React Suspense is literally “throw, then resume”:
// Simplified React Suspense:
function fetchData() {
if (!cache.has(key)) {
throw promise; // "perform Suspend" — throws a Promise UP the tree
// React catches it, shows fallback, waits for promise to resolve,
// then RE-RENDERS the component — effectively "resuming" it with data
}
return cache.get(key);
}
This is exactly the algebraic effects pattern: code performs an effect (throws a Promise), a handler catches it (the Suspense boundary), and the code is resumed (re-rendered) with the result. React couldn’t add real algebraic effects to JavaScript, so they simulated them with throw/re-render.
Everything Is the Same Control Flow Mechanism
Look at these seemingly different language features:
Feature
“Perform”
“Handle”
“Resume”
Exceptions
throw error
try/catch
? (can’t resume)
Async/Await
await promise
Runtime scheduler
Resolves with value
Generators
yield value
for..of consumer
.next(value)
React Hooks
useState()
React runtime
Re-render with state
DI Container
@Inject
Container config
Constructor call
Algebraic Effects
perform effect
handle block
resume(value)
They’re all the same pattern: (1) code declares “I need something,” (2) something up the call stack provides it, (3) execution continues with the provided value. Algebraic effects are just the general version that unifies all the others. The historical arc of control flow in programming languages tells the same story:
Each step gives more structured, more composable control over program flow.
The Monad Infection Problem
If you’ve used functional languages, you know what happens once you use Result, Option, Future, or IO as every function in the chain must return that type:
Once one function returns Result<T, E>, everything up the chain must acknowledge it. This is the same coloring problem as async just with error types. Effects solve this: the function just performs the effect, and a single handler at the top decides what to do. Intermediate functions stay clean.
For example, Jane Street’s hardware simulation team switched from monads to OCaml 5’s algebraic effects for exactly this reason. Their testbench code had to synchronize threads stepping through clock cycles. With monads, every function needed special let%bind syntax and couldn’t use normal OCaml features. With effects:
(* Business logic is PLAIN OCaml — no special syntax *)
let run_testbench () =
let clk = read_signal clock in
step (); (* "perform Step" — suspend until next clock cycle *)
let data = read_signal data_bus in
assert (data = expected);
step (); (* Step again — handler resumes us at next cycle *)
write_signal reset 1
(* Handler provides the simulation scheduler *)
let simulate circuit testbench =
match_with testbench () {
effc = (fun (type a) (eff : a Effect.t) ->
match eff with
| Step -> Some (fun (k : (a, _) continuation) ->
advance_circuit circuit; (* Tick the simulated hardware *)
continue k () (* Resume testbench at next line *)
)
)
}
The testbench reads like sequential code without monadic boilerplate. The step() call suspends execution, the handler advances the simulated hardware clock, and execution resumes.
Effects in Languages You Use Today
You don’t need OCaml 5 or Koka. Effects can be approximated in any language. In TypeScript using generator functions:
function* processEvent(event: RawEvent) {
const config = yield { effect: 'getConfig' }; // "perform GetConfig"
const enabled = yield { effect: 'checkFlag', flag: 'v2' }; // "perform CheckFlag"
yield { effect: 'log', msg: 'processing' }; // "perform Log"
return transform(event, config);
}
// Handler interprets the effects
function runWithHandler(gen, handlers) {
let result = gen.next();
while (!result.done) {
const effect = result.value;
const value = handlers[effect.effect](effect); // "resume with value"
result = gen.next(value);
}
return result.value;
}
// Production vs test — trivially swapped
const prodResult = runWithHandler(processEvent(event), productionHandlers);
const testResult = runWithHandler(processEvent(event), testHandlers);
You can’t test this without the real singleton. You can’t run different configurations in the same process. And the dependencies are invisible because you discover them at runtime via crashes. Effects-style DI (approximated with the Reader pattern in TypeScript):
type AppDeps = {
config: IConfigProvider;
metrics: IMetricsCollector;
flags: IFeatureFlags;
clock: IClock;
};
// Business logic is a pure function of its dependencies
function configurePipeline(deps: AppDeps) {
return (pipeline: PipelineConfig): Result<ConfiguredPipeline, ConfigError> => {
const features = deps.flags.getEnabled(pipeline.namespace);
const stages = pipeline.stages
.filter(s => features.includes(s.requiredFeature))
.map(s => buildStage(s, deps.config));
return { ok: true, value: { stages, configuredAt: deps.clock.now() } };
};
}
// Production wiring — one place, at startup
const production = configurePipeline({
config: new FileConfigProvider('/etc/app/config.yaml'),
metrics: new PrometheusCollector(),
flags: new LaunchDarklyFlags(apiKey),
clock: SystemClock,
});
// Tests — zero mocking frameworks needed
const test = configurePipeline({
config: { get: (key) => testDefaults[key] },
metrics: new NoOpCollector(),
flags: { getEnabled: () => ['all-features'] },
clock: { now: () => new Date('2024-01-01') },
});
In languages with native effect support (OCaml 5, Koka, Eff), this becomes even cleaner as intermediate functions don’t need to accept or pass deps at all. They just perform GetConfig and the handler provides the value.
Problem 2: Multiple Metrics Implementations
The legacy system had multiple parallel metrics implementations built by different teams, each with stringly-typed dimensions:
// different ways to record metrics, scattered across 17+ files
IMetricsStore
GlobalMetrics
IoMetricsMgr
DataInsightsMetricsMgr
LocalSearchMetricsReporter
// Plus per-class ad-hoc metrics: PeriodicStats, ConnectionMetrics, PacketReducer...
// Stringly-typed dimensions — typos produce SILENT missing metrics:
metrics.record(['id', prefixId, 'route', routeId]); // Swap any string? Silent wrong data.
Five implementations and seventeen files collapse into one typed effect that the compiler validates.
Problem 3: Auth Tokens Anyone Can Forge
The legacy system used a single shared HS256 symmetric token for ALL workers:
// All workers share the same symmetric auth secret
// HS256 symmetric means: every worker can FORGE admin tokens!
// No per-node identity. No revocation without rotating for ALL.
const isValid = authToken === this.masterAuthToken; // Raw secret comparison
With branded types, per-worker tokens become type-enforced:
type WorkerToken = string & { __brand: 'WorkerToken', workerId: WorkerId, scope: TokenScope };
type LeaderToken = string & { __brand: 'LeaderToken' };
type TokenScope =
| { kind: 'control_plane', permissions: ControlPermission[] }
| { kind: 'data_plane', routes: RouteId[] }
| { kind: 'metrics_only' };
// Functions declare what token scope they require
function deployConfig(token: WorkerToken & { scope: { kind: 'control_plane' } }): Result<...> {
// Can ONLY be called with a control-plane scoped token
// Data-plane tokens won't typecheck here
}
Now a compromised worker can’t forge admin tokens. The type system enforces token scope at compile time.
Problem 4: Control Flow Disguised as Errors
The legacy codebase used exceptions for control flow:
try {
for (const event of events) {
processEvent(event);
}
} catch (e) {
if (e instanceof SkipEventError) continue; // Control flow disguised as error!
if (e instanceof AppError) logger.warn(e);
if (e instanceof PipelineError) { ... }
// Unknown errors fall through and are silently swallowed
}
There were multiple error hierarchies (AppError, RESTError, RpcError, PipelineError) with no unified classification. With effects, control flow signals and failures are distinct and handled separately:
The business logic says “this event should be skipped” or “this operation failed transiently.” It doesn’t decide whether to retry, log, or dead-letter. That’s the handler’s job and handlers can be swapped independently.
Problem 5: No Circuit Breakers
The legacy system had no circuit breakers. When a downstream service failed, requests piled up until the process crashed:
dest.connect().catch(NOOP); // If it fails, try again next time. Or don't. Who knows.
// Retry with infinite loop and no idempotency:
while (true) {
try {
await writeToFile(...);
callback();
break;
} catch {
await delay(1000); // Retry forever. No backoff. No limit. No idempotency check.
}
}
With effects, retry and circuit-breaking become composable middleware:
type RetryPolicy =
| { kind: 'none' }
| { kind: 'fixed', attempts: number, delay: Duration }
| { kind: 'exponential', maxAttempts: number, baseDelay: Duration, maxDelay: Duration }
| { kind: 'circuitBreaker', failureThreshold: number, resetAfter: Duration };
// Circuit breaker itself is a state machine — an ADT!
type CircuitState =
| { kind: 'closed', failureCount: number }
| { kind: 'open', openedAt: Date, failureCount: number }
| { kind: 'halfOpen', testRequest: Promise<unknown> };
function circuitTransition(state: CircuitState, event: CircuitEvent): CircuitState {
switch (state.kind) {
case 'closed':
if (event.kind === 'failure') {
const newCount = state.failureCount + 1;
if (newCount >= threshold) return { kind: 'open', openedAt: new Date(), failureCount: newCount };
return { ...state, failureCount: newCount };
}
return { kind: 'closed', failureCount: 0 };
case 'open':
if (elapsed(state.openedAt) > resetTimeout) return { kind: 'halfOpen', testRequest: null };
return state;
case 'halfOpen':
if (event.kind === 'success') return { kind: 'closed', failureCount: 0 };
return { kind: 'open', openedAt: new Date(), failureCount: state.failureCount };
}
}
Notice: the circuit breaker itself is modeled as an ADT with exhaustive state transitions. ADTs model the state. Effects separate the retry policy from the code that needs retrying. Together they create systems that are both correct and composable.
VII. Design Thinking: Transformations Over Entities
Here’s an insight that ties everything together: design the transformations first, then the things being transformed. A system’s architecture is defined by how data flows, not by what objects exist.
The God Class Problem: Architecture You Can’t See
// A pipeline manager — 1,300+ lines, 80+ methods
class PipelineManager {
process(event: any) {
if (this.shouldFilter(event)) return; // filtering concern
this.metrics.increment('processed'); // observability concern
const result = this.transform(event); // transformation concern
this.route(result); // routing concern
this.metrics.recordLatency(start); // observability again
}
}
The architecture is invisible. Everything is tangled. You can’t test transformation without routing. You can’t add observability without modifying the pipeline. When you model the same thing as typed functions, the architecture becomes visible:
// Each stage is a typed function with a clear input/output contract
fn parse(raw: RawEvent) -> Result<ParsedEvent, ParseError> { ... }
fn validate(parsed: ParsedEvent) -> Result<ValidEvent, ValidationError> { ... }
fn enrich(valid: ValidEvent) -> Result<EnrichedEvent, EnrichError> { ... }
fn route(enriched: &EnrichedEvent) -> RoutingDecision { ... }
// Composition IS the architecture — visible, testable, reorderable
fn process_event(raw: RawEvent) -> Result<EnrichedEvent, PipelineError> {
let parsed = parse(raw)?;
let valid = validate(parsed)?;
let enriched = enrich(valid)?;
Ok(enriched)
}
// Cross-cutting concerns are separate composable wrappers
let pipeline = WithMetrics::new("pipeline", process_event);
let pipeline = WithFilter::new(filter_config, pipeline);
let pipeline = WithRouting::new(route_table, pipeline);
Each stage is independently testable. Adding observability doesn’t touch business logic. Reordering is just reordering function composition. The types document the flow: RawEvent --> ParsedEvent --> ValidEvent --> EnrichedEvent. This is what “the arrows are the architecture” means the transformations between types are the system’s behavior.
Rust’s ? Is Railway-Oriented Programming Built In
Think of data processing as a railway with two tracks: success and failure. Data flows along the success track until something goes wrong then it switches to the failure track and skips all remaining stages:
// Each ? is a branch point onto the failure track
fn process_event(raw: RawEvent) -> Result<ClassifiedEvent, PipelineError> {
let parsed = parse(raw)?; // fails? switch to error track
let valid = validate(parsed)?; // fails? switch to error track
let enriched = enrich(valid)?; // fails? switch to error track
let classified = classify(enriched)?;
Ok(classified)
}
// Each piece tested in isolation:
#[test]
fn parse_handles_malformed_json() {
let result = parse(RawEvent::new("not json"));
assert!(matches!(result, Err(PipelineError::MalformedInput { .. })));
}
Rust’s ? operator is this pattern built into the language syntax. No special library, no monadic boilerplate and the language itself is railway-oriented.
Thinking in Transformations
Not all transformations are the same. Knowing which kind you’re building helps you choose the right pattern:
One-to-one (parsing, validation): every input produces exactly one output. These compose directly: parse >> validate >> enrich.
One-to-many (fan-out, splitting): one input produces multiple outputs. Use flatMap or stream splitting, one log line becomes multiple metrics events.
Many-to-one (aggregation): multiple inputs combine into one. Use windowed reduce, 1000 metric samples become a single P99 value.
Reversible (encoding, encryption): can be undone without loss. Good for serialization boundaries where you need to cross system edges.
Self-directed (state transitions): transforms a value into another of the same type. State machines are exactly this, e.g., State --> State. An ADT enum is the natural representation.
The legacy PipelineManager muddled all five together in one class. Separating them makes each stage’s contract explicit and independently testable.
Measuring Coupling Through Connections
Here’s a concrete way to see how much a legacy architecture costs. Count the connections:
Point-to-point (legacy): N services = N × (N-1) / 2 connections
10 services = 45 connections
20 services = 190 connections
50 services = 1,225 connections ? quadratic growth
Data-oriented: N services = N connections (each talks to a shared typed data layer)
10 services = 10 connections
20 services = 20 connections
50 services = 50 connections ? linear growth
The legacy system’s 125+ endpoints each know about each other implicitly through shared singletons, events, and direct calls. Adding endpoint #126 means understanding what it might break in endpoints #1–125.
With a data-oriented approach, each component only needs to understand the shared data schema instead of every other component. The tradeoff: schema design becomes your hardest decision. Data outlives code. You can rewrite a service in a weekend, but migrating a billion records takes months. Get the ADTs right before committing.
Stratified Design: Layers by Rate of Change
Within the functional core, code should be layered by how often it changes:
The legacy codebase had hundreds of imperative accumulation loops:
// Legacy: imperative accumulation (hundreds of instances)
const results = [];
for (const worker of workers) {
if (worker.isActive()) {
const metrics = await worker.getMetrics();
if (metrics.cpuUsage > threshold) {
results.push({ workerId: worker.id, cpu: metrics.cpuUsage });
}
}
}
Iterator combinators express the same thing as a pipeline with each step is independently readable and testable:
// Declare WHAT, not HOW
let results: Vec<_> = workers.iter()
.filter(|w| w.is_active())
.filter_map(|w| {
let metrics = w.get_metrics();
(metrics.cpu_usage > threshold).then(|| OverloadedWorker {
worker_id: w.id.clone(),
cpu: metrics.cpu_usage,
})
})
.collect();
You can add or remove a stage without restructuring any loop. Each step in the chain has a clear type. And for a 1,200-line initialization sequence, the same idea applies:
// Instead of 1,200 lines of sequential initialization with implicit ordering:
let server = ServerBuilder::new(env)
.with_logging()?
.with_metrics()?
.with_storage()?
.load_pipelines()?
.with_health_check()?
.bind_endpoints()?
.build();
// Each method returns the next builder phase.
// Ordering is explicit in the chain — not hidden at line 847.
// ? propagates errors cleanly — no nested try/catch.
Reactive Patterns: Derived State That Can’t Go Stale
The legacy codebase had derived values that went stale because updates were manually tracked:
class Dashboard {
private totalEvents = 0; // must remember to update
private avgLatency = 0; // must remember to update
private activeWorkers = 0; // must remember to update
onMetric(metric) {
this.totalEvents++;
// avgLatency updated... somewhere else. Maybe. If someone remembers.
}
}
The reactive pattern (the same idea behind React, Redux, and spreadsheets) makes derived values automatic:
// Source cells (the inputs you can change)
const events = createCell<EventLog>([]);
const workers = createCell<Worker[]>([]);
// Derived formulas (automatically recompute when inputs change)
const totalEvents = formula(() => events.get().length);
const activeWorkers = formula(() => workers.get().filter(w => w.isActive()).length);
const avgLatency = formula(() => {
const recent = events.get().slice(-1000);
return recent.reduce((sum, e) => sum + e.latency, 0) / recent.length;
});
// Can NEVER be stale — recomputes automatically when inputs change
// "Forgot to update" bugs are impossible
This is ValueCell (a mutable input) and FormulaCell (a derived computation) are the two primitives behind every reactive system from spreadsheets to React.
VIII. The Bigger Framework: Actions, Calculations, Data
Everything covered so far fits into a simple three-way classification from Eric Normand’s book Grokking Simplicity:
type WorkerState = { kind: 'idle' } | { kind: 'configuring', request: ClusterRequest };
type JobEvent = { kind: 'started', workerId: string, at: Date };
Calculations: Pure functions. Same input always produces the same output. No side effects. Safe to call anywhere, anytime, as many times as you want.
function deriveState(events: JobEvent[]): JobState { ... }
function validate(event: RawEvent): Result<ValidEvent, ValidationError> { ... }
Actions: Depend on when or how often they run. I/O. Time. Network. The dangerous stuff.
async function saveToDatabase(state: JobState): Promise<void> { ... }
async function sendMetrics(metrics: Metric[]): Promise<void> { ... }
The legacy system had roughly 80% Actions, 15% Mixed (calculations that accidentally touched singletons or Date.now()), and 5% pure Calculations. The target is the Functional Core, Imperative Shell pattern:
The core is pure: no I/O, no time, no randomness. It takes Data in and produces Data out. It’s trivially testable, trivially parallelizable (no shared state), and trivially composable. The shell is thin, it translates between the real world and the pure core. Every antipattern in the legacy codebase came from violating this boundary: singletons injecting Actions into Calculations, mutable state making “pure” functions depend on timing, mixed I/O making business logic untestable without the full system running.
Consistent API Responses as Typed Envelopes
The legacy system had 125+ endpoints with inconsistent response formats:
GET /system/inputs ? { items: IInput[] }
GET /system/outputs ? IOutput[] // No wrapper!
GET /jobs ? PaginatedListResults<IJob> // Different wrapper!
// Error formats inconsistent too:
throw new RESTError(JSON.stringify(data), code); // JSON string as message!
throw new RESTError('Not found', 404);
throw new RESTError('Not found', 400); // Wrong status code!
A typed response envelope makes inconsistency a compile error:
type ApiResponse<T> =
| { ok: true, data: T, meta?: PaginationMeta }
| { ok: false, error: ApiError };
type ApiError = {
code: ErrorCode; // Typed enum, not arbitrary string
message: string;
details?: FieldError[];
traceId: TraceId; // Branded — always present for debugging
};
// Both return the same shape. Always. Compiler enforces it.
function listInputs(req: Request): ApiResponse<Input[]> { ... }
function listOutputs(req: Request): ApiResponse<Output[]> { ... }
IX. Let Compiler Work for You
The compiler catches bugs in seconds. Tests catch them in minutes. Staging catches them in hours. Production catches them over days of incident response, root cause analysis, and post-mortems. The math is simple. Investing time in better types eliminates entire categories of bugs that would each cost 10-100x more downstream.
X. When NOT to Use This
These patterns aren’t universally optimal.
Don’t use ADTs when you’re still exploring. When you don’t know yet what the valid states ARE, encoding them as sum types locks you in prematurely. Start with loose types, discover the states through testing, then lock them down.
Don’t use ADTs for simple CRUD with few states. A blog post with {title, body, published} doesn’t need Draft | Published | Archived. If the state space is small and obvious, a boolean is fine.
Don’t use full effects systems in hot paths. Effect handlers add indirection. In inner loops processing millions of events per second, direct function calls beat effect dispatch. Use effects at the boundary, direct calls in the hot path.
Don’t adopt effects before your team understands them. If your team has never seen algebraic effects, introducing them when new Service(deps) works fine creates confusion without proportional benefit. The approximations (Reader pattern, context variables) are a gentler on-ramp.
The adoption gradient, from easiest to hardest:
Easy (adopt today):
Boolean pairs ? sum types (just types, zero learning curve)
.catch(NOOP) ? explicit handling (mindset shift only)
Medium (team discussion needed):
Singletons ? parameter injection (changes constructor signatures)
Imperative loops ? map/filter/reduce (functional style shift)
Hard (architectural decision):
Shared state ? actors/channels (concurrency model change)
Mixed I/O ? functional core/shell (structural refactor)
Full effect systems (new paradigm)
Start at the top. Each level delivers value independently. You don’t need to reach the bottom to benefit.
XI. The Migration Path (Incremental, Not Big Bang)
You don’t need to rewrite your system. Here’s the step-by-step path.
Step 1: Boolean pairs –> sum types (minutes per instance)
// Before
let isConnected: boolean;
let isAuthenticated: boolean;
// After
enum ConnectionState {
Disconnected,
Connected { socket: TcpStream },
Authenticated { socket: TcpStream, token: AuthToken },
}
Step 2: Find every .catch(NOOP) and make a decision: Each one is a decision point: should it retry, log, propagate, or recover? At minimum, log it. Better: make it a Result so callers know.
Step 3: Singletons ? constructor parameters (one file at a time): Pick one singleton-using class. Pass the dependency as a constructor parameter instead of hunting for it globally. Test it with a stub.
Step 4: Centralize mode checks before eliminating them: Before you can replace 506 scattered mode checks, you need mode determination in ONE place:
// Step 1: Create the union type
type AppMode = { kind: 'leader', ... } | { kind: 'worker', ... } | ...;
// Step 2: Determine mode ONCE at startup
const mode: AppMode = determineMode(process.env);
// Step 3: Pass mode to subsystems — then replace checks one at a time
Step 5: Shared mutable state ? channels (one boundary at a time): Identify shared mutable state accessed by multiple async operations. Introduce a channel wrapper and don’t rewrite everything at once.
Step 6: New features go in first (pure core, then I/O): For every new feature, write the business logic as pure functions. Push all I/O to the boundaries.
What’s Available in Your Language Today
Language
Sum Types
Exhaustiveness
Result Type
Pattern Matching
Rust
enum (first-class)
Built-in, enforced
Result<T, E> + ?
match (exhaustive)
TypeScript
Discriminated unions
never check
Custom or fp-ts
switch + narrowing
Swift
enum with associated values
Built-in
Result<T, E>
switch
Kotlin
Sealed classes
when exhaustive
Result / Either
when
Java 17+
Sealed interfaces + records
Switch expressions
Custom or vavr
Pattern matching (21+)
Python 3.10+
@dataclass unions
match (partial)
Custom or returns
match statement
Go
Interface + type switch
No built-in
(T, error) tuple
Type assertions
Rust stands out because it was designed around these patterns: first-class ADTs, mandatory exhaustive matching, built-in Result/Option with the ? operator, ownership-based concurrency safety, and zero-cost newtypes. But you can apply these ideas in any language as the patterns are about thinking, not syntax.
XII. The Three Laws
All of this comes down to three principles:
If it can’t be represented, it can’t happen. Illegal states that don’t exist in the type system are bugs that don’t exist in production.
If it must be handled, it will be handled. When the compiler forces you to address every variant, every error, every edge case then nothing slips through.
If it’s composed from tested parts, the composition is tested. Pure functions that individually work correctly compose into pipelines that work correctly. No emergent failure modes from unexpected interactions.
Conclusion: Architecture as Enforcement
The legacy system I analyzed had documentation describing its intended architecture. It had design reviews. It had coding guidelines. None of it prevented 441 silent error swallows, 64-state boolean explosions, race conditions in shared mutable state, 5 redundant metrics implementations, or a shared auth token that let any worker forge admin credentials. Documentation describes intent. Tests verify behavior at a point in time. But types enforce invariants continuously on every line of code, in every file, for every developer, for the entire lifetime of the codebase.
ADTs make impossible states unrepresentable. Algebraic effects separate mechanism from policy. Together, they transform architecture from aspiration into enforcement. The compiler doesn’t take vacations. It doesn’t forget edge cases. In a world of distributed systems, concurrent operations, and ever-growing complexity, that’s not just good engineering practice, it’s the only approach that scales.
How automated reasoning with Dafny and TLA+ reduces review burden, catches subtle bugs, and gives you a principled way to resist the pressure to ship without thinking
The core problem: AI-generated code is probabilistic, and at scale, probability catches up with you. Based on Brooks’ Mythical Man-Month breakdown, coding itself is roughly 14% of the software delivery process. Agentic AI has largely solved that 14%. It writes clean, well-formatted, plausible code faster than any human. But plausible is not the same as correct. And when you generate 10× more code, the other 86% of your pipeline like design, specification, review, testing, deployment doesn’t automatically scale with it. I keep watching three failure modes play out:
Hallucinations scale with complexity. An AI writing a 50-line function gets it right most of the time. An AI building a major feature in a large codebases with dozens of modules operators more probabilistically. It produces shallow modules instead of deep ones, duplicates logic, and makes locally correct decisions that violate global invariants. The code looks fine at the file level but the system breaks at the integration level.
Review becomes the bottleneck. When one engineer’s code output multiplies by 10×, review bandwidth doesn’t scale with it. I’ve watched teams respond in two ways: slow everything down to match review capacity, or cut the review process to maintain throughput. Amazon learned what cutting review does to production reliability. It’s not a lesson you want to repeat.
AI-generated code is harder to review than messy code. This is the counterintuitive one. Bertrand Meyer’s article AI for Software Engineering: From Probable to Provable names it precisely: clean, well-structured AI code creates a psychological safety bias. You stop reading as carefully. The concurrency bug in elegant code is harder to spot than the same bug in obviously messy code.
The Pressure to Abandon Quality
I have observed the organizational pressure to treat 10× code output as “just faster developers” and to cut the review, specification, and verification processes accordingly. I’ve seen executive pressure to eliminate code review entirely, to lay off senior engineers who “just do reviews,” to skip integration testing because “the AI tested it.”
This is exactly backwards. When code output increases 10×, the need for rigorous verification increases proportionally not decreases. Joe Mager’s Monte Carlo simulation of agentic coding pipelines quantifies that at a defect rate of 1-in-40 commits with a 12-hour pipeline, you get 0.7% deployment success, essentially deadlock. He calls the safe zone the “valley of calm”: the region where defect rate × pipeline duration stays well below 1.
Formal verification is the tool that keeps you in the valley. It doesn’t slow the generative side down and the AI still generates code fast. It gates the output mathematically, so you catch invariant violations before they reach production rather than after. The practical solution is a triple-engine pipeline: a generative engine (the LLM) that produces implementations fast, a verification engine (Dafny/TLA+/Z3) that proves correctness mathematically, an AI assisted specification engine where LLMs write the loop invariants, lemma stubs, and preconditions that feed the verifier. Human engineers own the intent and specification: what invariants matter, what correctness means in the problem domain. AI assists on all three layers. This post shows how to build that pipeline using a real RBAC system as the example. The companion repository is at github.com/bhatti/automated-reasoning.
From Logic AI to LLMs and Back
Modern LLMs work by predicting the next token, i.e., statistical, probabilistic, pattern-matching at scale. But AI didn’t start here. The dominant AI paradigm from the 1970s through the 1990s was symbolic and logical: knowledge representation, inference engines, expert systems, formal reasoning. We went from logic to probability and now we need both.
timeline
title AI Paradigms and Verification Approaches
section Logic Era (1970s–1990s)
1972 : Prolog — logic programming and knowledge representation
1979 : Boyer-Moore theorem prover
1986 : Eiffel introduces Design by Contract
1987 : TLA created by Leslie Lamport
section Hybrid Era (2000s–2010s)
1999 : Z3 SMT solver (Microsoft Research)
2005 : Alloy model finder
2009 : Dafny created (Microsoft Research)
2014 : TLA+ used at AWS for S3 and DynamoDB
section LLM Era (2020s)
2022 : ChatGPT and Copilot — probabilistic code generation goes mainstream
2024 : Agentic coding — 10× code throughput becomes normal
2025 : Spec-driven development movement emerges
2026 : Formal verification as AI guardrail
Understanding this history matters for a practical reason: the tools from the logic era didn’t disappear when LLMs arrived. They got faster, more automated, and better integrated into real development workflows. The question today isn’t “logic or probability?”, it’s “how do we combine them?”
Prolog and Logic Programming
Prolog (1972) represents knowledge as facts and rules, then uses unification and backtracking to answer queries. For authorization policy, you write the what, not the how:
Bertrand Meyer’s Eiffel language introduced Design by Contract (DbC): every method carries a formal contract such as preconditions, postconditions, class invariants that the runtime checks. I’ve been a fan of this approach for a long time, because it encodes intent alongside code rather than hoping a test suite happens to cover the right cases. DbC influenced:
Clojure: pre/post condition maps on functions
Ada/SPARK: formal proof obligations on subprograms
Java/C++: assert statements (though almost nobody enables them in production, which defeats the point)
Go: convention-based precondition checks that panic or return errors
Dafny: compile-time verification of contracts
The key DbC insight that gets lost in most production codebases: assertions should always be enabled in production. They’re not test-time scaffolding. They’re executable specifications that catch invariant violations the moment they occur, including input combinations no test ever anticipated.
The Verification Spectrum
Here’s how I think about the tools available, from informal to formally proven:
Loop invariants, lemmas, and annotations generated by LLMs,
Low–Medium
dafny-annotator, LLM + Dafny
The progression is from probabilistic to provable. The top rows test specific cases and find bugs. The bottom rows prove properties over all possible inputs and make entire classes of bugs impossible. AI-generated code needs both sides of this spectrum. Tests give you practical coverage fast. Proofs give you guarantees that no test suite can match. But here’s the catch I keep running into: when tests are also generated by AI, they may test the wrong thing as they optimize for passing, not for correctness. Formal specifications are the antidote. They state what correct is, mathematically, so even wrongly generated tests get caught when they conflict with the spec.
Automated Reasoning: The Technical Foundation
Before diving into code, let me explain what automated reasoning means. Automated reasoning means using software to answer mathematical questions about other software, without running it. Three activities matter here:
Control flow analysis: what execution paths can the code take?
Invariant discovery: what conditions hold regardless of which path it takes?
Property verification: given a specification, does the code satisfy it for all inputs?
The critical distinction from testing: testing checks that specific inputs produce expected outputs. Automated reasoning proves that a property holds for every input the program could ever receive.
SAT: Boolean Satisfiability
The foundation is SAT (Boolean Satisfiability): given a formula with boolean variables, can you assign true/false values to satisfy all constraints simultaneously?
Example: (A v B) ^ (¬A v C) ^ (¬B v ¬C)
SAT solver: A=true, B=false, C=true
SAT is NP-complete in theory but practically fast with modern CDCL (Conflict-Driven Clause Learning) solvers. Industrial solvers handle millions of variables routinely.
SMT: Satisfiability Modulo Theories
SMT extends SAT with theories and first-class reasoning about integers, real numbers, arrays, bitvectors, and strings. Where SAT works with booleans, SMT works with the kinds of values programs actually use:
(assert (= (+ x y) 10))
(assert (> x 3))
(assert (> y 3))
(check-sat)
--> sat; x=4, y=6
AWS runs SMT at extraordinary scale. Their Zelkova system runs a billion SMT queries per day to analyze IAM access policies. Zelkova encodes IAM policies as logical formulas and feeds them to Z3 and CVC4. The FMCAD 2018 paper describes how policies translate to first-order logic with string theories and how incremental SMT solving makes this practical at scale.
Constraint Logic Programming (CLP)
CLP extends logic programming with constraint domains. Rather than enumerating solutions by hand, you declare variables, domains, and constraints, and the solver searches:
from ortools.sat.python import cp_model
model = cp_model.CpModel()
x = model.new_int_var(0, 10, 'x')
y = model.new_int_var(0, 10, 'y')
model.add(x + y == 10)
model.add(x > 3)
model.add(y > 3)
solver = cp_model.CpSolver()
solver.solve(model) # finds x=4, y=6
Memory safety: Rust’s type system, Verus, or Dafny ghost state
One finding from AWS’s work that surprises most people: formal verification often makes systems faster, not just safer. Their IAM authorization engine got a 50% performance improvement after verification as the process of proving correctness forced developers to eliminate redundant computation and latent bugs that happened to be performance bottlenecks. The S3 index subsystem moved from quarterly to monthly releases after applying automated reasoning. This directly addresses the organizational pushback: verification doesn’t slow you down.
Spec-Driven Development: The Movement Behind the Tools
The insight that specifications instead of code should drive AI development has gained serious traction. Projects like OpenSpec and Spec-Kit formalize this workflow. My own you-got-skills SDLC skills set encodes it: structured workflows for PRD refinement, TRD review, architecture, work breakdown, implementation, and formal QA where AI operates within human-defined constraints rather than inventing its own.
The spec-driven philosophy is: make invalid implementations unrepresentable. You can do this through types (Rust, Haskell), contracts (Eiffel, Dafny), or formal models (TLA+). When your specification is precise enough, AI hallucinations become immediately visible as verification failures rather than subtle production bugs. The verifier catches them instead of the reviewer or the on-call engineer at 2am. Formal verification shifts the time from debugging production incidents to writing specifications that make the rest of the process faster and more predictable.
TLA+ for Concurrency
I covered TLA+ extensively in my earlier post about an year go. I’ll show a targeted example specific to RBAC: the concurrent policy update problem.
The scenario: two admins simultaneously assign roles to the same principal. Without coordination, a check-then-act race violates Separation of Duty (SoD):
Admin A: check(submitter) --> no conflict --> intend to assign
Admin B: check(approver) --> no conflict --> intend to assign
Admin A: assign(submitter) OK
Admin B: assign(approver) <-- SoD violated: both roles now held
The TLA+ spec models an optimistic-locking protocol and asks TLC to exhaustively check that SoD is never violated:
For the rest of this post I focus on Dafny, since I already covered TLA+ in depth and Dafny is where I spend most of my verification time now.
Dafny: Practical Deductive Verification
Dafny is a verification-aware programming language from Microsoft Research. It sits in the practical sweet spot: more powerful than static analyzers, far less manual effort than Coq or Lean. Dafny uses Z3 under the hood and verifies many programs automatically without manual proof steps. Importantly for this post, Dafny compiles to Go so you write your specifications in Dafny, your implementations in Go, and the type system and contracts carry through naturally.
Programming language: loops, classes, generics, standard data structures
Proof assistant: write lemmas and Dafny proves them automatically
Program verifier: attach requires/ensures to methods and Dafny proves they hold for all possible inputs
Design by Contract in Dafny
method Divide(a: int, b: int) returns (result: int)
requires b != 0 // precondition: caller must ensure this
ensures result * b == a // postcondition: callee guarantees this
{
return a / b;
}
If you call Divide(10, 0), Dafny rejects it at compile time not at runtime. That’s the shift from “testing catches bugs” to “bugs can’t be expressed.”
The Example: An RBAC System
I chose RBAC because it’s rich enough to demonstrate real verification value without being contrived. The companion project is a simplified version of my saas_rbac project. The domain model has six entities:
Why RBAC? It exhibits four bug classes that AI-generated code routinely gets wrong and each maps cleanly to a formal property:
Type safety: dangling references, e.g., a principal in org A assigned a role that references a resource in org B
Structural safety: role hierarchy must be a DAG, e.g., cycles cause infinite loops during claim resolution
Security safety: policy evaluation must be sound (no phantom permissions) and complete (no missed permissions)
Conflict safety: Separation of Duty must hold after every role assignment, including the symmetric direction AI almost always misses
Each of these is a property I state once in Dafny and prove once rather than hoping a test suite happens to exercise the right edge cases.
Step 1: Types and the System-Wide Invariant (rbac_types.dfy)
The first thing I write isn’t any method, it’s the ValidStore predicate: the system-wide invariant that every operation must preserve. Writing it out forces you to articulate what “correct state” actually means before writing a single line of logic.
predicate ValidStore(s: RBACStore) {
// No dangling principal references
&& (forall id :: id in s.principals ==>
s.principals[id].orgId in s.orgs)
// Tenant isolation: role parents must be in same org
&& (forall rid :: rid in s.roles ==>
(forall pid :: pid in SeqToSet(s.roles[rid].parentIds) ==>
s.roles[pid].orgId == s.roles[rid].orgId))
// Claim resources must exist in the store
&& (forall rid :: rid in s.roles ==>
(forall c :: c in s.roles[rid].claims ==>
c.resourceId in s.resources))
// ... (8 more invariants)
}
A lemma proves the empty store satisfies it and Dafny verifies this automatically with no manual proof steps:
The value here isn’t the lemma but it’s the discipline the predicate imposes. When you have to state every invariant precisely before writing code, the class of bugs you can introduce narrows dramatically. Every subsequent method carries requires ValidStore(s) and ensures ValidStore(result) and Dafny enforces this chain automatically.
Step 2: Policy Evaluation (rbac_policy.dfy)
The two most critical properties of any authorization system:
SOUNDNESS: If Evaluate returns Allow, a valid claim chain EXISTS.
No phantom permissions. No false positives.
COMPLETENESS: If a valid claim chain exists, Evaluate returns Allow.
No missed permissions. No false negatives.
I write the ground-truth specification as a pure, non-executable predicate, then verify that the executable method matches it exactly:
// The specification — states what "correct" means mathematically
predicate PolicySpec(req: Request, store: RBACStore, ctx: EvalContext) {
var principal := store.principals[req.principalId];
exists c :: c in PrincipalClaims(principal, store.roles) &&
ClaimGrants(c, req.action, req.resourceId, ctx)
}
// The implementation — Dafny proves it matches PolicySpec for all inputs
method Evaluate(req: Request, store: RBACStore, ctx: EvalContext)
returns (decision: Decision)
requires ValidStore(store)
requires req.principalId in store.principals
requires store.principals[req.principalId].orgId == req.orgId
ensures decision == Allow ==> PolicySpec(req, store, ctx) // SOUNDNESS
ensures decision == Deny ==> !PolicySpec(req, store, ctx) // COMPLETENESS
Dafny verifies the loop implementation with a loop invariant that tracks “no match found in claims[0..i]”:
while i < |claimSeq|
invariant decision == Deny ==>
forall j :: 0 <= j < i ==>
!ClaimGrants(claimSeq[j], req.action, req.resourceId, ctx)
decreases |claimSeq| - i
{
if ClaimGrants(claimSeq[i], req.action, req.resourceId, ctx) {
decision := Allow;
return;
}
i := i + 1;
}
The decreases clause proves termination and Dafny guarantees no infinite loops, for any input. When AI generates the implementation, if it introduces a subtle loop condition bug, Dafny catches it immediately rather than at a production incident. A bonus lemma proves monotonicity and adding claims can never turn an Allow into a Deny:
lemma MoreClaimsMonotonic(req, store1, store2, ctx)
requires store1 has subset of claims of store2
ensures PolicySpec(req, store1, ctx) ==> PolicySpec(req, store2, ctx)
Step 3: Role Hierarchy with No Cycles (rbac_role_hierarchy.dfy)
AddParent proves that cycles can never be introduced, regardless of what sequence of operations an API caller attempts:
method AddParent(child: RoleId, parent: RoleId, roles: map<RoleId, Role>)
returns (result: map<RoleId, Role>, ok: bool)
requires NoCycles(roles)
ensures ok ==> NoCycles(result) // DAG invariant always preserved
ensures !ok ==> result == roles // rejection leaves the store unchanged
{
var wouldCycle := child in Ancestors(parent, roles, |roles|);
if wouldCycle { return roles, false; }
// safe to add the parent edge
}
Ancestors computes the full ancestor set with bounded recursion, e.g., fuel of |roles| is sufficient for any valid DAG. This is a property that’s easy to state but extremely hard to test exhaustively: you’d have to enumerate all possible role graph topologies. Dafny proves it once, for all possible graphs.
Step 4: Separation of Duty (rbac_separation_of_duty.dfy)
SoD says certain role pairs must never be co-assigned and you can’t be both the invoice submitter and the invoice approver. The subtle bug AI code routinely misses is the symmetric case: checking (existing, new) but not (new, existing). This is exactly the kind of off-by-one semantic error that looks correct on inspection and only surfaces in edge-case inputs.
predicate SoDSatisfied(assignedRoles: set<RoleId>, conflicts: ConflictSet) {
forall a, b ::
a in assignedRoles && b in assignedRoles && a != b ==>
(a, b) !in conflicts
}
method AssignRole(principal, newRole, conflicts)
requires SoDSatisfied(SeqToSet(principal.roleIds), conflicts)
ensures ok ==> SoDSatisfied(SeqToSet(updated.roleIds), conflicts)
ensures !ok ==> exists existing ::
existing in SeqToSet(principal.roleIds) &&
(existing, newRole) in conflicts // proof witness for why it was rejected
Here’s what Dafny outputs when an AI generates the broken version that only checks one direction:
rbac_separation_of_duty.dfy(42,4): Error: a postcondition could not be proved
ensures ok ==> SoDSatisfied(SeqToSet(updated.roleIds), conflicts)
Counterexample:
principal.roleIds = ["approver"]
newRole = "submitter"
conflicts = {("submitter", "approver")} ? (new, existing) direction missed
That counterexample shows exactly which input violates the contract, with a concrete example. Without Dafny, catching this requires either a carefully targeted test case or it shows up in production when someone discovers SoD can be bypassed by using conflict pairs in reverse order.
Constraints make RBAC dynamic like time windows, geo fences, usage quotas. The key properties to prove are:
// Adding constraints can only reduce access, never increase it
lemma AddingConstraintReducesAccess(base, extra, ctx)
ensures AllConstraintsHold(base + [extra], ctx) ==>
AllConstraintsHold(base, ctx)
// Empty constraint list always passes (vacuous truth — no constraints = no restrictions)
lemma EmptyConstraintsAlwaysHold(ctx)
ensures AllConstraintsHold([], ctx)
{}
// Higher usage makes quota constraints harder to satisfy
lemma HigherUsageHarder(limit, usage1, usage2, ctx)
requires usage1 <= usage2
ensures ConstraintHolds(MaxUsage(limit), ctx[usage:=usage2]) ==>
ConstraintHolds(MaxUsage(limit), ctx[usage:=usage1])
These seem obvious. They are exactly the properties that break when AI generates constraint evaluation with subtle off-by-one errors or a flipped inequality direction (>= instead of >). Proving them once means you catch the implementation error in the Go translation from a failed test instead of production from an access control bypass.
The Go Implementation: Verified by Construction
The Go implementation translates the Dafny specifications directly. Every design decision traces back to a proved property.
Types Mirror Dafny Datatypes
// go/pkg/types/types.go
type Claim struct {
ID ClaimID
Action string
ResourceID ResID
Constraints []Constraint // empty = always passes (vacuous truth, proved by EmptyConstraintsAlwaysHold)
}
// NewTimeWindow enforces the Dafny precondition ValidConstraint at construction time
func NewTimeWindow(start, end int) (Constraint, error) {
if start >= end || end > 24 {
return Constraint{}, fmt.Errorf("invalid time window: start < end <= 24 required")
}
return Constraint{Kind: TimeWindowKind, StartHour: start, EndHour: end}, nil
}
// NewGeoFence — Dafny requires |regions| > 0
func NewGeoFence(regions []string) (Constraint, error) {
if len(regions) == 0 {
return Constraint{}, fmt.Errorf("geo fence requires at least one region")
}
return Constraint{Kind: GeoFenceKind, Regions: regions}, nil
}
// NewMaxUsage — Dafny requires limit > 0
func NewMaxUsage(limit int) (Constraint, error) {
if limit <= 0 {
return Constraint{}, fmt.Errorf("max usage limit must be positive")
}
return Constraint{Kind: MaxUsageKind, MaxCount: limit}, nil
}
Constraint Evaluation Maps Directly to Dafny
// go/pkg/constraints/constraints.go
// Holds mirrors Dafny's ConstraintHolds predicate exactly.
// Every case corresponds to a branch in the Dafny match expression.
func Holds(c types.Constraint, ctx types.EvalContext) bool {
switch c.Kind {
case types.TimeWindowKind:
// Dafny: ctx.currentHour >= c.startHour && ctx.currentHour < c.endHour
return ctx.CurrentHour >= c.StartHour && ctx.CurrentHour < c.EndHour
case types.GeoFenceKind:
// Dafny: ctx.currentRegion in c.allowedRegions
for _, r := range c.Regions {
if r == ctx.CurrentRegion {
return true
}
}
return false
case types.MaxUsageKind:
// Dafny: ctx.currentUsage < c.maxCount
return ctx.CurrentUsage < c.MaxCount
default:
return false
}
}
// AllHold evaluates a conjunction of constraints.
// Dafny proved: AllConstraintsHold([], ctx) == true (EmptyConstraintsAlwaysHold)
// Dafny proved: AllConstraintsHold(base + [extra], ctx) ==> AllConstraintsHold(base, ctx)
func AllHold(cs []types.Constraint, ctx types.EvalContext) bool {
for _, c := range cs {
if !Holds(c, ctx) {
return false
}
}
return true
}
Role Hierarchy: BFS with Proven Cycle Detection
// go/pkg/hierarchy/hierarchy.go
// HasCycle returns true if adding parent to child would create a cycle.
// Mirrors Dafny: child in Ancestors(parent, roles, |roles|)
func (r *Resolver) HasCycle(child, parent types.RoleID) bool {
visited := map[types.RoleID]bool{}
queue := []types.RoleID{parent}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if current == child {
return true
}
if visited[current] {
continue
}
visited[current] = true
if role, ok := r.roles[current]; ok {
queue = append(queue, role.ParentIDs...)
}
}
return false
}
// AddParent adds a parent role with cycle guard.
// Mirrors Dafny: requires NoCycles, ensures NoCycles preserved or store unchanged.
func (r *Resolver) AddParent(child, parent types.RoleID) error {
if r.HasCycle(child, parent) {
return fmt.Errorf("adding parent %s to %s would create a cycle", parent, child)
}
role := r.roles[child]
role.ParentIDs = append(role.ParentIDs, parent)
r.roles[child] = role
return nil
}
// TransitiveClaims collects all claims reachable through the role hierarchy.
// BFS bounded by number of roles — same as Dafny's fuel parameter.
func TransitiveClaims(roleID types.RoleID, roles map[types.RoleID]types.Role) []types.Claim {
var claims []types.Claim
visited := map[types.RoleID]bool{}
queue := []types.RoleID{roleID}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if visited[current] {
continue
}
visited[current] = true
role, ok := roles[current]
if !ok {
continue
}
claims = append(claims, role.Claims...)
queue = append(queue, role.ParentIDs...)
}
return claims
}
Store: Invariant Enforcement at Every Write
// go/pkg/store/store.go
// AssignRole mirrors Dafny AssignRole:
// requires SoDSatisfied(current roles, conflicts)
// ensures SoDSatisfied(updated roles, conflicts) OR rejection with witness
func (s *Store) AssignRole(principalID types.PrinID, roleID types.RoleID) error {
s.mu.Lock()
defer s.mu.Unlock()
principal, ok := s.principals[principalID]
if !ok {
return fmt.Errorf("principal %s not found", principalID)
}
role, ok := s.roles[roleID]
if !ok {
return fmt.Errorf("role %s not found", roleID)
}
// Tenant isolation — from Dafny ValidStore predicate
if role.OrgID != principal.OrgID {
return fmt.Errorf("tenant isolation: role org %s != principal org %s",
role.OrgID, principal.OrgID)
}
// SoD conflict check — checks BOTH directions, per Dafny SoDSatisfied predicate
for _, existingRoleID := range principal.RoleIDs {
if s.hasConflict(existingRoleID, roleID) {
return fmt.Errorf("separation of duty: role %q conflicts with existing role %q",
roleID, existingRoleID)
}
}
// Safe to assign — SoD preserved (Dafny ensures clause holds)
principal.RoleIDs = append(principal.RoleIDs, roleID)
s.principals[principalID] = principal
return nil
}
Policy Engine Encodes the Soundness/Completeness Contract
// go/pkg/policy/policy.go
// Evaluate decides Allow or Deny for a request.
// Preconditions from Dafny requires clauses: request fields valid, principal exists, tenant matches.
// Postconditions from Dafny ensures clauses: Allow iff valid claim chain exists.
func (e *Engine) Evaluate(req types.Request, ctx types.EvalContext) (types.Decision, error) {
if err := req.Validate(); err != nil {
return types.Deny, fmt.Errorf("invalid request: %w", err)
}
principal, ok := e.store.GetPrincipal(req.PrincipalID)
if !ok {
return types.Deny, nil // deny-by-default — proved by DenyByDefault lemma
}
if principal.OrgID != req.OrgID {
return types.Deny, fmt.Errorf("tenant isolation violated")
}
// Walk transitive claims — same BFS algorithm as Dafny Evaluate method
claims := hierarchy.PrincipalClaims(principal, e.store.AllRoles())
for _, c := range claims {
if constraints.ClaimGrants(c, req.Action, req.ResourceID, ctx) {
return types.Allow, nil
}
}
return types.Deny, nil
}
Property-Based Tests: The Bridge from Provable to Probable
Each Dafny lemma gets a matching gopter property-based test. Dafny proves for all possible inputs; gopter fires hundreds of random inputs and catches bugs in the Go translation where the Dafny spec is correct but the Go implementation diverges.
// go/pkg/policy/policy_test.go
// Mirrors: DenyByDefault lemma in rbac_invariants.dfy
func TestProp_NoRolesAlwaysDenied(t *testing.T) {
props := gopter.NewProperties(gopter.DefaultTestParameters())
props.Property("principal with no roles is always denied", prop.ForAll(
func(action, resource string) bool {
s := store.New()
_ = s.AddOrg(types.Organization{ID: "org", Name: "org"})
_ = s.AddResource(types.Resource{ID: resource, Name: resource, Kind: "api"})
_ = s.AddPrincipal(types.Principal{ID: "p", OrgID: "org", Name: "P"})
engine := policy.New(s)
req := types.Request{OrgID: "org", PrincipalID: "p",
Action: action, ResourceID: resource}
decision, _ := engine.Evaluate(req, types.EvalContext{CurrentHour: 12})
return decision == types.Deny
},
gen.AlphaString(), gen.AlphaString(),
))
props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}
// Mirrors: AddingConstraintReducesAccess lemma in rbac_constraints.dfy
func TestProp_ConstraintMonotonicity(t *testing.T) {
props := gopter.NewProperties(gopter.DefaultTestParameters())
props.Property("subset of constraints passing implies prefix passes", prop.ForAll(
func(hour int, region string, usage int) bool {
ctx := types.EvalContext{
CurrentHour: abs(hour) % 24,
CurrentRegion: region,
CurrentUsage: abs(usage) % 100,
}
tw, _ := types.NewTimeWindow(9, 17)
base := []types.Constraint{tw}
geo, _ := types.NewGeoFence([]string{"us-east-1"})
extended := append(base, geo)
// If the extended (stricter) set passes, the base set MUST also pass
if constraints.AllHold(extended, ctx) {
return constraints.AllHold(base, ctx)
}
return true
},
gen.Int(), gen.AnyString(), gen.Int(),
))
props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}
// Mirrors: HigherUsageHarder lemma in rbac_constraints.dfy
func TestProp_QuotaMonotonicity(t *testing.T) {
props := gopter.NewProperties(gopter.DefaultTestParameters())
props.Property("higher usage never turns Deny into Allow for quota", prop.ForAll(
func(limit int, lo int, delta int) bool {
limit = abs(limit)%100 + 1
lo = abs(lo) % 200
hi := lo + abs(delta)%100 // hi >= lo guaranteed
quota, _ := types.NewMaxUsage(limit)
ctxLo := types.EvalContext{CurrentUsage: lo}
ctxHi := types.EvalContext{CurrentUsage: hi}
// If quota passes at HIGHER usage, it MUST pass at lower usage
if constraints.Holds(quota, ctxHi) {
return constraints.Holds(quota, ctxLo)
}
return true
},
gen.Int(), gen.Int(), gen.Int(),
))
props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}
// Mirrors: OwnClaimsIncluded lemma in rbac_role_hierarchy.dfy
func TestProp_OwnClaimsAlwaysIncluded(t *testing.T) {
props := gopter.NewProperties(gopter.DefaultTestParameters())
props.Property("role's own claims always appear in transitive claims", prop.ForAll(
func(claimCount int) bool {
claimCount = abs(claimCount)%5 + 1
var claims []types.Claim
for i := 0; i < claimCount; i++ {
claims = append(claims, types.Claim{
ID: types.ClaimID(fmt.Sprintf("c%d", i)),
Action: fmt.Sprintf("action%d", i),
ResourceID: "res1",
})
}
roles := map[types.RoleID]types.Role{
"role1": {ID: "role1", OrgID: "org1", Claims: claims},
}
transitive := hierarchy.TransitiveClaims("role1", roles)
for _, c := range claims {
found := false
for _, tc := range transitive {
if tc.ID == c.ID {
found = true
break
}
}
if !found {
return false
}
}
return true
},
gen.Int(),
))
props.TestingRun(t, gopter.NewFormatedReporter(false, 80, os.Stdout))
}
Running the full suite:
+ empty constraint list always passes: OK, passed 200 tests.
+ hour in [start,end) passes, hour outside fails: OK, passed 500 tests.
+ subset of constraints passing implies prefix passes: OK, passed 300 tests.
+ higher usage never turns Deny into Allow for quota: OK, passed 300 tests.
+ role's own claims always appear in transitive claims: OK, passed 200 tests.
+ adding a parent never reduces transitive claims: OK, passed 200 tests.
+ unknown principal is always denied: OK, passed 200 tests.
+ principal with no roles is always denied: OK, passed 200 tests.
PASS — 2300+ property-based test cases executed.
Each line corresponds to a Dafny lemma. The property tests don’t replace the proofs, they catch bugs in the Go translation that the Dafny verifier can’t see.
Adding a Feature the Verified Way
Let me walk through adding a RateLimit constraint from scratch. This is the exact workflow for extending a formally verified system, and it shows why the upfront cost is much lower than it looks.
Step 1: Add the datatype in Dafny
datatype ConstraintKind =
| TimeWindow(startHour: nat, endHour: nat)
| GeoFence(allowedRegions: seq<string>)
| MaxUsage(maxCount: nat)
| RateLimit(requestsPerMinute: nat) // NEW
predicate ValidConstraint(c: ConstraintKind) {
match c
case TimeWindow(s, e) => 0 <= s < e <= 24
case GeoFence(regions) => |regions| > 0
case MaxUsage(max) => max > 0
case RateLimit(rpm) => rpm > 0 // must be positive
}
Step 2: Define evaluation semantics
predicate ConstraintHolds(c: ConstraintKind, ctx: EvalContext) {
match c
case TimeWindow(s, e) => s <= ctx.currentHour < e
case GeoFence(regions) => ctx.currentRegion in regions
case MaxUsage(max) => ctx.currentUsage < max
case RateLimit(rpm) => ctx.currentRequestRate < rpm // NEW
}
Step 3: Write and prove a monotonicity lemma
// Lower rate limit is harder to satisfy — proved automatically
lemma LowerRateLimitHarder(rpm1: nat, rpm2: nat, ctx: EvalContext)
requires rpm1 <= rpm2
requires rpm1 > 0 && rpm2 > 0
ensures ConstraintHolds(RateLimit(rpm1), ctx) ==>
ConstraintHolds(RateLimit(rpm2), ctx)
{
// Dafny proves this in under a second: if rate < rpm1 <= rpm2 then rate < rpm2
}
Step 4: Verify
$ dafny verify dafny/rbac_constraints.dfy
Dafny program verifier finished with 12 verified, 0 errors
Step 5: Implement in Go
func NewRateLimit(rpm int) (Constraint, error) {
if rpm <= 0 {
return Constraint{}, fmt.Errorf("rate limit must be positive")
}
return Constraint{Kind: RateLimitKind, MaxCount: rpm}, nil
}
// Add to Holds() in constraints.go:
case types.RateLimitKind:
return ctx.CurrentRequestRate < c.MaxCount
That full loop from datatype –> predicate –> lemma –> verify –> implement –> test takes maybe 20 minutes for a new constraint type. The result ships with a mathematical proof that the Go implementation matches the specification.
The Complete Pipeline
The workflow in the companion repo ties everything together:
Running the full pipeline:
# Step 1: Verify formal specs
$ make verify-dafny
[DAFNY] Verifying rbac_types.dfy... ? PASS
[DAFNY] Verifying rbac_policy.dfy... ? PASS
[DAFNY] Verifying rbac_role_hierarchy.dfy... ? PASS
[DAFNY] Verifying rbac_separation_of_duty.dfy... ? PASS
[DAFNY] Verifying rbac_constraints.dfy... ? PASS
[DAFNY] Verifying rbac_invariants.dfy... ? PASS
All 6 Dafny files verified successfully.
# Step 2: Model check concurrent protocol
$ make check-tla
[TLA+] Model checking RBACPolicyChange...
Model checking completed. No error has been found.
2349 states generated, 1046 distinct states found.
# Step 3: Run property-based tests
$ make test
+ empty constraint list always passes: OK, passed 200 tests.
+ time window boundary conditions: OK, passed 500 tests.
+ constraint monotonicity: OK, passed 300 tests.
+ quota monotonicity: OK, passed 300 tests.
+ own claims in transitive closure: OK, passed 200 tests.
+ adding parent never removes claims: OK, passed 200 tests.
+ unknown principal always denied: OK, passed 200 tests.
+ no roles always denied: OK, passed 200 tests.
PASS — 2300+ property-based test cases executed.
# Step 4: Smoke-test the API
$ make run &
Server starting on :9090...
# alice can read docs (viewer role, time window 9-17, currently hour 10)
$ curl -s localhost:9090/evaluate -d '{
"org_id":"acme", "principal_id":"alice",
"action":"read", "resource_id":"docs",
"hour":10, "region":"us-east-1", "usage":0
}' | jq .decision
"Allow"
# alice denied at 10pm — time constraint blocks access outside 9-17
$ curl -s localhost:9090/evaluate -d '{
"org_id":"acme", "principal_id":"alice",
"action":"read", "resource_id":"reports",
"hour":22, "region":"us-east-1", "usage":0
}' | jq .decision
"Deny"
# bob can write docs from US (editor role + geo constraint us-east-1)
$ curl -s localhost:9090/evaluate -d '{
"org_id":"acme", "principal_id":"bob",
"action":"write", "resource_id":"docs",
"hour":12, "region":"us-east-1", "usage":0
}' | jq .decision
"Allow"
# bob denied from EU — geo constraint blocks non-US regions
$ curl -s localhost:9090/evaluate -d '{
"org_id":"acme", "principal_id":"bob",
"action":"write", "resource_id":"docs",
"hour":12, "region":"eu-west-1", "usage":0
}' | jq .decision
"Deny"
# SoD blocks carol (finance role) from also being submitter
$ curl -s localhost:9090/principals/carol/roles -d '{"role_id":"submitter"}'
{"error":"separation of duty: role \"submitter\" conflicts with existing role \"finance\""}
Who Writes the Specs? The Human-AI Division
This is the question I get asked most. Here’s how I answer it.
Humans must own:
What the invariants are, e.g., SoD, tenant isolation, deny-by-default, referential integrity
What the formal properties mean in the problem domain
Reviewing counterexamples from the verifier and refining specs accordingly
Architecture decisions: which tool for which problem, which 20% of the codebase to verify
AI can assist:
Dafny syntax, e.g.,LLMs generate valid Dafny from English property descriptions, as the TLA+ for the LLM era article demonstrates for TLA+
Boilerplate Go translated from Dafny type definitions
Test scaffolding for gopter properties
Translating Dafny lemmas into property test outlines
Generating loop invariants and decreases clauses from BFS/iteration patterns the LLM recognizes
The feedback loop in practice:
Human writes ValidStore capturing tenant isolation
AI generates the AddPrincipal method in Go
Dafny verifies the spec — or produces a counterexample
If counterexample: human understands the bug (usually a missed invariant direction), refines the spec
AI regenerates from the refined spec
Repeat until proof succeeds
Marc Brooker’s analysis of what AI agents find easy versus hard makes the point precisely: agents succeed on tasks with good automated feedback and struggle on tasks without it. Formal verification is that feedback as it is mathematical, precise, and automatable. It turns the review loop into a tight iteration between human specifier and verifier, rather than a bottleneck where an engineer reads 1,000 lines of plausible AI code hoping to spot a subtle invariant violation. This directly counters the “cut review to go faster” argument: you don’t cut review instead you replace line-by-line code review with specification review, which is faster, higher leverage, and catches the bugs that matter.
When to Apply Formal Verification
Not every line of code needs formal verification. Here’s how I decide where to apply it:
Apply formal verification
Skip it
Authorization and access control
UI rendering logic
Cryptographic protocols
CRUD boilerplate
Distributed consensus
Simple data transformations
Financial calculations
User-facing text content
Schema migration validators
Logging and metrics
Safety-critical state machines
Configuration defaults
The pattern: apply where bugs are expensive like security, correctness, data integrity and where the specification can be stated mathematically. For the critical 20% of a codebase where correctness failures are severe, formal verification pays for itself on the first prevented production incident. For the other 80%, tests and code review are the right tools. This targeting also addresses the organizational pressure argument directly. You don’t need to formally verify everything, which would be impractical. You verify the parts where the cost of being wrong is highest. That’s a defensible, scoped investment that produces measurable risk reduction.
Getting Started with Dafny: Three Steps
Step 1: Start with types. Write ValidXxx predicates before writing any methods. This forces you to articulate what “correct state” means before writing code that’s supposed to produce it. The predicates are small, incremental, and require no theorem-proving expertise. AI can bootstrap this step. LLMs are now quite capable at generating Dafny precondition/postcondition stubs from English property descriptions. See dafny-annotator: AI-Assisted Verification of Dafny Programs.
Step 2: Add contracts to one critical method. Pick the authorization check. Add requires/ensures. Let Dafny fail. Understand why. Add lemmas. The first proof is the hardest; subsequent ones follow the same pattern.
Step 3: Mirror each lemma with a property test. This catches translation bugs in the Go implementation and keeps spec and code in sync as the system evolves.
// Dafny lemma — proved by the verifier
lemma EmptyConstraintsAlwaysHold(ctx: EvalContext)
ensures AllConstraintsHold([], ctx)
{}
The AI era has come full circle. In the 1980s, AI meant logic like Prolog, expert systems, formal inference. The 2020s flipped to probabilistic: statistical token prediction that generates plausible code at extraordinary speed. But plausible was never the goal. Correct is the goal. And correctness was always logic’s domain.
The Bertrand Meyer’s article From Probable to Provable captures the shift precisely: the engineering role moves from writing code to writing specifications. From debugging via console.log to managing verification pipelines. From reviewing AI-generated code line by line to reviewing the specs the verifier checks against.
Catches translation bugs between spec and implementation
Review
Human engineer
Reviews counterexamples, refines specifications
This is the answer to the organizational pressure to skip review, reduce verification, and just ship. The pressure comes from observing 10× code output and concluding that verification overhead is blocking throughput. The data says the opposite: organizations that remove verification to increase throughput move from the valley of calm to the plateau of misery. They ship more code with lower reliability. The pipeline stalls.
Formal verification, applied selectively to the critical 20% of your codebase, keeps the defect rate low enough that the rest of the pipeline flows. It shifts human effort from reading AI-generated code line by line to writing the specifications that make wrong implementations immediately visible. That’s a higher-leverage use of engineering time and a better argument to make to management than “we need more review bandwidth.”
The tools are practical today:
Dafny verifies all 6 RBAC specification files in under 30 seconds on a laptop
TLC model-checks the concurrent update protocol in under a second
gopter runs 2,300+ property tests in under a second
Total upfront overhead: roughly 20% more time spent writing specs rather than debugging production
Mager’s valley of calm stays wide when your defect rate stays low. Formal verification is the most effective tool I’ve found for keeping it there.
Hermes Agent from Nous Research is very capable open agent that centers on three ideas that reinforce each other:
Structured system prompt with function-calling discipline. The system prompt teaches the model when to call a tool versus when to answer directly, how to format tool inputs as JSON, and how to interpret results and loop forward. The model learns that end_turn means the task is finished. This discipline makes Hermes far more reliable than agents running open-ended prompts.
Multi-step tool loop. After each LLM response, the agent checks: did the model request a tool? If yes, execute it, append the result, and call the LLM again up to a configured limit. This is what lets Hermes chain steps like “search –> read –> summarise” without the user driving each step by hand.
Self-critique and skill accumulation. After a complex task, Hermes reflects on the conversation and extracts a reusable skill, a named, structured description of the steps it took. The next time it encounters a similar request, it injects that skill into context and executes faster, without re-discovering the procedure from scratch.
These three properties make Hermes genuinely useful. But the reference implementation is a monolithic Python process. One crash loses every in-flight session. There is no distribution, no tenant isolation, no scheduled automation, and no provider failover. It is excellent research code and a fragile foundation for anything beyond a single-user demo.
MiniHermes keeps all three Hermes ideas and rebuilds the execution model on PlexSpaces, an actor-based distributed runtime. The result compiles to a single WASM binary, runs 12 actors under supervision, and adds durable state, fault isolation, distributed cron, context compression, and guardrails without changing how the core agent loop reasons.
The Problem: Stateless vs. Stateful Monolith
Most AI agents fall into one of two camps, and both have real problems.
Stateless agents are easy to deploy but forget everything between requests. You can’t reuse a procedure the agent learned last Tuesday. You can’t track that the user prefers metric units. Every conversation starts from zero. The workarounds like external caches, vector stores turn the agent into infrastructure glue rather than an intelligent system.
Stateful monoliths like the Hermes reference implementation go the other direction: one process owns everything. That’s clean for development, but fragile under load. When the process crashes, every active session vanishes. A bug in skill extraction can corrupt the memory that session management depends on.
The actor model offers a third path. Decompose the system into many small actors, each owning exactly one responsibility, communicating only through messages. When one crashes, the supervisor restarts just that actor. The others keep running.
PlexSpaces Primitives
Before walking through the actors, it helps to understand the primitives every actor has access to inside the WASM sandbox. These are the only operations available, no filesystem, no global state, no raw sockets. This constraint is deliberate: it is part of what makes the system auditable and safe.
KV: Durable Point Lookup
# Persist and restore session history across restarts
host.kv_put(f"session_history:{session_id}", json.dumps(messages))
raw = host.kv_get(f"session_history:{session_id}")
messages = json.loads(raw) if raw else []
KV stores anything keyed by an exact string: session history, skill metadata, cron job state, provider configuration. The durability facet checkpoints it automatically, so a restarted actor picks up exactly where it left off.
TupleSpace: Pattern-Matched Coordination
TupleSpace is not KV. Rather than point lookups, it supports wildcard queries:
# Index a skill under multiple trigger keywords
host.ts.write(["skill_trigger", "csv", "skill-001"])
host.ts.write(["skill_trigger", "spreadsheet", "skill-001"])
host.ts.write(["skill_trigger", "pivot", "skill-001"])
# Find every skill that might match — None is a wildcard
all_triggers = host.ts.read_all(["skill_trigger", None, None])
# ? [["skill_trigger","csv","skill-001"], ["skill_trigger","spreadsheet","skill-001"], ...]
# Audit log: all events of a specific type
events = host.ts.read_all(["audit", "tool_executed", None, None])
# Health snapshots: last N polls
snapshots = host.ts.read_all(["health_snapshot", None, None])
TupleSpace powers skill indexes, memory tiers, audit logs, and health snapshots, anything where you scan across many entries rather than fetching one by ID.
Design tradeoff. TupleSpace pattern matching scales well for hundreds to thousands of entries but is not a replacement for a vector database or SQL at large scale. For this POC it removes an external dependency entirely; a production system with millions of skills would add an embedding-based index alongside it.
BlobStorage: Large, Opaque Content
# Skill procedures can be several paragraphs — too large for KV values
host.blob.upload(f"skill_procedure_{skill_id}", procedure_text.encode())
procedure = host.blob.download(f"skill_procedure_{skill_id}").decode()
BlobStorage handles the full procedure text that would be awkward as a KV value and wasteful to pass in message payloads.
Channel: At-Least-Once Delivery
# Cron scheduler enqueues a job
host.channel.send("", "cron:pending", "cron_job", job_payload)
# Agent receives, processes, then acks — message redelivered if agent crashes before ack
msg, ok, _ = host.channel.receive("", "cron:pending", timeout_ms=5000)
if ok:
# ... process the job ...
host.channel.ack("", "cron:pending", msg["msg_id"])
# or: host.channel.nack("", "cron:pending", msg["msg_id"], True) # requeue
Channel provides the durability that host.send() does not. If the consuming actor crashes between receive and ack, the message is redelivered on restart. This is what makes recurring tasks survive node failures without a separate message broker.
DistributedLock: Cluster-Wide Leader Election
// Go — CronSchedulerActor.tick()
// TryAcquire returns false immediately if another node holds the lock
// TTL of 90s is longer than the 60s tick interval, preventing gaps
acquired, _ := host.Lock().TryAcquire("minihermes", "cron_leader", 90000)
if !acquired {
return // another node is the leader this cycle
}
// Safe to fire jobs — only this node runs this block right now
Without DistributedLock, every node in a three-node cluster would fire every cron job simultaneously. The lock ensures exactly one leader schedules per tick.
SendAfter: Actor-Managed Timers
@init_handler
def on_init(self, config: dict) -> None:
host.process_groups.join("svc:health_monitor")
# Arm the first tick — no external cron daemon needed
host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})
@handler("poll_tick", "cast")
def poll_tick(self) -> None:
# ... do poll work ...
# Re-arm: each tick schedules the next
host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})
send_after replaces external schedulers for periodic work inside an actor. The actor manages its own timeline.
This distinction matters for latency. Audit events and async skill learning always use send(). The calling actor never waits for them. LLM completions and tool results use ask() because the outcome is needed before continuing.
IncrCounter: Lightweight Metrics
# Increment a named counter — visible to monitoring without any external metrics system
host.incr_counter("llm_completions_total", 1)
host.incr_counter("tool_executions_total", 1)
host.incr_counter(f"tool_{name}_total", 1)
host.incr_counter("skill_matches_total", len(matched_ids))
Every key operation in MiniHermes emits a counter. Aggregated across actors, these give a metrics dashboard without Prometheus or a separate telemetry pipeline.
MiniHermes Architecture
MiniHermes consists of 12 actors and compiles to a single WASM binary. The PlexSpaces supervisor boots 12 actors from it at startup, each with its own state, crash domain, and message contract.
The four actor behaviors map to four different runtime contracts:
Behavior
Actors
What It Provides
GenServer
Agent, LLM, Tools, Skills, Memory, Compressor, Cron, Session, Health
Synchronous request-reply with durable state
GenFSM
GuardrailsGate
Validated state machine and invalid transitions are rejected at runtime
GenEvent
AuditEvent
Fire-and-forget event delivery; callers never block
Workflow
SkillExtractionWorkflow
Durable multi-step execution with per-step checkpoints and cancel/query signals
Fault isolation. A bug in SkillStoreActor cannot corrupt AgentActor‘s session history. If SkillExtractionWorkflow crashes mid-extraction, it resumes from its last checkpoint without restarting the conversation. The one_for_one supervisor strategy restarts only the failed actor; everything else keeps running.
# app-config.toml
[supervisor]
strategy = "one_for_one" # restart ONLY the crashed child
max_restarts = 10
max_restart_window_seconds = 60 # if 10 crashes in 60s, escalate to parent supervisor
Latency tradeoff. Each actor boundary costs one ask() call instead of an in-process function call. For an LLM agent this is negligible as LLM round-trips dominate at 100ms to 10s. The isolation and recoverability benefits far outweigh the sub-millisecond message overhead.
The Supervisor Tree and the Let-It-Crash Philosophy
Monolithic agent frameworks force every developer to write defensive error handling around every tool call, every LLM request, every memory write. MiniHermes takes the Erlang philosophy instead: let actors crash, and let supervisors restart them in a clean state.
When ToolExecutorActor crashes due to a bad tool payload, a timeout, or a WASM trap, the supervisor restarts it with clean state. The AgentActor‘s in-flight request receives a timeout error and can retry. Every other actor continues running. The audit trail, the cron scheduler, the skill store, the LLM gateway, none of them know a crash happened.
This is the opposite of a monolith, where one bad tool call can corrupt the process heap and take the entire agent down.
Security: WASM, Firecracker, and Actor Isolation
Security in MiniHermes comes from three concentric layers, not from application-level checks.
Layer 1 Actor message isolation. Each actor owns its state exclusively. No shared memory, no global variables. Communication happens only through host.ask() and host.send(). Even if a prompt injection tricks AgentActor into misbehaving, it cannot read LLMGatewayActor‘s stored API credentials or SkillStoreActor‘s procedure data as those live in separate actor state.
Layer 2 WASM linear memory sandbox. Every actor compiles to a WebAssembly module. The WIT (WebAssembly Interface Types) definition explicitly lists every operation the actor can call:
// wit/plexspaces-actor/host.wit
// Actors can ONLY call these imports — nothing else is accessible
interface host {
send: func(to: string, msg-type: string, payload: payload) -> result<_, actor-error>;
ask: func(to: string, msg-type: string, payload: payload, timeout-ms: u64) -> result<payload, actor-error>;
kv-get: func(key: string) -> result<payload, actor-error>;
kv-put: func(key: string, value: payload) -> result<_, actor-error>;
http-fetch: func(link-name: string, method: string, path: string, request: payload) -> result<payload, actor-error>;
ts-write: func(tuple: list<string>) -> result<_, actor-error>;
ts-read-all:func(pattern: list<option<string>>) -> result<list<list<string>>, actor-error>;
// No filesystem. No env vars. No raw network. No process exec.
}
A malicious tool payload cannot exfiltrate environment variables or write to the filesystem because those syscalls do not exist in the WASM environment.
Layer 3 Firecracker. In a production deployment, each WASM runtime runs inside a Firecracker microVM, a lightweight KVM-based hypervisor that provides hardware-enforced memory and I/O isolation between tenants. A compromise in one tenant’s actor cannot affect another tenant’s data or execution even if the WASM sandbox were bypassed.
Tenant isolation. Every PlexSpaces operation propagates tenant context automatically. KV keys, TupleSpace tuples, process groups, and object registry entries are all scoped by tenant and namespace:
# Framework-enforced key scoping — no application code can bypass this
KV: tenant-acme:prod:session_history:sess-001
TupleSpace: tenant-acme:prod:["skill_trigger", "csv", "skill-001"]
PG: tenant-acme:prod:svc:agent
Tenant acme cannot retrieve a session belonging to tenant globex. The framework rejects the request before it reaches any actor.
The Agent Loop
AgentActor drives the core conversation. When it receives a chat message, here is the full sequence:
User: "calculate 42 * 17 and remember the result"
1. Restore session history from KV (survives restarts)
2. Ask ContextCompressorActor: token budget > 75%?
--> Yes: summarize the middle, keep the recent tail, archive original
3. Ask SkillStoreActor: known procedures for "calculate" + "memory_store"?
--> Found: inject skill into system prompt
4. Ask ToolExecutorActor: list current tool schemas
5. LOOP (max 8 iterations):
a. Ask LLMGatewayActor: complete with these messages + tools
b. stop_reason = tool_use:
--> GuardrailsGate.check("calculator") --> allow
--> ToolExecutor.execute("calculator", {expr: "42*17"}) --> {result: 714}
--> GuardrailsGate.check("memory_store") --> allow
--> ToolExecutor.execute("memory_store", {key: "last_calc", value: "714"})
--> Append results; continue loop
c. stop_reason = end_turn --> break
6. KV.put("session_history:sess-001", messages) -- durable checkpoint
7. send (fire-and-forget): SkillStoreActor.evaluate_for_learning
8. send (fire-and-forget): AuditEventActor.log_event
== "42 × 17 = 714. I've stored the result in your memory."
The Python implementation:
@actor
class AgentActor:
system_prompt: str = state(default="You are a helpful AI assistant with access to tools.")
messages: list = state(default_factory=list)
max_iterations: int = state(default=8)
token_budget: int = state(default=4096)
@init_handler
def on_init(self, config: dict) -> None:
args = config.get("args", {})
self.system_prompt = args.get("system_prompt", self.system_prompt)
host.process_groups.join("svc:agent")
# Publish capabilities for registry-based discovery
host.registry.register(ctx="", object_type="actor", object_id=config["actor_id"],
object_category="agent",
capabilities=["chat", "tool_use", "memory"])
@handler("chat")
def chat(self, message: str = "", session_id: str = "") -> dict:
# 1. Restore durable session
if session_id:
raw = host.kv_get(f"session_history:{session_id}")
if raw:
self.messages = json.loads(raw)
self.messages.append({"role": "user", "content": message})
# 2. Compress if over token budget
comp_id, _ = pg_first("svc:context_compressor")
if comp_id:
resp = ask(comp_id, "check_and_compress",
{"messages": self.messages, "token_budget": self.token_budget})
if resp and resp.get("compressed"):
self.messages = resp["messages"]
# 3. Inject matching skills
skill_id, _ = pg_first("svc:skill_store")
skill_context = ""
if skill_id:
resp = ask(skill_id, "match_skills", {"query": message})
if resp and resp.get("skills"):
skill_context = self._format_skills(resp["skills"])
# 4. Get live tool schemas
tool_exec_id, _ = pg_first("svc:tool_executor")
tools = []
if tool_exec_id:
resp = ask(tool_exec_id, "list_tools", {})
tools = resp.get("tools", []) if resp else []
system = self.system_prompt
if skill_context:
system += f"\n\n## Relevant Skills\n{skill_context}"
# 5. The tool loop — max_iterations prevents runaway execution
final_response = ""
for iteration in range(self.max_iterations):
llm_id, _ = pg_first("svc:llm_gateway")
llm_resp = ask(llm_id, "completion",
{"messages": [{"role": "system", "content": system}] + self.messages,
"tools": tools},
timeout_ms=30000)
response = llm_resp.get("response", {})
stop_reason = response.get("stop_reason", "end_turn")
self.messages.append({"role": "assistant",
"content": response.get("content", ""),
"stop_reason": stop_reason})
if stop_reason == "end_turn":
final_response = response.get("content", "")
break
if stop_reason == "tool_use":
guard_id, _ = pg_first("svc:guardrails")
for tc in response.get("tool_calls", []):
# Every tool call clears the guardrail first
if guard_id:
check = ask(guard_id, "check_tool",
{"tool_name": tc["name"], "input": tc["input"]})
if check and check.get("decision") == "deny":
self.messages.append({"role": "tool",
"content": f"[denied: {tc['name']}]"})
continue
result = ask(tool_exec_id, "execute",
{"name": tc["name"], "input": tc["input"]})
self.messages.append({"role": "tool",
"tool_call_id": tc["id"],
"content": json.dumps(result)})
host.send(audit_id, "log_event",
{"event_type": "tool_executed",
"detail": f"tool={tc['name']} session={session_id}"})
host.incr_counter("tool_executions_total", 1)
# 6. Checkpoint session — durable across restarts
if session_id:
host.kv_put(f"session_history:{session_id}", json.dumps(self.messages))
# 7+8. Async learning and audit — never block the response
if skill_id:
host.send(skill_id, "evaluate_for_learning",
{"messages": self.messages, "user_intent": message})
host.incr_counter("agent_chats_total", 1)
return {"status": "ok", "response": final_response, "session_id": session_id}
Step 7 uses host.send(), not host.ask(). Skill learning never adds latency to the response, it happens in the background while the user reads the answer.
The LLM Gateway: Hot-Swap and Circuit Breaker
LLMGatewayActor is the single point through which all LLM calls flow. It can switch providers at runtime without restarting, and it protects downstream actors from a flaky provider with a built-in circuit breaker.
# Switch from Ollama to Anthropic — takes effect immediately, no restart
curl -X POST http://localhost:8091/api/v1/actors/llm_gateway/switch_provider \
-d '{"provider":"anthropic","model":"claude-opus-4-8"}'
# Or to OpenAI
curl -X POST http://localhost:8091/api/v1/actors/llm_gateway/switch_provider \
-d '{"provider":"openai","model":"gpt-4o"}'
The circuit breaker lives in the actor’s durable state, it survives restarts:
@actor
class LLMGatewayActor:
provider: str = state(default="ollama")
model: str = state(default="llama3.2")
circuit_open: bool = state(default=False)
consecutive_failures: int = state(default=0)
total_completions: int = state(default=0)
@init_handler
def on_init(self, config: dict) -> None:
host.process_groups.join("svc:llm_gateway")
host.send_after(30_000, "timer_tick", {"op": "timer_tick"})
@handler("completion")
def completion(self, messages: list = None, tools: list = None) -> dict:
if self.circuit_open:
# Fail fast — don't queue work behind a broken provider
return {"status": "ok", "response": self._simulated_response(),
"circuit_open": True}
try:
result = self._call_provider(messages or [], tools or [])
self.consecutive_failures = 0
self.total_completions += 1
host.incr_counter("llm_completions_total", 1)
return {"status": "ok", "response": result}
except Exception as e:
self.consecutive_failures += 1
if self.consecutive_failures >= 3:
self.circuit_open = True
host.warn(f"LLM circuit opened after {self.consecutive_failures} failures")
host.incr_counter("llm_circuit_opens_total", 1)
return {"error": str(e), "response": self._simulated_response()}
@handler("timer_tick", "cast")
def timer_tick(self) -> None:
# Gradual recovery: one fault cleared per 30s tick
# 3 faults ? 90s before circuit closes again — prevents flapping
if self.circuit_open and self.consecutive_failures > 0:
self.consecutive_failures -= 1
if self.consecutive_failures == 0:
self.circuit_open = False
host.info("LLM circuit closed — provider available again")
host.send_after(30_000, "timer_tick", {"op": "timer_tick"})
@handler("switch_provider")
def switch_provider(self, provider: str = "", model: str = "") -> dict:
self.provider = provider
self.model = model
# Switching resets the circuit — assume the new provider is healthy
self.circuit_open = False
self.consecutive_failures = 0
return {"status": "ok", "provider": provider, "model": model}
def _call_provider(self, messages: list, tools: list) -> dict:
if self.provider == "ollama":
resp = host.http_fetch("ollama", "POST", "/api/chat",
{"model": self.model, "messages": messages, "stream": False})
elif self.provider == "anthropic":
resp = host.http_fetch("anthropic", "POST", "/v1/messages",
{"model": self.model, "messages": messages,
"tools": tools, "max_tokens": 4096})
elif self.provider == "openai":
resp = host.http_fetch("openai", "POST", "/v1/chat/completions",
{"model": self.model, "messages": messages, "tools": tools})
return self._normalize(resp)
Every provider response normalizes to the same format before leaving the gateway:
AgentActor doesn’t knows which provider answered and switching providers is transparent to the rest of the system.
Design tradeoff. The circuit breaker in this POC uses a simple failure count threshold. A production implementation would add per-provider backoff, budget caps, and latency-based degradation.
Skill Learning: The Self-Improvement Loop
This is what separates MiniHermes from every standard agent loop. When the agent uses three or more tools in a single turn, it asynchronously extracts a reusable skill. The next time the user asks something similar, the agent injects that skill into the system prompt and skips the re-discovery phase entirely.
The Durable Extraction Workflow
SkillExtractionWorkflow uses the @workflow_actor behavior, which checkpoints state after each step. A node crash during step 2 of 3 resumes from step 2, not the beginning:
@workflow_actor
class SkillExtractionWorkflow:
@run_handler
def run(self, payload: dict = None) -> dict:
user_intent = payload.get("user_intent", "")
tool_sequence = payload.get("tool_sequence", [])
domain = payload.get("domain", "general")
llm_id = payload.get("llm_id", "")
# Three focused LLM passes — each optimizes for a different extraction goal.
# Python runs them sequentially (shared LLM budget).
# Go runs them in true parallel goroutines for lower latency.
name_result = self._analyse_name(llm_id, user_intent, tool_sequence)
# ? workflow checkpoints here; crash-safe from this point
procedure_result = self._analyse_procedure(llm_id, user_intent, tool_sequence)
# ? checkpoint
trigger_result = self._analyse_triggers(llm_id, user_intent, domain)
# ? checkpoint
skill_id = f"skill-{host.now_ms()}"
skill_store_id, _ = pg_first("svc:skill_store")
if skill_store_id:
ask(skill_store_id, "propose_skill", {
"skill_id": skill_id,
"name": name_result.get("name", "unnamed-skill"),
"description": name_result.get("description", ""),
"procedure": procedure_result.get("procedure", ""),
"tags": trigger_result.get("tags", []),
"trigger_patterns": trigger_result.get("patterns", []),
})
return {"status": "ok", "skill_id": skill_id}
@signal_handler("cancel")
def cancel(self) -> None:
# In-flight extraction can be cancelled without crashing the actor
host.info("SkillExtraction cancelled")
@query_handler("status")
def query_status(self) -> dict:
return {"task_id": self.task_id, "status": self.status, "progress": self.progress}
Three Storage Layers for Three Access Patterns
@handler("propose_skill")
def propose_skill(self, skill_id: str = "", name: str = "",
description: str = "", procedure: str = "",
tags: list = None, trigger_patterns: list = None) -> dict:
# KV: metadata — fast exact-key lookup when the ID is known
meta = {"skill_id": skill_id, "name": name, "description": description,
"status": "active", "usage_count": 0,
"created_at": host.now_ms(), "last_used_at": host.now_ms()}
host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))
# BlobStorage: full procedure text — potentially several paragraphs
host.blob.upload(f"skill_procedure_{skill_id}", procedure.encode())
# TupleSpace: keyword indexes — pattern scan at query time, no SQL needed
for tag in (tags or []):
host.ts.write(["skill_tag", tag, skill_id, name])
for pattern in (trigger_patterns or []):
host.ts.write(["skill_trigger", pattern, skill_id])
host.incr_counter("skills_created_total", 1)
return {"status": "ok", "skill_id": skill_id}
Why three layers? KV answers “give me skill X” in O(1). TupleSpace answers “which skills match this query?” without an index build step. BlobStorage keeps large procedure text out of both KV values and message payloads.
Skill Matching at Query Time
@handler("match_skills")
def match_skills(self, query: str = "") -> dict:
query_words = set(query.lower().split())
# Scan all trigger entries — None is a wildcard
all_triggers = host.ts.read_all(["skill_trigger", None, None])
matched_ids = set()
for tpl in all_triggers:
pattern = tpl[1].lower()
if pattern in query_words or any(w in pattern for w in query_words):
matched_ids.add(tpl[2])
skills = []
for skill_id in matched_ids:
meta_json = host.kv_get(f"skill_meta:{skill_id}")
if not meta_json:
continue
meta = json.loads(meta_json)
if meta.get("status") != "active":
continue
# Load the full procedure only for matched, active skills
meta["procedure"] = host.blob.download(f"skill_procedure_{skill_id}").decode()
skills.append(meta)
# Track usage for lifecycle decisions
meta["usage_count"] += 1
meta["last_used_at"] = host.now_ms()
host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))
host.incr_counter("skill_matches_total", len(skills))
return {"status": "ok", "skills": skills}
Skills Age Out Automatically
Skills that go unused for 30 days transition to stale. After 90 more days they become archived. A daily send_after tick drives this, no external scheduler:
@handler("timer_tick", "cast")
def timer_tick(self) -> None:
now = host.now_ms()
thirty_days_ms = 30 * 24 * 60 * 60 * 1000
ninety_days_ms = 90 * 24 * 60 * 60 * 1000
all_tags = host.ts.read_all(["skill_tag", None, None, None])
seen = set()
for t in all_tags:
skill_id = t[2]
if skill_id in seen:
continue
seen.add(skill_id)
meta_json = host.kv_get(f"skill_meta:{skill_id}")
if not meta_json:
continue
meta = json.loads(meta_json)
age = now - meta.get("last_used_at", now)
if meta["status"] == "active" and age > thirty_days_ms:
meta["status"] = "stale"
host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))
elif meta["status"] == "stale" and age > ninety_days_ms:
meta["status"] = "archived"
host.kv_put(f"skill_meta:{skill_id}", json.dumps(meta))
host.send_after(24 * 60 * 60 * 1000, "timer_tick", {"op": "timer_tick"})
active --> (30 days unused) --> stale --> (90 more days) --> archived
This prevents the skill store from accumulating noise from one-off tasks that will never recur.
Memory: Three Tiers, One Actor
MemoryActor manages three memory tiers with different durability and retrieval characteristics. The Hermes reference implementation stores facts in flat files; MiniHermes uses KV + TupleSpace + BlobStorage, with each tier mapped to a storage layer.
@actor
class MemoryActor:
memory_count: int = state(default=0)
@handler("store_memory")
def store_memory(self, key: str = "", value: str = "",
scope: str = "global", tier: str = "reachable",
agent_id: str = "", session_id: str = "") -> dict:
if not key:
return {"error": "key required"}
scoped_key = self._scoped_key(scope, agent_id, session_id, key)
if tier == "deep":
# BlobStorage: large, rarely needed, not scanned by default
host.blob.upload(f"deep_memory_{scoped_key}", value.encode())
else:
# KV: durable point lookup
host.kv_put(scoped_key, str(value))
# TupleSpace index: queryable by scope and tier regardless of storage layer
host.ts.write(["memory", scope, tier, key, str(value)[:64]])
self.memory_count += 1
return {"status": "ok", "key": key, "scope": scope, "tier": tier}
@handler("recall_memory")
def recall_memory(self, key: str = "", scope: str = "global",
agent_id: str = "", session_id: str = "") -> dict:
scoped_key = self._scoped_key(scope, agent_id, session_id, key)
value = host.kv_get(scoped_key)
if not value:
# Try deep tier
try:
value = host.blob.download(f"deep_memory_{scoped_key}").decode()
except Exception:
pass
return {"status": "ok", "key": key, "value": value, "found": bool(value)}
@handler("list_memories")
def list_memories(self, scope: str = "global", tier: str = None) -> dict:
pattern = ["memory", scope, tier or None, None, None]
tuples = host.ts.read_all(pattern)
memories = [{"key": t[3], "value": t[4], "tier": t[2]}
for t in tuples if len(t) >= 5]
return {"status": "ok", "memories": memories, "count": len(memories)}
def _scoped_key(self, scope: str, agent_id: str, session_id: str, key: str) -> str:
if scope == "agent" and agent_id: return f"mem:agent:{agent_id}:{key}"
if scope == "session" and session_id: return f"mem:session:{session_id}:{key}"
return f"mem:global:{key}"
The three scopes (global, agent, session) determine which facts survive which boundaries: session memories disappear with the session, agent memories persist across sessions, global memories are shared across all agents.
Distributed Cron: Recurring Tasks That Survive Node Failures
You may need to run “summarize my tasks every morning” request. Making it work reliably across a cluster requires solving three problems at once: who fires the job when there are three nodes, what happens if the firing node crashes mid-delivery, and how do you prevent duplicate execution? MiniHermes solves all three with two primitives:
// Go — CronSchedulerActor
func (a *CronSchedulerActor) tick() {
// TryAcquire returns false immediately if another node holds the lock.
// TTL of 90s exceeds the 60s tick interval, preventing leader gaps.
acquired, _ := host.Lock().TryAcquire("minihermes", "cron_leader", 90000)
if !acquired {
return // another node leads this cycle — nothing to do
}
now := host.NowMs()
for _, jobID := range a.JobIDs {
job := a.loadJob(jobID)
if now-job.LastRunAt >= job.IntervalMs {
payload := map[string]interface{}{
"job_id": job.JobID, "prompt": job.Prompt, "session_id": job.SessionID,
}
// Channel: at-least-once. If agent crashes before ack, job redelivers.
host.Ch().Send("", "cron:pending", "cron_job", payload)
job.LastRunAt = now
a.saveJob(job)
}
}
}
The agent runs each cron job in an isolated session context so the job never bleeds into the user’s live conversation:
@handler("process_cron_job", "cast")
def process_cron_job(self, job_id: str = "", prompt: str = "",
session_id: str = "") -> None:
cron_session = f"cron:{session_id}"
# Stash the current interactive conversation
saved_messages = self.messages[:]
# Load the cron session's own history — completely separate from user sessions
raw = host.kv_get(f"session_history:{cron_session}")
self.messages = json.loads(raw) if raw else []
self._run_agent_loop(prompt, tools=[])
host.kv_put(f"session_history:{cron_session}", json.dumps(self.messages))
self.messages = saved_messages # restore user conversation
host.send(audit_id, "log_event",
{"event_type": "cron_executed", "detail": f"job_id={job_id}"})
Creating a recurring task takes one API call:
curl -X POST http://localhost:8091/api/v1/actors/cron_scheduler/create_job \
-d '{
"job_id": "daily-digest",
"prompt": "Summarize today'\''s tasks and send a digest email",
"schedule": "every_24h",
"session_id": "cron-digest"
}'
Context Compression: Long Conversations Without Truncation
Every LLM agent eventually exceeds the model’s context window. The reference Hermes implementation truncates, it drops the oldest messages and loses context. MiniHermes compresses instead: ContextCompressorActor summarizes the middle of the conversation, keeps the recent tail intact, and archives the full original.
@handler("check_and_compress")
def check_and_compress(self, messages: list = None, token_budget: int = 4096) -> dict:
messages = messages or []
estimated_tokens = sum(len(str(m)) // 4 for m in messages)
if estimated_tokens < token_budget * 0.75:
return {"compressed": False, "messages": messages}
system_msgs = [m for m in messages if m.get("role") == "system"]
other_msgs = [m for m in messages if m.get("role") != "system"]
recent_count = max(4, len(other_msgs) // 3)
middle = other_msgs[:-recent_count]
recent = other_msgs[-recent_count:]
if len(middle) < 2:
return {"compressed": False, "messages": messages}
# Archive the full original before compression — preserves audit trail
if self.session_id:
host.kv_put(f"full_history_archive:{self.session_id}", json.dumps(messages))
llm_id, _ = pg_first("svc:llm_gateway")
summary_resp = ask(llm_id, "completion", {
"messages": [
{"role": "system",
"content": "Summarize this conversation history concisely. "
"Preserve key facts, tool results, and decisions."},
{"role": "user", "content": json.dumps(middle)}
],
"tools": []
})
summary_text = summary_resp.get("response", {}).get("content", "")
summary_msg = {"role": "assistant",
"content": f"[Conversation summary: {summary_text}]",
"is_summary": True}
compressed = system_msgs + [summary_msg] + recent
host.incr_counter("context_compressions_total", 1)
return {"compressed": True, "messages": compressed,
"original_count": len(messages), "compressed_count": len(compressed)}
Design tradeoff. LLM-based summarization costs tokens and adds latency to that one turn. The tradeoff is that the compressed context is semantically richer than simple truncation as the model retains the meaning of earlier turns, not just the most recent N messages. For a task-focused agent this matters: a calculation result from turn 3 is still relevant at turn 50.
Guardrails: Per-Tool Policy Enforcement Without Redeployment
GuardrailsGateActor implements a GenFSM that sits between every tool call and execution. Every call passes through it. Policies update at runtime via a single message — no redeploy, no restart.
# Block a dangerous tool immediately — affects all in-flight and future calls
curl -X POST http://localhost:8091/api/v1/actors/guardrails/set_policy \
-d '{"tool_name":"delete_file","decision":"deny"}'
# Route a sensitive tool through human review
curl -X POST http://localhost:8091/api/v1/actors/guardrails/set_policy \
-d '{"tool_name":"send_email","decision":"review"}'
The GenFSM behavior validates every transition at runtime. Attempting allow --> approved without going through review first is rejected by the framework so that bugs in the policy logic cannot produce invalid states.
Tools: Runtime Registration and HTTPFetch Execution
Tools are not compiled in. Any HTTP endpoint can become a tool at runtime without redeploying the binary:
# Register a weather API as a tool — takes effect immediately
curl -X POST http://localhost:8091/api/v1/actors/tool_executor/register_tool \
-d '{
"name": "weather",
"description": "Get current weather for a city",
"input_schema": {"type":"object","properties":{"city":{"type":"string"}}},
"handler_type": "service_link",
"handler_config": {"link_name":"openweather","path":"/data/2.5/weather","method":"GET"}
}'
ToolExecutorActor dispatches registered tools via host.http_fetch() and the only way to make outbound network calls from within the WASM sandbox:
@actor
class ToolExecutorActor:
tools: dict = state(default_factory=dict) # name ? spec
exec_count: int = state(default=0)
@init_handler
def on_init(self, config: dict) -> None:
self.tools = {t["name"]: t for t in _BUILTIN_TOOLS}
host.process_groups.join("svc:tool_executor")
@handler("register_tool")
def register_tool(self, name: str = "", description: str = "",
input_schema: dict = None, handler_type: str = "builtin",
handler_config: dict = None) -> dict:
self.tools[name] = {
"name": name, "description": description,
"input_schema": input_schema or {},
"handler_type": handler_type,
"handler_config": handler_config or {}
}
return {"status": "ok", "name": name}
@handler("execute")
def execute(self, name: str = "", input: dict = None) -> dict:
input = input or {}
if name not in self.tools:
return {"error": f"unknown tool: {name}"}
self.exec_count += 1
host.incr_counter(f"tool_{name}_total", 1)
spec = self.tools[name]
if spec.get("handler_type") == "service_link":
cfg = spec.get("handler_config", {})
resp = host.http_fetch(cfg["link_name"], cfg.get("method","GET"),
cfg["path"], input)
return {"result": resp}
# Built-in handlers
if name == "calculator":
expr = input.get("expression", "0")
try:
result = eval(expr, {"__builtins__": {}}) # demo only — see gaps section
return {"result": str(result)}
except Exception as e:
return {"error": str(e)}
if name == "memory_store":
mem_id, _ = pg_first("svc:memory")
if mem_id:
return ask(mem_id, "store_memory", input) or {}
if name == "memory_recall":
mem_id, _ = pg_first("svc:memory")
if mem_id:
return ask(mem_id, "recall_memory", input) or {}
return {"result": f"[simulated] {name} executed"}
Service Discovery: Process Groups vs. Object Registry
MiniHermes demonstrates both discovery patterns side by side.
Process Groups — simple, built-in, zero configuration:
# Every actor announces itself on startup
host.process_groups.join("svc:agent")
# Callers find the first available member — location-transparent
agent_id, err = pg_first("svc:agent")
result = ask(agent_id, "chat", {"message": "Hello"})
// Go version — same pattern
agentID, err := host.PG().First("svc:agent")
Object Registry — richer, capability-aware, preferred for production:
# On startup — declare what this actor can do
host.registry.register(ctx="", object_type="actor",
object_id=self.actor_id,
object_category="skill_store",
capabilities=["match_skills", "propose_skill", "lifecycle"])
# Caller — find an actor that specifically supports skill matching
actors = host.registry.discover(ctx="", object_type="actor",
object_category="skill_store",
required_capability="match_skills")
skill_id = actors[0]["object_id"] if actors else None
Process groups answer “is there anyone in this group?” Registry answers “is there anyone in this group who can do this?” The registry is the better choice when multiple actor versions may be deployed simultaneously, or when different instances offer different capabilities.
Audit Trail and Health Monitoring
Non-Blocking Audit with GenEvent
AuditEventActor uses the GenEvent behavior. Senders call host.send() with fire-and-forget so audit logging never adds latency to the critical path:
The TupleSpace audit log is append-only by construction, there is no ts.delete() in the sandbox. Every tool call, policy change, skill creation, cron execution, and circuit event lands here and stays queryable by event type.
Health Monitor with SendAfter Polling
HealthMonitorActor never subscribes to membership change events. It polls every service group on a fixed interval and writes a snapshot to TupleSpace:
_SERVICE_GROUPS = [
"svc:llm_gateway", "svc:tool_executor", "svc:agent",
"svc:skill_store", "svc:guardrails", "svc:audit",
"svc:cron_scheduler", "svc:session_manager", "svc:memory",
"svc:context_compressor", "svc:health_monitor",
]
@actor
class HealthMonitorActor:
poll_count: int = state(default=0)
last_poll_ms: int = state(default=0)
group_health: dict = state(default_factory=dict)
poll_interval_ms: int = state(default=5000)
@init_handler
def on_init(self, config: dict) -> None:
host.process_groups.join("svc:health_monitor")
host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})
@handler("poll_tick", "cast")
def poll_tick(self) -> None:
health = {}
for grp in _SERVICE_GROUPS:
try:
members = host.process_groups.members(grp)
health[grp] = len(members)
except Exception:
health[grp] = 0
self.group_health = health
self.poll_count += 1
self.last_poll_ms = host.now_ms()
host.ts.write(["health_snapshot", self.last_poll_ms, json.dumps(health)])
# Each tick reschedules the next — no external scheduler
host.send_after(self.poll_interval_ms, "poll_tick", {"op": "poll_tick"})
@handler("get_health")
def get_health(self) -> dict:
degraded = [g for g, c in self.group_health.items() if c == 0]
return {
"status": "ok" if not degraded else "degraded",
"group_health": self.group_health,
"healthy": len(self.group_health) - len(degraded),
"degraded": degraded,
}
Polling converges to the true state on every tick regardless of event ordering, it’s always eventually consistent and never stale for more than one poll interval.
Primitives Scorecard
MiniHermes uses 16 distinct PlexSpaces primitives across 12 actors:
Durable parallel skill extraction with cancel/query
Durability (checkpoint_interval)
All stateful actors
State persistence across crashes and restarts
GenFSM
Guardrails
Validated state machine; invalid transitions rejected
GenEvent
Audit
Non-blocking event delivery; callers never wait
Known Gaps
MiniHermes is a proof of concept, not a production system. The same disclaimer applies here as in the MiniClaw post: the point is to demonstrate what the architecture can support, not to ship something you should run in production today.
Skill quality and safety. The extraction workflow uses LLM reflection without any validation layer. Extracted skills can be incorrect, subtly wrong, or even harmful if the original task involved a bad assumption. A production system needs automated skill evaluation, human review for high-impact skills, and version history with rollback.
Calculator eval. The built-in calculator tool uses Python’s eval() with empty builtins. This is a demo shortcut. In production, replace it with an AST-based evaluator or a sandboxed tool actor in its own WASM module with no outbound capabilities at all.
Skill matching at scale. TupleSpace keyword matching works well up to thousands of skills. For a large skill store, keyword overlap produces too many false positives. The fix is an embedding-based vector index for semantic similarity but that requires an embedding model and an external vector store.
Context compression quality. The compressor summarizes the middle of the conversation with a generic prompt. It does not distinguish between a casual exchange and a chain of tool results that the later part of the conversation depends on. Poor summarization can cause the agent to “forget” a result it needs. Production compression needs to identify load-bearing context and exclude it from summarization.
No per-session actor instances.AgentActor stores self.messages as actor state, which all chat calls within one actor share. This is safe when there is one actor per session, but the POC maps many sessions to one actor instance. A production deployment should either run one actor per session or explicitly key all state by session_id.
No prompt injection defense. Tool results flow back into the conversation without any sanitization. A malicious tool response could attempt to override the system prompt. Production systems need input/output validation and possibly an LLM-as-judge layer between tool results and the next LLM call.
Circuit breaker threshold is fixed. Three consecutive failures opens the circuit. A slow provider that times out 20% of the time would never trip the breaker. Production needs adaptive thresholds based on error rate windows, not just consecutive failure counts.
No credential management. The LLM gateway reads provider API keys from service link configuration, which in this POC are stored in app-config.toml. A production system needs the phantom-token pattern from MiniClaw: the gateway resolves a real key from actor-private KV and never echoes it in any response or log.
MiniHermes vs. MiniClaw: Complementary, Not Competing
Dimension
MiniClaw
MiniHermes
Primary focus
Security and multi-tenant isolation
Self-improvement and operational resilience
Agent topology
Multi-agent orchestration with sub-tasks
Single self-improving long-lived agent
Session model
Ephemeral per-request
Long-lived with LLM-based compression
Skill learning
None — static tool catalog
Automatic from conversation, durable workflow
Scheduling
None
Distributed cron with DistLock + Channel
LLM integration
Simulated only
Real Ollama + OpenAI + Anthropic, hot-swap
Provider management
None
Hot-swap + gradual circuit breaker
Memory tiers
Single KV scope
Core / Reachable / Deep across three storage layers
Guardrails
WASM + actor isolation (structural)
GenFSM gate with per-tool runtime policies
Credential handling
Phantom token in actor-private KV
Service link config (see gaps)
Observability
TupleSpace audit, health polling
Same, plus IncrCounter metrics on every operation
MiniClaw establishes the security foundation with WASM isolation, tenant enforcement, credential proxying, blast-radius containment. MiniHermes builds on that same foundation to add learning, resilience, and operational flexibility. A production system would combine both.
brew install ollama
ollama run llama3.2 # pulls ~2GB on first run
All tests pass without any LLM running. When Ollama is available, LLMGatewayActor switches automatically from the simulated fallback to real inference.
Build and Test
# Python
cd examples/python/apps/minihermes
./build.sh # componentize-py ? WASM Component Model binary
pytest test_minihermes.py -v # unit tests, no live node required
# Go
cd examples/go/apps/minihermes
./build.sh # TinyGo ? wasm-tools ? component binary
go test ./... -v # unit tests, no live node required
Integration Tests Against a Live Node
# Start a PlexSpaces node first — see docs/getting-started.md
cd examples/go/apps/minihermes
./test.sh 8091 # 21 steps, roughly 2 minutes
The test script covers the full actor tree:
# Basic agent chat
ask "agent" '{"op":"chat","message":"Hello","session_id":"test-1"}'
# Tool use — triggers guardrail check before execution
ask "agent" '{"op":"chat","message":"Calculate 42 * 17","session_id":"test-1"}'
# Hot-swap LLM provider
ask "llm_gateway" '{"op":"switch_provider","provider":"anthropic","model":"claude-opus-4-8"}'
# Register a new tool at runtime
ask "tool_executor" '{
"op":"register_tool","name":"weather",
"description":"Get weather for a city",
"input_schema":{"type":"object","properties":{"city":{"type":"string"}}},
"handler_type":"service_link",
"handler_config":{"link_name":"openweather","path":"/data/2.5/weather","method":"GET"}
}'
# Create a cron job
ask "cron_scheduler" '{
"op":"create_job","job_id":"morning-digest",
"prompt":"Summarize pending tasks","schedule":"every_24h","session_id":"cron-main"
}'
# Block a tool via guardrails
ask "guardrails" '{"op":"set_policy","tool_name":"delete_file","decision":"deny"}'
# Query health across all service groups
ask "health_monitor" '{"op":"get_health"}'
# Query audit trail for tool executions
ask "audit_event" '{"op":"query_events","event_type":"tool_executed"}'
Conclusion
MiniHermes is a proof of concept, not a production agent platform. What it demonstrates is a way of thinking about agent systems that is different from the standard monolith approach. The Hermes Agent design from Nous Research gives us three powerful ideas: prompt discipline, multi-step tool loops, and skill accumulation. Those ideas work whether the agent runs in one Python process or across 12 actors. What changes is everything else, e.g., what happens when a component crashes, how you update a policy without restarting, how you prevent one tenant’s data from touching another’s, and how you keep conversations going past the model’s context limit.
The actor model with PlexSpaces provides a set of primitives like KV, TupleSpace, BlobStorage, Channel, DistributedLock, SendAfter, GenFSM, GenEvent, Workflow that map directly onto the operational problems an agent system faces. State durability, fault isolation, leader election, non-blocking audit, validated state machines, durable workflows: each is one primitive. The full source for both Python and Go implementations lives at github.com/bhatti/PlexSpaces. The architecture is meant to be a starting point, not a finished product.