Design Decisions - joeylking/agent-runtime GitHub Wiki

Design Decisions

The repository keeps short decision records under docs/decisions, nine of them. The first six date from v0.3.0 and earlier, and v0.3.1 added none. v0.4.0 added two, 7 and 8, which are about what the project is for, and v0.5.0 added 9, which is about what a decision can be checked against afterwards. This page gives each the space to say what it rules out and what it cost.

1. A small custom runtime rather than an agent framework

The tool loop is fifty lines with any model SDK. What is not available off the shelf, and what the first consumer needs, is policy enforced outside the model, a durable pause with the approval bound to the exact request, hard limits enforced by the driver, an audit log written with the state, and a loop that runs against a scripted agent in tests. Frameworks bundle those with orchestration and prompt machinery this project does not want. The cost is discipline: every public type is justified by a consumer requirement, and speculative abstractions are refused. The decision would be revisited if a second consumer with different needs appeared, or if the first stopped needing the pause, policy, and limit machinery.

2. Persisted current state plus an audit log, not event sourcing

Runs, steps, approvals, and model calls are the source of truth; events are appended in the same transaction and never read for control flow. Resumption happens at most a few times per run and needs no replay into alternative states. Ordinary table migrations are simpler and safer than versioned event payloads with upcasters, and the current state is visible with one query rather than after a fold. The cost is two writes per transition, kept honest by a completeness test and a consistency test on the log. Event sourcing would be reconsidered if a consumer needed projections or replay into branches.

3. The model never decides its own permissions

Every tool request passes schema validation and then a policy evaluated by the runtime with a read-only view of the run. The agent receives outcomes as observations and has no handle to the policy. Repository content, dependency sources, and command output are untrusted input to the model; whatever the model concludes from them, the set of actions it can take is bounded by code the model cannot influence. The alternatives, asking the model to confirm risky actions or giving it a permissions tool, put the decision inside the thing being defended against. The cost is that policy needs domain facts, so the consumer supplies them through an interface that is testable without a model. This decision is the premise of the project and is not revisited.

4. Tools from someone else's server are classified by the operator

A tool reached over MCP is registered only when an operator has named its side-effect class; the tool set is pinned by a hash over each tool's name, description, and input schema; and the server's own annotations may refuse a registration but never relax one. Policy is evaluated on a side-effect class, so whoever supplies the class decides what the runtime will allow. The server is code this project did not write, often chosen by whoever is being helped rather than whoever is accountable, and the specification itself says a client must never base decisions on annotations; the model cannot supply the class either, because the tool list is model input and the model is the thing being bounded. That leaves the operator, who writes one line per tool and is the person an audit log names. Descriptions are pinned because they are model input: a reworded description is an injection vector, not a cosmetic edit. An annotation is still worth recording, because when the server calls a tool destructive and the operator called it a local mutation, one of them is wrong, and refusing until the operator says which costs nothing; the converse is not evidence, since a hostile server would call its own tool read-only.

The record grew with the security review. The pin is taken over the raw bytes the server sent, because the SDK's float64 decoding would let a bound move from 9007199254740993 to 9007199254740992 unnoticed; a listing that names a tool twice is refused and listings are bounded. A Deny or Fixed name must be a top-level property nothing else in the schema mentions, or the tool is refused rather than partly rewritten, and a call that sets a fixed parameter fails rather than having the operator's value silently replace the model's behind an approval. A stdio server gets PATH, HOME, and TMPDIR plus what the operator names, not the operator's environment.

What it does not protect, stated in the record itself: the pin covers what a server presents, not what it does; the tool set is fixed at Load and tools/list_changed is ignored; annotations and output schemas are outside the hashes; nothing sandboxes a stdio server beyond its environment. The cost is that an operator classifies every tool before the first run, a server upgrade that rewords a description stops the tools it reworded, and a tool with no rule is unavailable rather than read-only, so a forgotten line is a missing capability, which is the failure this project prefers. Revisit when servers ship signed, versioned tool metadata an operator could delegate to, or a consumer needs a tool set discovered per run. See MCP Tools.

5. A running run is leased to the process executing it

A run in RUNNING carries a lease, an owner and an expiry, on its own row: taken with the move to RUNNING, released with the pause or finish, renewed by a wall-clock heartbeat for as long as the call executing the run lives. Every write the loop makes to a running run requires it; a Resume of a run leased live by another owner returns ErrRunLeased and changes nothing; one whose lease has expired takes it over as interrupted. A run found running was either executing or abandoned, nothing told the two apart, and Resume assumed abandoned: a second process could take over a live run, whose tool call in flight still completed while the newcomer re-entered the step. A lease is the smallest record that answers the question, because a live owner renews it and a dead one cannot.

