Skip to content

Eight Architectural Decisions for Sovereign Agent

Before you start

Prerequisite: Lesson 28 — The two-halves architecture, which introduced the loop half / structured half split as one instance of "put decisions with consequences in code, not in a prompt." After this lesson, you can: name each of sovereign-agent's eight architectural decisions by the specific bug it removes, and score any agent framework — including one you're building — by how many bug classes it removes versus how many it merely handles after the fact.

Twelve users running an agent at once, and User A's memory shows up in User B's conversation. Two users' tool outputs overwrite the same ./workspace/output.txt. A crash loses half of everyone's in-progress work. That's the bug that forced Decision 1 below, and it's one of eight — none of them clever, all of them expensive to learn the hard way.

None of the eight decisions in this lesson are clever. That's worth saying up front, because it's tempting to read a list like this as a bag of tricks. It isn't. Every one of them came from a specific bug, a specific outage, or a specific debugging session that cost real hours — and the point of walking through all eight together, rather than as isolated tips, is that they compound. Append-only tickets plus SHA-256 manifests plus forward-only state don't just each solve their own problem; together they make a session auditable in a way no single decision gets you alone.

Read these like scar tissue. Each one is a lesson someone already paid for, so you don't have to.

The question that organizes all eight

Here's the frame to hold while you read: for each decision, ask "what class of bug does this remove, versus what class of bug does it just help me handle after it happens?" A framework that removes bug classes ages well. A framework that only helps you handle them accumulates handler code forever, and the handler code itself becomes a second framework you now have to maintain.

Decision 1 — Session directories

Pain point: twelve users running an agent simultaneously and User A's memory shows up in User B's conversation, two users' tool outputs overwrite the same ./workspace/output.txt, a crash loses half of everyone's in-progress work. These aren't separate bugs — they're one bug: no boundary between what belongs to whom.

Naive approach: a global database, conversations in one table, tool results in another, memory in a third, joined by user_id and session_id. Every feature needs a migration; a bug in a query filter leaks one tenant's data into another's; crash recovery means replaying an event log that has to be perfectly correct.

Decision: every run gets its own directory, sessions/sess_<12hex>/, and everything for that run lives inside it — session.json, workspace/, memory/, ipc/, tickets/, logs/trace.jsonl.

Tradeoff: no cross-session queries without writing code — you can't SELECT across all sessions for a keyword in one statement. In exchange, isolation is physical: User A's data is in a different path, not a different row a bug can leak across.

Decision 2 — Forward-only state machine

Pain point: a task fails halfway through, after it's already written files, called a paid API, sent an email. A retry that resets state to planning and reruns doesn't undo any of that — it runs on top of it. The second API call is a duplicate charge.

Naive approach: reset the state field and rerun in place.

Decision: state only ever moves forward — created → planning → executing → halved → complete (or failed/escalated), never backward. A retry is a new session that references the old one, create_session(retries_from=old_session.id).

Tradeoff: more session directories accumulate over time (a housekeeping cost, not a correctness one). In exchange, every session has exactly one history, and you can always tell how many times something was attempted by counting sessions instead of guessing from mutated state.

Decision 3 — Tickets

Pain point: the agent misbehaved in production and your manager asks what it actually did. Your logs say INFO: tool call succeeded — no tool name, no arguments, no result, no order.

Naive approach: regular text logs, written for a human skimming, not for reconstructing a sequence of operations.

Decision: every operation — planner call, executor turn, tool invocation, handoff — writes an append-only ticket to tickets/, with a ticket ID, timestamps, a summary, and (for file-producing operations) a manifest. Tickets are never edited or deleted; a correction is a new ticket.

Tradeoff: more files, more disk. In exchange, "what happened in this session?" always has one answer: read the tickets in order.

Decision 4 — SHA-256 manifests

Pain point: a colleague asks, a week later, whether workspace/report.md is still the agent's original output or whether someone edited it. The file's modification time proves nothing — touch -d forges it in one command, and an absent-minded editor save bumps it by accident.

Naive approach: trust the modification time.

Decision: when a ticket records that files were produced, it records the SHA-256 hash of each file's content:

python
ticket.manifest = Manifest(
    files=["workspace/report.md"],
    sha256={"workspace/report.md": "c891f7a2b...  (64 hex chars)"}
)
# Later: ticket.manifest.verify() — True only if content still
# hashes to the recorded value, False if even one byte changed

Tradeoff, stated honestly: this catches accidental edits and disk corruption — it does not catch a determined attacker who rewrites both the file and the manifest on the same disk. That's not cryptographic security, it's an integrity check against accidents. Signed manifests with external keys are a v0.2 concern if the threat model changes.

Decision 5 — Filesystem IPC

Pain point: the loop half and the structured half need to talk — when the loop half finishes, it has to tell the structured half "your turn, here's the context." The standard tutorial answer is a message broker: RabbitMQ, Redis, Kafka. Now you're installing and running the broker, wiring its config and credentials, and debugging why it won't connect on a student's laptop.

