|
| 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 logging — it'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