The details each came from a failure. The heartbeat is time-based, not per step, because a tool call can outlast any TTL a crash should be detected within. It renews on its own connection, because on the shared one a consumer holding DB() starved it. The writes that start a side effect check the expiry by the wall clock, not just the owner, so a stalled loop starts nothing. The stored owner carries a random suffix per driver, because a sweep reproduced a side effect executed twice when two replicas shared a configured name. Lease times never come from Config.Now, because the number and order of Now readings is a contract with consumers. Only a takeover writes an event, so an ordinary run's events are unchanged.

The alternatives were an advisory file lock or SQLite's own locking, which leave no record a reader of the row can see; a separate leases table, a second place a run's state lives; renewing in every write, which misses the long calls a lease is for; and stamping lease times with Now. The cost is that a dead process blocks Resume of its run for up to one TTL, 30 seconds by default, even for the same process restarted, because a name is not proof the old process is gone; that processes sharing a file need clocks agreeing well within the TTL; and a goroutine, a second connection, and a write every TTL/3 per running call. Revisit when runs are executed by more than one machine against a store that is not one SQLite file. See Run Lease.

6. An interrupted side effect waits for an operator

When Resume takes over a run and finds a step that was executing a tool that is not ReadOnly, and the consumer has no Config.Reconcile, the run does not continue. It pauses on that step's recorded request, through the ordinary pause, on an approval of kind interrupted_side_effect whose presentation says the call started and its outcome is unknown. Approving runs it again, once, on the same step; rejecting ends the run. A step whose tool is gone or whose arguments no longer pass the schema fails the run as reconcile_conflict. A read-only or deciding step continues as before.

Before this, such a run went back to the agent, which saw that its last call had no result and asked for it again in a new step: a payment, a push, or a message sent twice, with nothing in the record saying the second was a repeat. The runtime cannot tell whether a call that started took effect; only the consumer's records or a person can, and a consumer that wrote no reconciliation has said nothing about how to find out.

The refused alternative was documenting at-least-once delivery and requiring every tool to be idempotent. Idempotency is a property of the outside system a tool talks to, not something the runtime or most consumers can guarantee, and it would make the runtime's own claim, that a gated action runs once per approval, false in exactly the case an operator most needs it. Failing the run outright was safe but left no way to finish a run whose call an operator had checked by hand; requiring every consumer to supply Reconcile broke every consumer that has none. The cost is that a consumer without Reconcile that relied on a run carrying on after a crash now sees it wait, and an operator decides something the runtime cannot check. Revisit when tools can declare that they are idempotent, or report whether a given call took effect. See Interruption and Reconciliation.

7. The runtime owns the effect, not the loop

The runtime's controls are offered to a loop it does not own. A Gate takes each action a caller's loop proposes and does what the Driver does with its agent's: it records the decision verbatim, checks the arguments against the tool's schema, evaluates policy, pauses on a hash-bound approval, enforces the limits, takes the run's lease, executes the tool, and writes the observation and the audit events in the same transactions. The caller decides, and the gate decides whether it runs, runs it, and records it. The Driver stays, as the reference loop and the one both consumers use, and it now runs on the same code, so there is one implementation of the sequence and not two that could drift. A path where the caller runs the tool and reports the outcome back is refused.

The reason is a research pass on 2026-10-05 that compared the project with agent frameworks, durable execution engines, automation platforms, and agent security tooling. The loop, durable state, pause-and-resume approval, budgets, and tracing are now standard in every agent framework and every durable execution engine. Owning the loop is no longer a reason to adopt this library, and it stopped anyone who already had a loop from adopting it at all, because the choice was to replace theirs. What was not found elsewhere in Go, by reading the code of Microsoft's Agent Governance Toolkit Go package, Google's ADK Go, and Eino, is the part this project was built around: an approval bound by hash to the exact request, durable, with policy re-evaluated on resume; the rule that an interrupted side effect is not run again without a reconciliation or an operator; an audit event committed with the state change it explains; and MCP tools pinned by hash and classified by an operator. None of these is about the loop. Each is about the effect, the moment a tool is called and something outside changes. So the identity is to own the effect and not the loop: any loop proposes an action, and the gate decides, executes, and records.