Naive approach: stand up a message broker for two processes on the same machine.

Decision: the two halves communicate by writing files to a shared directory, using an atomic rename:

python
tmp = ipc_dir / "handoff_to_structured.json.tmp"
tmp.write_text(json.dumps(payload))
tmp.rename(ipc_dir / "handoff_to_structured.json")  # ← the magic line

rename() is atomic under POSIX — it either fully happens or fully doesn't — and that guarantee holds across ext4, APFS, and NTFS. A reader never observes a half-written file; there's no in-between state to race against.

Tradeoff: reading a file costs roughly 1ms versus roughly 0.1ms for a broker message. Invisible for an agent making an LLM call every few seconds; would matter for high-frequency trading. Atomic rename is the only IPC primitive you need until you've measured a reason otherwise.

Decision 6 — Per-session serialization

Pain point: the executor is mid-write on three related memory facts. It's written two of three when the planner, running in parallel, reads memory to prepare the next subgoal — and sees an incomplete, inconsistent view. Worse: if the executor crashes after two of three writes, the inconsistency is permanent.

Naive approach: lock each file individually. This gives per-file atomicity but not "all three together or none" — and locking multiple files in different orders invites deadlock, which is a worse problem to debug than the one you started with.

Decision: lock at the session level, not the file level — at most one operation touches a given session at a time; different sessions run in parallel freely.

Tradeoff: the planner and executor of the same session can't run concurrently. That's the point, not a bug — the cost is a small amount of latency within one session, and the benefit is that "what's happening right now in this session" always has exactly one answer.

Decision 7 — Defensive JSON parsing

Pain point: the planner is told, explicitly, "respond with ONLY a JSON array, no prose, no markdown fences." It responds with prose, wrapped around JSON, wrapped in markdown fences anyway. json.loads() crashes. This isn't rare or model-specific — in Rod's own sovereign-agent test runs, logged while building this framework rather than pulled from a published benchmark, MiniMax-M2.5 wrapped its JSON in fences roughly 40% of the time, Qwen3 about 15%, GPT-4 about 5%, despite the explicit instruction in every case.

Naive approach: add "no markdown fences" to the prompt and hope. It works for a while, until a model update changes the failure rate and your alerts fire at 2 AM.

Decision: assume the model is going to violate the format, and parse defensively — strip fences if present, fall back to extracting the first [...] block if json.loads still fails, and validate the shape before trusting it.

Tradeoff: more parsing code to maintain, and it never fully eliminates edge cases. In exchange, the pipeline survives the roughly 5-to-40% of responses that don't match the instruction, instead of crashing on them.

Decision 8 — Explicit tool registry

Pain point: the code-review scenario is given exactly the task "analyze this Python source, write findings to review.md" — and the LLM calls list_files, calls it again, calls read_file on a path that doesn't exist, and gives up. It never calls the analyzer tool it actually needed. Two hours of adding "do not call list_files" to the prompt didn't stop it — the model has seen millions of training trajectories where "first, see what's on disk" is step one, and no instruction reliably overrides that reflex.

Naive approach: register every available tool "for flexibility" and prompt the model away from the ones you don't want used.

Decision: register tools explicitly, per scenario — the code-reviewer scenario gets exactly three: write_file, complete_task, analyze_workspace_file. No list_files, no read_file. The LLM cannot call a tool that isn't in its registry; the option doesn't exist to be reached for.

After this change, in Rod's own retest of that same code-reviewer scenario: the agent succeeded on the first try, every time.

The meta-principle underneath all eight

"Please don't call list_files." "Respond with ONLY JSON." "Never book parties over 8."

Each of these is a request. The model usually complies — 85%, 95%, whatever the number happens to be for that model, that day. "Usually" is a probability, not a guarantee, and the failure mode shows up exactly when you're not watching.

Prompts are advisory. Registries and rules are physics.

That line generalizes past tools: want the model to stay in the loop half? Don't list "structured" as an available half in the planner's prompt. Want it to not reach the network? Run the tool in a container with no network access. Every time you catch yourself writing a third "do not X" instruction into a prompt, that's the signal — stop adding instructions and remove the capability instead.

Eight decisions, one shape: remove the bug class, don't just handle it
Quick check — Why does the lesson insist on covering all eight decisions together instead of letting you pick your favorite two or three?

What to carry forward

You now have the architecture vocabulary — session directories, tickets, manifests, atomic rename, session-level locks, defensive parsing, explicit registries, and the loop/structured split from the previous lesson. The next lesson uses that vocabulary to tell a different kind of story: not what the architecture is, but the six-hour sequence of real failures that forced several of these decisions into existence in the first place.

Continue to Lesson 30

Eight real failures, in the order they actually happened, from an AttributeError to a fabricated code review of functions that don't exist.

Have a question about this lesson?

Reply here and it goes straight to Rod. Same as replying to one of his emails.