Skip to content

Commit 9782faa

Browse files
committed
Add ORGA architecture deep-dive blog post
Introduces typestate-enforced phase ordering, policy-as-phase, durable journaling, and cryptographic audit in Symbiont's reasoning loop.
1 parent 625555b commit 9782faa

1 file changed

Lines changed: 288 additions & 0 deletions

File tree

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
---
2+
layout: post
3+
title: "ORGA: A Typestate-Enforced Agent Runtime That Makes Policy a Phase, Not a Feature"
4+
author: ThirdKey Team
5+
categories: [AI Security, Architecture, Agent Runtime]
6+
tags: [symbiont, orga, typestate, policy, reasoning loop, rust, agent architecture, zero trust]
7+
---
8+
9+
Every agent framework has a loop. Call the LLM, parse the tool calls, execute them, feed the results back. ReAct, AutoGPT, LangGraph, CrewAI — the shape is always the same. What differs is what happens when things go wrong, and more importantly, what *can't* happen at all.
10+
11+
Symbiont's reasoning loop is called **ORGA** — Observe, Reason, Gate, Act. The name is deliberate: the "Gate" phase isn't optional middleware or a plugin. It's a compile-time-enforced phase of execution that every agent action must pass through before it can reach the outside world.
12+
13+
This post introduces the architecture, explains the four innovations that make it novel, and walks through how they work together.
14+
15+
## The Problem with "Policy as Middleware"
16+
17+
Most agent frameworks treat safety as a layer you bolt on. A callback before tool execution. A filter on the output. An approval queue in a dashboard somewhere. These approaches share a failure mode: they can be bypassed, forgotten, or misconfigured.
18+
19+
Consider a typical agent loop:
20+
21+
```
22+
LLM generates tool call → Execute tool → Return result → Repeat
23+
```
24+
25+
Where does policy go? Usually one of two places:
26+
27+
1. **Before execution** — a hook that checks whether the tool call is allowed. If someone forgets to register the hook, the tool runs anyway.
28+
2. **After generation** — a filter on the LLM's output. If the output format changes slightly, the filter misses it.
29+
30+
Both approaches treat policy as external to the loop's core logic. The loop *works* without them. That's the problem.
31+
32+
## ORGA: Policy as a Mandatory Phase
33+
34+
ORGA restructures the agent loop so that policy evaluation is a phase transition — the same kind of primitive as "call the LLM" or "execute the tool." You can't skip it any more than you can skip reasoning.
35+
36+
```mermaid!
37+
flowchart LR
38+
O["Observe"] --> R["Reason"]
39+
R --> G["Gate"]
40+
G --> A["Act"]
41+
A --> O
42+
```
43+
44+
Each phase is a distinct type in Rust's type system. The loop physically cannot progress from Reason to Act without passing through Gate, because the compiler won't let you call `dispatch_tools()` on a value of type `AgentLoop<PolicyCheck>` — only `check_policy()` is available. And `dispatch_tools()` only exists on `AgentLoop<ToolDispatching>`, which you can only obtain from a successful policy check.
45+
46+
This is enforced at compile time. Not at runtime. Not by convention. By the type checker.
47+
48+
## The Four Pillars
49+
50+
ORGA's novelty isn't any single technique — it's combining four mechanisms into a single runtime primitive:
51+
52+
### 1. Typestate-Enforced Phase Ordering
53+
54+
Each phase of the loop is a zero-sized type marker:
55+
56+
```rust
57+
pub struct Reasoning;
58+
pub struct PolicyCheck;
59+
pub struct ToolDispatching;
60+
pub struct Observing;
61+
62+
pub struct AgentLoop<Phase: AgentPhase> {
63+
pub state: LoopState,
64+
pub config: LoopConfig,
65+
_phase: PhantomData<Phase>,
66+
}
67+
```
68+
69+
Phase transitions consume `self` and return the next phase:
70+
71+
```rust
72+
impl AgentLoop<Reasoning> {
73+
pub async fn produce_output(self, ...)
74+
-> Result<AgentLoop<PolicyCheck>, LoopTermination>;
75+
}
76+
77+
impl AgentLoop<PolicyCheck> {
78+
pub async fn check_policy(self, gate: &dyn ReasoningPolicyGate)
79+
-> Result<AgentLoop<ToolDispatching>, LoopTermination>;
80+
}
81+
82+
impl AgentLoop<ToolDispatching> {
83+
pub async fn dispatch_tools(self, ...)
84+
-> Result<AgentLoop<Observing>, LoopTermination>;
85+
}
86+
87+
impl AgentLoop<Observing> {
88+
pub fn observe_results(self) -> LoopContinuation;
89+
}
90+
```
91+
92+
Each transition method is only defined on its phase type. `AgentLoop<Reasoning>` has `produce_output()` but not `dispatch_tools()`. `AgentLoop<ToolDispatching>` has `dispatch_tools()` but not `check_policy()`. The move semantics mean the old phase is consumed — you can't hold onto both sides of a transition.
93+
94+
Invalid phase orderings aren't runtime errors. They're compile errors. You literally cannot write code that skips the Gate.
95+
96+
### 2. Policy-as-Phase
97+
98+
The Gate is implemented through the `ReasoningPolicyGate` trait:
99+
100+
```rust
101+
#[async_trait]
102+
pub trait ReasoningPolicyGate: Send + Sync {
103+
async fn evaluate_action(
104+
&self,
105+
agent_id: &AgentId,
106+
action: &ProposedAction,
107+
state: &LoopState,
108+
) -> LoopDecision;
109+
}
110+
```
111+
112+
Every action the LLM proposes — tool calls, delegations, responses, terminations — is submitted to the gate. The gate returns one of three decisions:
113+
114+
- **Allow**: Action proceeds to dispatch
115+
- **Deny**: Action is blocked, and the denial reason is fed back to the LLM as an observation
116+
- **Modify**: Action is rewritten (e.g., parameters redacted) and then dispatched
117+
118+
The denial feedback loop is key. A denied action doesn't crash the agent or terminate the loop. The LLM learns *why* the action was denied and can try a different approach. The agent self-corrects within policy boundaries.
119+
120+
Symbiont ships three gate implementations:
121+
122+
| Gate | Use Case |
123+
|------|----------|
124+
| `DefaultPolicyGate` | Delegates to the DSL policy engine |
125+
| `CedarPolicyGate` | Formal authorization via AWS Cedar policies |
126+
| `ToolFilterPolicyGate` | Simple allowlist/denylist for tool names |
127+
128+
A Cedar policy example:
129+
130+
```cedar
131+
// Allow all agents to respond to users
132+
permit(principal, action == Action::"respond", resource);
133+
134+
// Forbid any agent from calling the delete tool
135+
forbid(principal, action == Action::"tool_call::delete_production_db", resource);
136+
```
137+
138+
The gate is never optional. Even with no explicit policy configured, a `DefaultPolicyGate` evaluates every action. The zero-policy case is "allow all" — but it's still *evaluated*, still journaled, still auditable.
139+
140+
### 3. Durable Journaling
141+
142+
Every phase transition emits a journal entry:
143+
144+
```rust
145+
pub struct JournalEntry {
146+
pub sequence: u64,
147+
pub timestamp: DateTime<Utc>,
148+
pub agent_id: AgentId,
149+
pub iteration: u32,
150+
pub event: LoopEvent,
151+
}
152+
153+
pub enum LoopEvent {
154+
Started { agent_id: AgentId, config: LoopConfig },
155+
ReasoningComplete { iteration: u32, actions: Vec<ProposedAction>, usage: Usage },
156+
PolicyEvaluated { iteration: u32, action_count: usize, denied_count: usize },
157+
ToolsDispatched { iteration: u32, tool_count: usize, duration: Duration },
158+
ObservationsCollected { iteration: u32, observation_count: usize },
159+
Terminated { reason: TerminationReason, iterations: u32, total_usage: Usage, duration: Duration },
160+
}
161+
```
162+
163+
Journal writes happen at phase boundaries — *before* state changes, not after. This means a crashed loop can recover from the last completed phase without re-invoking the LLM. If the agent crashes after `ReasoningComplete` but before `PolicyEvaluated`, the recovery path knows the LLM's proposed actions and can resume from the policy check.
164+
165+
Two journal backends ship with the runtime:
166+
167+
- **`BufferedJournal`**: In-memory ring buffer (default, fast, ephemeral)
168+
- **`DurableJournal`**: Persistent storage via a pluggable `JournalStorage` trait for production workloads
169+
170+
The journal is also the foundation for observability. Every iteration's token usage, tool dispatch latency, policy denial count, and termination reason is recorded. You don't need to instrument the loop — the loop instruments itself.
171+
172+
### 4. Cryptographic Audit
173+
174+
For high-assurance deployments, Symbiont extends journaling with a Merkle-chained, Ed25519-signed audit trail:
175+
176+
```rust
177+
pub struct CriticAuditEntry {
178+
pub entry_id: String,
179+
pub director_output_hash: String, // SHA-256 of LLM output
180+
pub critic_assessment_hash: String, // SHA-256 of evaluation
181+
pub verdict: AuditVerdict, // Approved, Rejected, NeedsRevision
182+
pub chain_hash: String, // SHA-256(prev_hash || entry_data)
183+
pub signature: String, // Ed25519 over chain_hash
184+
pub timestamp: DateTime<Utc>,
185+
}
186+
```
187+
188+
Each entry's `chain_hash` is computed from the previous entry's hash concatenated with the current entry's data, then signed with Ed25519. This creates a tamper-evident chain: modifying any entry invalidates all subsequent signatures.
189+
190+
Verification recomputes the chain from genesis and checks every signature:
191+
192+
```rust
193+
pub fn verify_chain(
194+
entries: &[CriticAuditEntry],
195+
verifying_key: &VerifyingKey,
196+
) -> Result<(), AuditError>
197+
```
198+
199+
If an entry has been modified, inserted, deleted, or reordered, verification fails with the exact index of the first inconsistency. This isn't just loggingit's a cryptographic proof of what the agent did, in what order, and what policy decisions were made.
200+
201+
## How It All Fits Together
202+
203+
The `ReasoningLoopRunner` orchestrates the full cycle:
204+
205+
```rust
206+
async fn run_inner(&self, state: LoopState, config: LoopConfig) -> LoopResult {
207+
let mut current_loop = AgentLoop::<Reasoning>::new(state, config);
208+
209+
loop {
210+
// OBSERVE: inject knowledge, manage context
211+
if let Some(ref bridge) = self.knowledge_bridge {
212+
bridge.inject_context(&agent_id, &mut current_loop.state.conversation).await.ok();
213+
}
214+
215+
// REASON: call inference provider
216+
let policy_phase = current_loop
217+
.produce_output(self.provider.as_ref(), self.context_manager.as_ref())
218+
.await?;
219+
self.journal.append(/* ReasoningComplete */).await;
220+
221+
// GATE: evaluate every proposed action
222+
let dispatch_phase = policy_phase
223+
.check_policy(self.policy_gate.as_ref())
224+
.await?;
225+
self.journal.append(/* PolicyEvaluated */).await;
226+
227+
// ACT: execute approved actions
228+
let observe_phase = dispatch_phase
229+
.dispatch_tools(self.executor.as_ref(), self.circuit_breakers.as_ref())
230+
.await?;
231+
self.journal.append(/* ToolsDispatched */).await;
232+
233+
// OBSERVE: decide whether to continue or terminate
234+
match observe_phase.observe_results() {
235+
LoopContinuation::Continue(next) => current_loop = *next,
236+
LoopContinuation::Complete(result) => return result,
237+
}
238+
}
239+
}
240+
```
241+
242+
Notice the type transitions: `current_loop` starts as `AgentLoop<Reasoning>`, becomes `AgentLoop<PolicyCheck>` after reasoning, becomes `AgentLoop<ToolDispatching>` after the gate, becomes `AgentLoop<Observing>` after dispatch, and then either becomes a fresh `AgentLoop<Reasoning>` for the next iteration or terminates.
243+
244+
The builder enforces required dependencies at compile time too:
245+
246+
```rust
247+
let runner = ReasoningLoopRunner::builder()
248+
.provider(cloud_provider) // Required — won't compile without
249+
.executor(tool_executor) // Required — won't compile without
250+
.policy_gate(cedar_gate) // Optional — defaults to permissive
251+
.journal(durable_journal) // Optional — defaults to in-memory
252+
.build();
253+
```
254+
255+
## Why This Combination Matters
256+
257+
Each of these techniques exists independently. Typestate patterns are well-known in Rust. Policy engines are commodity. Append-only logs are everywhere. Merkle chains are blockchain 101.
258+
259+
The novelty is combining them into a single agent runtime primitive where:
260+
261+
- **Phase ordering is compile-time**: You can't write an agent that skips policy
262+
- **Policy is a phase**: Not middleware, not a hook — a mandatory state transition
263+
- **Every transition is journaled**: Crash recovery without LLM re-invocation
264+
- **The journal is cryptographically chained**: Tamper-evident proof of agent behavior
265+
266+
No existing agent framework provides all four. Most provide zero or one. The result is a runtime where "the agent did X without authorization" is not a failure mode — it's a type error.
267+
268+
## Getting Started
269+
270+
Symbiont is open source under the Apache 2.0 license:
271+
272+
```bash
273+
# Install
274+
cargo install symbi
275+
276+
# Or via Docker
277+
docker pull ghcr.io/thirdkeyai/symbi:latest
278+
```
279+
280+
The ORGA loop is the core of every agent built with Symbiont — from simple tool-calling assistants to fleet-managed autonomous agents with external integrations.
281+
282+
- **Source**: [github.com/thirdkeyai/symbiont](https://github.com/thirdkeyai/symbiont)
283+
- **Documentation**: [symbiont.dev](https://symbiont.dev)
284+
- **SDKs**: [Python](https://pypi.org/project/symbiont-sdk/) and [JavaScript](https://www.npmjs.com/package/symbiont-sdk-js) wrappers available
285+
286+
---
287+
288+
*ORGA is part of the [Symbiont](https://symbiont.dev) agent runtime, built by [ThirdKey AI](https://thirdkey.ai). It integrates with [SchemaPin](https://schemapin.org) for tool integrity and [AgentPin](https://agentpin.org) for cryptographic agent identity.*

0 commit comments

Comments
 (0)