The gate executes the tool because the runtime's strongest claims depend on it. An action the policy gated runs once per approval, and an interrupted side effect waits for an operator, because the runtime writes the tool's start before the call and its outcome after, under the lease. If the caller ran the tool and reported back, the runtime would be recording a claim: at-most-once would become the caller's promise, and the audit log would say what a program said happened. The cost is that a tool is registered with the gate and not left in the caller's own code.

This is the first addition justified by outside research and not by a consumer's demonstrated need, which decision 1 and the roadmap require. No consumer asked for it. The rule still governs abstractions inside the runtime, and this exception is deliberate, decided once, and recorded. It does not set a precedent for the next addition, which needs a consumer as before. Nothing a Driver consumer sees changes, and TestGate_ExternalLoopMatchesTheDriver holds that. The small additions that came with the gate are Decision.Origin, which records who proposed a step and changes no stored byte when empty; a Limits.GrantTTL check at Execute, because a caller holding an approved step can wait where a Driver cannot; Session.Input; and testkit.Scenario.Loop.

The alternatives were leaving the runtime as a loop, which keeps the project small and leaves it where every framework already is; a proxy or middleware that sits between a loop and its tools, which would work for any language and which decision 8 then built for one case; a path where the caller executes and reports the outcome, refused above; and two implementations of the sequence, which would drift, so that the first difference between them would be a control that held for one and not the other. The costs are that the gate is Go only; that a caller's loop is trusted as an Agent is, so the gate bounds what it can make happen and not what it decides; that a fresh step's gap between Propose and Execute is covered by the lease and by no other limit; and that the surface the project must keep stable is larger by a type and a handful of methods. Revisit when a loop in another language needs the same controls, or when a consumer's own loop shows the session needs something it lacks. See The Gate.

8. A stdio MCP proxy, one run per call

The gate gets a first form outside Go: agentrt-proxy, a local MCP proxy over stdio, in the nested module proxy. An MCP host starts it as its MCP server. It connects as a client to the real servers through the mcp module, which pins their tools and takes each tool's class from the operator, and it routes every tools/call through a Gate, which validates, evaluates policy, pauses on a hash-bound durable approval, executes the upstream call itself, and records. Each tool call is its own run. A call that needs an approval answers in prose that it is pending, and the model collects the approval by calling again with the same arguments, and a status tool reports what waits. Approvals are decided only outside the host. An identical mutating call within a window is refused. The policy is a configuration file with no language. It is a small, deliberately scoped experiment: the Go library remains the full form, and the proxy adds one export to the core, the canonical JSON encoder its hashes already use.

Decision 7 left open whether a loop in another language could have the gate's controls. A design round on 2026-10-05 answered it for the commonest such loop, an MCP host: an inventory of the code, research on how MCP hosts behave, an independent design, and a measurement of how models react to a pending result. A proxy needs no change in the host and no code from the operator, and everything the runtime's strongest claims depend on stays in the runtime, because the gate executes the call.

Each choice has its own reason. One run per call, because MCP passes no conversation identifier, a run spanning calls would have one step in flight at a time so that one pending approval would block every other call, and a rejection, which ends a run, would end everything after it. The cost is that the runtime's step, failure, loop, and time limits can say nothing about a conversation, and no policy can see what the model did before. Pending, then re-call, with a status tool, because a measurement with two small local models over 96 runs found that after a prose pending result the models stopped and reported, and told the request was approved they re-called with identical arguments in 48 of 48 runs, while a JSON-shaped pending result made them poll and ask the user to approve. Approval outside the host, because the model must not be handed the way to approve its own request. The duplicate rule, because a host that lost a response retries blindly, and for a payment that is a second payment; it refuses a deliberate repeat as well. Unknown outcomes wait for an operator, because a mutating call cut off by a crash or a timeout may have taken effect and is never run again on the policy alone. No policy language, because conditions on arguments and rules over history are what the Go library is for.

The scope is the part to read first: the proxy governs only calls that go through it. A host that gives the model a shell, file access, or tools of its own lets the model bypass the proxy and approve its own request with the operator command or by editing the database. The proxy is for one person who is both the host's user and the operator, and does not separate them. It never sees model calls or the user's prompt.

The alternatives were one run per named session, refused above; holding a call open until the operator decides, which the hold does for a few seconds only, because hosts time out tool calls; MCP elicitation, sampling, and input_required, refused because the host's user is who the model talks to and the point is a decision the model cannot reach; and a network daemon in front of several hosts, refused because it needs authentication, a listener, and a service's operation, for no gain to one person on one machine. The costs are no limits or policy over a conversation, no model accounting, results capped at 64 KiB and flattened, and approval ids shown to the model so it can ask about them. This is not a consumer's demonstrated need either, and it is an experiment on the question decision 7 left open, built as a nested module. Revisit when a host passes a stable conversation identifier, when a consumer needs conditions on arguments without writing Go, or when a second form, such as a different transport, is asked for. See MCP Proxy.

9. Record what a decision needs to be decided again, and chain the events

An audit log says what was decided. Nothing in it showed that the same policy would decide the same again, and nothing showed that the log itself was intact. Worse, a policy was handed different bytes on the loop and on resume: the loop gave it the agent's own bytes and the schema as registered, and a resume gave it the stored request, compacted and HTML-escaped. So even a policy that reads only its input could not be held to its record, because one request reached it in different forms. The policy sits outside the model (decision 3), but the record could show only that it ran. What an operator needs, after a policy changes or a run surprises them, is to put a finished run in front of a policy and see whether it would decide the same, and to know the record is the one the runtime wrote.

So four things were decided together. Each decision records what it can be checked against: the tool's spec by hash, stored once in tool_specs, the identity of the policy when it implements IdentifiedPolicy, and in the step.policy event a view of the run the record does not otherwise keep. The events form a hash chain, each hash covering the previous one and the event's own fields, across every run and not per run, so an event deleted from another run's record or a run deleted whole breaks it where a per-run chain would not. agentrt.Recheck rebuilds each recorded evaluation, asks a policy, and reports where it decides differently. A policy is handed one form of the request, PolicyJSON, on every path. Migration 6 adds the columns and backfills the chain.

What it does not show is part of the decision, and Re-checking a Run states each limit. The chain proves integrity only against a head kept where whoever can write the database cannot reach; without one it catches corruption and a careless edit. Events stored before migration 6 were hashed when it ran. A re-check proves the policy's answer, not the tool's behaviour, and trusts what it reads. A policy that reads outside state, a clock, a counter, a file, a service, cannot be re-checked exactly, and repo-steward's is one: that is a finding about the consumer, which the check exists to make. The proxy's rules applied before its policy are not re-checked. A step.policy event or step longer than a page is not re-checkable. The state tables are not chained.

The alternatives. Event sourcing, refused in decision 2 and again here: a re-check reads the record and reconstructs nothing, so the tables remain the state. Storing the raw bytes a policy saw instead of defining a form, which would preserve the inconsistency between paths, so a re-check would answer for one of the forms. A chain per run, simpler to verify and blind to a deleted run. Signing the events, deferred: a secret in the writer's process proves nothing against the writer, and a key kept out of its reach is a deployment the project does not have.

The costs. Every event write reads the head and hashes seven fields, in the transaction that already holds the write lock. The migration hashes every stored event in one transaction, so it takes time in proportion to the log. A process of v0.4.0 or earlier that writes after the migration breaks the chain at its first event, so every older process is stopped before the first open, and a v0.4.0 binary refuses a migrated database. A policy that matches bytes may notice the form. The surface to keep stable is larger. Revisit when a consumer needs events signed, which means a signer outside the writer's reach; when the state tables need the same assurance as the events; or when a policy that reads outside state needs a re-check that supplies what it read. See Re-checking a Run and Upgrading to v0.5.

Smaller decisions worth recording

  • Decisions are recorded before validation. The log must show what the agent asked for, not only what was accepted.
  • Denials are observations, aborts are terminal. A correctable request gets a second chance; an impossible run stops.
  • Loop detection keys on the observation. Repeating a call with a changed result is progress.
  • Ambiguous model attempts are charged. A lost response may have cost money; the conservative choice is to count it.
  • Token and cost limits use projections. Stopping before a limit is cheaper than discovering it afterwards.
  • Active and elapsed time are separate. One measures work, the other staleness.
  • Replay keys cover the entire request. Drift between what was measured and what runs is made impossible rather than detected.
  • One SQLite connection, plus one for the lease. Simplicity over concurrency; the consumer's inspection reads are cheap and rare. The heartbeat alone gets its own connection, because a renewal that waits behind a consumer's open rows is a lease lost to a live process.
  • The file is the operator's. Owner-only, refused when others can write it, refused when it carries a trigger or a view, because an approval's hash has no secret in it and whoever can write the file can forge one.
  • Fail closed at every boundary. JSON that would hash lossily, usage that cannot be believed, a schema that refers outside itself, a redirect, a key over plain http, a database on another schema version: each is refused with a named error rather than accepted with a stand-in.
  • Every commit is synced. A lost step.tool_started would let the next owner treat a tool that ran as one that never started; the cost, measured, is about a third more per step.