agentplane
A durable, replayable, policy-governed runtime for AI agents β in Rust. π¦
Not a prompt framework. Not an agent library. The layer beneath those β the thing that makes an agent's actions survivable, auditable, and governable when it is calling real systems that move real money.
// Performs its effects once, and journals everything.
let outcome = runtime.run.await?;
// Replay re-executes the logic and reads every effect back from the journal.
// No tool is called again. No clock is read again. No invoice is issued twice.
runtime.replay.await?;
π₯ The problem
Production agents fail in ways a better model does not fix:
- A 40-minute run dies at minute 38, and the retry re-issues every invoice.
- "Why did the agent refund β¬4,200?" has no answer, because the reasoning was prose in a log line.
- Untrusted tool output steers the next tool call.
- A prompt change ships with no way to know what it broke.
These are runtime problems. agentplane is a runtime.
π‘ The idea
The journal is the plan of record. Orchestration is deterministic and replayable. Everything non-deterministic β model inference, tool calls, the clock, randomness β is an effect: performed at most once, written to an append-only hash-chained log, and read back on replay.
Get that right and six things fall out of one mechanism: crash recovery, audit, cost accounting, regression testing, tamper evidence, and regulatory record-keeping. They stop being six subsystems that can each rot independently.
And critically: the audit trail is also the recovery mechanism, so it cannot quietly stop working β the system would stop working with it. Logging that exists only to satisfy an auditor always rots.
π Try it
# env-gated Amazon Bedrock Converse call
# a model choosing tools, and four refusals
# plan once, execute without the model β
# a prompt injection with no reader
# erase a case: every copy unreadable,
# and the chain still verifies
# Calls a model and replays without calling it again β no API key, no network.
# Digest-only multimodal dispatch and zero-I/O replay β also fully offline.
# An agent whose prompt, model, result shape and ceilings come from a file.
# A real MCP server in this process beside a typed Rust tool β one agent
# reaching both, and a strict replay that calls neither.
# Four agents, one plane: a coded editor that dictates the sequence, and a
# YAML desk that consults the same specialists as tool://agent/... grants.
# This plane served as an A2A 1.0 agent, called the way a peer would call it:
# a public card, authenticated methods, and a message that arrives untrusted.
# Live tokens for a human, one journaled completion for the machine β and a
# replay that performs neither.
# One customer's approved β¬500, spent across two separate runs, then revoked β
# with the terms still readable afterwards.
Or skip Rust entirely β a file and a key are the whole agent, and a file may
hold a whole room: several manifests separated by ---, the Kubernetes
packaging convention. Each document keeps its own digest β the file is
packaging, not identity β and a run starts at the room's declared orchestrator:
Or without a Rust toolchain at all β needing one to run a YAML file rather defeats the point of the file:
Distroless, nonroot, no shell. It runs --read-only --network none because the
default journal is genuinely in memory and the example's provider is the
deterministic fake β so the first run needs neither a disk nor the internet.
:slim (the default, and :latest) carries every model provider β Anthropic,
OpenAI, Gemini, Bedrock, any OpenAI-compatible server; :full adds MCP, the A2A
peer server, the operator HTTP API, Cedar, key rings, governed media and
Postgres. Both are multi-arch, cosign-signed keylessly, and carry SLSA build
provenance and an SBOM attached to the digest:
durable_pipeline prints the whole claim in four steps: a live run, a strict
replay that touches nothing, a crash that resumes without repeating work, and a
changed build that is quarantined instead of quietly rewriting history.
A first program is one import and about forty lines:
use *;
And when it goes wrong, the plane answers with what it does have rather than
with a variant name β fn main() reports through Debug, so on the errors you
hold, Debug is the message:
Error: no skill provides capability 'demo.greeet' β this plane provides:
demo.greet. `run` takes a capability, not a skill name; a skill declares its own
with `SkillDescriptor::new(..).provides(..)`
A declarative tool loop runs from a file too β the manifest grants
tool://tickets/read, and which transport reaches tickets is deployment
wiring rather than part of the reviewed declaration:
A grant naming a server nobody wired is refused at build, not on every run.
Needs --features cli,mcp-stdio, or the :full image.
And an agent can be hosted from the same file β the A2A 1.0 server that passes the protocol project's own conformance kit, started without writing Rust:
Add --operator-addr 127.0.0.1:9090 and the worklist, task decisions and
GET /runs?outcome=quarantined are served too β on their own listener, off
unless asked for, and separated from the peer surface by policy (peer reaches
a2a:*, operator reaches api:*) rather than by the port. A served plane also
sweeps deadlines, task expiry, dead letters and due timers, so a run that sleeps
or waits actually wakes up.
Both --policy and --tokens are required and have no defaults. That is the
design rather than an inconvenience: a permissive engine and no engine are the
same behaviour, and a server that authenticates nobody has no actor to record a
decision against. Needs --features cli,a2a-server,cedar, or the :full image.
New here? β docs/getting-started.md
π¦ What you get
| π§Ύ | A journal you can audit β append-only, hash-chained, per-record signatures naming the workload that wrote them, and a per-plane Merkle log so deleting a whole run is detectable |
| π€ | A record you can take away β agentplane export writes framed JSON Lines an auditor reads with no Rust toolchain; audit --key --prior checks authorship and deletion against a store it did not write; verify re-walks a copy offline, from the file alone; restore rebuilds a store and proves it by one comparison β equal Merkle roots at equal size. A truncated export is told from a whole one by its missing trailer, and runs that could not be read are named, not counted |
| β±οΈ | Durable execution β crash mid-run and resume from the last completed effect; a suspended run costs a row on disk, not a task |
| ποΈ | Cases, not long-lived workflows β runs stay minutes, business processes span months, so a deploy never has to migrate an in-flight workflow |
| π« | There is no AllowAll β the default is DenyAll, and a permissive engine and no engine are the same behaviour, so having two ways to spell it is how a plane ends up with a policy layer everyone believes is on. The same reason there is no Egress::allow_all(). Note the direction: a catch-all permit left in a policy set makes every later rule redundant, because Cedar allows on any matching permit β a baseline is something to remove deliberately, not to inherit |
| π‘οΈ | Policy before live dispatch β a total, I/O-free gate; denials are journaled, strict replay never re-judges history, and plan authority is checked before step 1 |
| π·οΈ | Field-level information flow β exact outbound arguments are bound to hierarchical provenance; recipient, amount, path, URL and other authority-bearing fields can require trusted or named sources while ordinary content remains untrusted |
| π | Typed, authorized release β improving trust or sensitivity names the exact fields, destination, basis and evidence, asks policy under data:release, retains provenance, and leaves a permanent decision record |
| πΈ | Budgets that bind β a failed model call is billed for what it burned, because the provider bills for it too |
| π§Ύ | A ceiling that outlives the run β a budget bounds one run and a quota bounds a billing period; neither can say this customer approved β¬500, across however many runs it takes, until they take it back. A standing authority is that, and it is an authorization rather than a throttle: revoked and exhausted are different answers, because one may be followed by asking for less and against the other that is a loop. Drawing is a journaled effect deduplicated on the dispatch identifier β the effect key deliberately changes per attempt, so keying on it would spend a customer's authorization twice for one purchase. And there is no refund: Spend is unsigned, so no amount can un-spend a ceiling |
| 𧬠| Effects that take together, or not at all β a group declares the resources it touches and refuses any member outside them. Each reversible member records the concrete call that undoes it, built from what that call actually returned rather than reconstructed later from state that has moved β the gap a per-step saga leaves, since compensate is handed the output of a step that failed and therefore has none. commit is the frontier: invariants are checked there because it is the last instant at which failing them is free, and only then are deferred members released. That is what makes an irreversible send safe β an aborted group never sends it, which beats sending and apologising. Doubt reverses nothing |
| π§± | A member that commits with the journal β when the resource shares the journal's database, its write and the record that it happened go in one transaction. No reversal to fail, no in-doubt window to survive, and an abort is a rollback. It is the one place this design can do better than a saga, and it says so rather than letting the word transactional imply it everywhere else |
| π€ | Human oversight on the call, not a summary of it β requires_approval: true on a tool grant opens a task carrying the exact tool and arguments about to be dispatched, and nothing happens until somebody approves. Gating the agent's answer instead is a review that arrives after the money moved. Durable worklists, four-eyes, declared expiry behaviour, and an operator who can stop a run and have it unwind |
| π | An emergency stop that a restart does not forget β halt a tenant and every instance refuses new work, because the flag is in the store rather than in one process. It is its own refusal, not a ceiling: a ceiling means not right now and invites the retry a halt exists to stop. New work only β runs already in flight are stopped by cancelling them, which unwinds what they did |
| π°οΈ | The sweeper is audited too β breaching an obligation and escalating a case happen on a clock, with no run to explain them, so a tick that decides anything writes its decisions into a sealed run of its own. State cannot tell the sweep breached this at 02:00 from somebody set it, and no human was there to remember |
| π¦ | A finding you can find β every conclusion the runtime reaches is queryable by whoever must clear it, including its two worst: GET /runs?outcome=quarantined and GET /cases?status=escalated. Both are newest-first and say when the page overflowed. A status returned to a caller that already returned, an event on a stream nobody reads, and a counter with no alert are all detection without delivery β the failure production studies of agent runtimes report most. Grant api:run.list and api:case.list: they are the two read verbs an on-call person needs, and the two an allowlist built from route names alone will miss |
| π‘οΈ | Your guardrail, not ours β this crate ships no content classifier, for the same reason it ships no policy evaluator: a deployment that needs one already has a better one, administered where its compliance people can see it. Bedrock::guardrail(..) passes the deployment's own through and owns everything around it β the guardrail's id and version are effect identity, so disabling it between a run and its replay is divergence rather than a silent change; an intervention is a metered refusal rather than an answer, because Bedrock returns 200 with whatever survived redaction; streaming assesses before releasing; and both request paths carry it, since a control on one path is one a streaming deployment loses |
| π | Model drivers included β OpenAI Responses, Anthropic Messages, Google Gemini generateContent, a separately gated AWS Bedrock Converse driver, and a chat-completions driver for the OpenAI-compatible wire every self-hosted server speaks (TGI, vLLM, Ollama, llama.cpp β and with it the Hugging Face catalogue, local or hosted), so adopting this does not mean keeping your provider plumbing. Streaming, native structured output, reasoning continuation across tool turns and cached-token accounting are in each one; OpenAI provider retention is explicitly off by default and replay-visible when enabled. What makes a driver work is not the transport but the failure mapping: every error is reduced to whether the call landed, and guessing that wrong is how a payment happens twice |
| π | Real context, tool and peer wires β a genuine MCP host for exact-granted prompts, resources, tools and async Tasks, several servers beside typed in-process tools, and an A2A 1.0 JSON-RPC client/server with typed remote-task handles and journaled polling. Each maps wire outcomes conservatively into recovery; elicitation is not advertised because no server may open an ungoverned human loop inside an effect |
| πͺͺ | Agent Cards derived, not written β an A2A v1.0 card built from the manifest, so what an agent advertises and what it is permitted cannot drift. Its skills are exactly the declared capabilities, and unimplemented transports are advertised as absent rather than aspirational |
| π‘ | A2A 1.0 task lifecycles on both sides β blocking/non-blocking tasks, direct normative GetTask, journal-backed status/artifact streaming, cursor-paginated ListTasks, context-based new tasks, exact taskId continuation of INPUT_REQUIRED, cancellation, durable push and extended cards. Continuation atomically targets the named wait, carries the authenticated peer as provenance, reconstructs every turn in history, and deduplicates transport retries. Outbound PeerTask/PeerTaskCall preserve handles and journal each poll |
| π | An instruction is not data that reads like one β /system is a protected field, so the order a model reasons under must be trusted while the content it reasons about may stay untrusted. Every other control bounds what a model may do; this one is the only one that asks who was allowed to give the order, and it is why a prompt is built with Tainted::object rather than mapped from one value |
| π§ | Memory that cannot promote itself β explicit writes and digest-covered declarative formation derive labels from source/model output. Fixed expiry and opt-in sliding access retention use separate journaled effects; legal hold blocks erasure. Semantic ranking is a sensitivity-bounded journaled retriever, never memory truth. EncryptedMemoryStore seals per item under a tenant/subject wrapping scope so subject erasure makes backup ciphertext unreadable; its built adapter is deliberately single-node until KMS destruction can share an active-active coordinator |
| π | A memory subject is an erasure unit, so it names the party β forget_subject is what an erasure request actually names, so a literal memory_formation.subject pools every customer under one key: one party's facts recalled into another's run, and erasing one destroys everybody's. A subject may be bound β $correlation/malo, $case, $input/<pointer> β and resolved per run from keys correlation settled at admission. An unrecognised $ value is refused rather than filed as a constant, since a typo would file every party under the typo; a binding that cannot resolve fails the run rather than guessing; and $input is refused unless the field is trusted, because a subject taken from untrusted input is whoever supplied it choosing whose memories this run writes into. The keys come from the run's own journal record, not from a case that has since accumulated more |
| π | Live model output without a second truth β ModelCall::streaming_to emits labelled visible text and usage during live provider consumption. Opaque reasoning never leaks, strict replay emits nothing live, and one terminal Completion remains the only journaled outcome |
| π§© | Reasoning that survives tools without provider state β OpenAI Responses output items, including encrypted reasoning and assistant phase, and Anthropic thinking/signature blocks are carried as opaque journaled continuation state into the next tool turn. The next effect key commits to them; no previous_response_id or expiring provider conversation is replay truth |
| π | At-least-once A2A push without a second truth β the task journal is the outbox and each redb/PostgreSQL registration stores its next unacknowledged sequence. An explicitly scheduled worker sends the same status/artifact union as streaming, advances only after HTTP 2xx, persists bounded retry state, and accepts duplicates rather than losing events after a crash. PushSender permits only operator-granted HTTPS hosts after all-address checks and DNS pinning; the card advertises push only when the durable store and transport are wired |
| π€ | The journal is the outbox for your events too β the same cursor loop, with the payload left to you: an operator-configured Outbox registers each destination at admission, so no run exists unwatched, and a Projection turns records into whatever your bus consumes. No outbox table to fall out of sync with the history. The three URL controls are lifted for an operator destination and only for one β there is no caller to check, an in-cluster collector on plaintext HTTP is ordinary, and resolving inward is the point β while the cursor discipline, retry ceiling and abandon-and-report are unchanged. A caller cannot register into that namespace |
| π§ | Discovery that grants nothing β a peer's card is fetched under an egress allowlist, verified against keys you trust, and used to pick an interface by binding and version. What it never does is confer authority: a party describing its own privileges is not a source of truth about them, so peer grants stay in the operator's registry and a forged card can waste a request but not widen one |
| βοΈ | Agent Cards you can verify β a detached JWS over the card, canonicalized per RFC 8785, so a peer checks who published it rather than only which host served it β and keeps checking after the card is copied into a registry. A real JWS over the standard signing input, not over its hash; the algorithm is read from a constant, never from the card being checked |
| π» | Streaming that survives a dropped connection β SendStreamingMessage and non-terminal SubscribeToTask are served from the journal, not an in-process channel. Status and output-artifact events are ordered from durable history, any instance can serve them, and reconnecting clients first read the current task snapshot |
| πΌοΈ | Governed remote media β provider-side URLs are refused; the media feature fetches through exact host/port grants, all-answer public-IP checks, pinned DNS, manual redirects, byte/time/type/signature bounds, versioned content validators, digest-only journaling and explicit retention. Bytes materialize only inside live model dispatch, never strict replay |
| ποΈ | Erasure that keeps the proof β drop a payload's bytes and the chain still verifies, because it only ever committed to a digest. A later read says expired, on this date, for this reason β never missing |
| π | Erasure that reaches the backups β deleting clears the live store; the backup taken an hour earlier still has everything, and backups are offsite and often immutable by design. So payload bytes are sealed under a per-case data key wrapped by a key the crate never holds: erasing a case destroys the key, and every copy becomes unreadable at once β including the ones nobody can reach. Rotation re-wraps without rewriting bulk data; an erased case never comes back. VaultTransit speaks Vault's transit engine, so the wrapping key never leaves Vault. And the journal too: SealedJournal::wrap(store, keys, tenant) seals run input, effect arguments β prompts and tool calls β and effect outputs under the same per-case scope, so one erasure reaches blobs and journal alike. Only the payload is sealed, so exactly-once and every index keep working with no key; and the chain commits to the ciphertext, so an auditor holding no keys still verifies the history of a run whose data is gone β erasure and keys |
| π | An agent that is only a file β agentplane run agent.yaml. No Rust, no main, no skill. The digest covers the agent in its entirety rather than only its boundary, and the run is journaled and deterministically replayable. Declarative formats give you the first; durable platforms give you the second; the pairing is what makes the evidence about something you can actually read |
| π§ββοΈ | Oversight in the file, not in the code β oversight.approval: required makes a declarative agent wait for a person, showing them its actual answer. Declared where nothing would apply it, it is refused β a file must not claim a human is in the loop when none is |
| π | A task beside the answer, not in front of it β a tool-calling agent granting no mutating tool structurally cannot act, and for those the other two modes are both wrong: tools-only gates nothing, required is a worklist that blocks. oversight.triage returns the answer and opens a task when it matches a predicate over the declared output.schema, for a named audience. It may hold a condition where approval may not, and the distinction is load-bearing: a triage rule changes nothing the run does β same answer, same memories β so it is reporting rather than control flow. Every condition is typed against the schema and refused where that schema provably cannot produce the field, because a rule that can never fire reads exactly like one that does |
| π | A tool reference names the tool, not the wire β a grant is tool://server/name, and which transport reaches that server is a deployment decision. So one manifest runs against an in-process double in a test and a real MCP server in production, and the reviewed file never claims a supply-chain fact it cannot know. The server component is load-bearing: ToolRouter gives each server its own client, so two servers that both offer read stay two tools β and a plane can use typed in-process tools and MCP at once, which one client handed every id could not represent |
| π§ | An embedding is an observation β StepCtx::embed journals the query vector and the model revision that produced it. An embedding service does not promise the same floats twice, and the vector is inside the retrieval effect's identity, so a skill computing its own would quarantine a healthy run on the next replay for a reason nothing on the record explains |
| π§° | A tool is one type β arguments are fields, the schema comes from the fields, and call takes self so the body receives the declared shape or the call was refused. Model-steering prose stays in the digest-covered manifest, where changing it becomes a reviewed version change. No Value to index, no field to misspell, no dispatch on a name. And .toolbox(..) derives the catalogue from the agent's own declaration and refuses to build where the code and the reviewed manifest have drifted either way |
| π οΈ | Tool calling where the model proposes and the operator decides β execution.kind: tool-calling runs the loop from a file. The model is offered exactly the manifest's grants, and the name it picks is matched byte for byte β a resolver that corrects a near miss lets a model reach a tool by describing it. A name matching nothing comes back as a failed call, so the model can correct itself and never gets the tool it nearly named. Arguments stay untrusted, so protected fields and the egress ceiling apply. max_turns bounds it, and an agent still asking when it runs out fails rather than passing off half-formed reasoning as an answer. Every turn is a journaled effect, so a replay reassembles the conversation without calling a model or a tool |
| πΊοΈ | Plan-then-execute, from a file β execution.kind: planned is the dual-LLM pattern completed and journaled: one privileged call over trusted input fixes the control flow before anything hostile is read, then the runtime executes the plan itself, routing step outputs between steps as labelled references ($step0/email) that no model reads. A protected field like recipient must be trusted is satisfiable by a reference to trusted input and refused for a planner literal β provenance the tool-calling loop structurally cannot carry, because an argument a model retypes always wears the completion's label. parse steps run hostile text through the quarantined model under a bounded schema and a runtime-injected not-enough-information escape that fails the step rather than letting a guess stand. And unlike CaMeL's own research interpreter, every step is a journaled effect: strict replay reassembles the whole plan and dispatches nothing |
| π | The declaration binds β an effect naming a model or tool the file never listed is refused before dispatch and journaled, under an action distinct from a policy denial. A config field read by convention is two copies of one decision; this is one |
| π | Grants and prompts declared in a file, not a builder call β a manifest states an agent's instructions, tools and ceilings where a reviewer sees them as a diff, and refuses a field it does not recognise, because max_tokns: in a permissive parser means no token ceiling at all. The prompt is inside the digest, so rewording it is a version bump rather than an untracked deploy |
| π§ͺ | Agents you can actually test β testkit ships a deterministic FakeProvider (assert the assembled prompt, the exact tool surface offered, and what the next turn was told about a refusal), seeded fault injection including the CommittedThenLost case that journal truncation cannot reach, a store conformance battery for your own backend, and assert_replay_was_not_backstopped β which proves replay replayed rather than being rescued by the store's unique index one layer down |
| πΈοΈ | Multi-agent shape is declared, not emergent β single or collaborative, with roles specialist or orchestrator. A specialist may not delegate, even when a duplicate numeric ceiling is omitted; collaboration must state why. Routing one trigger to one agent is ordinary deployment dispatch and was removed from the manifest because accepting YAML that the runtime never executes manufactures confidence |
| π | A model swap is a version bump β provider and model id are in the digest. The privileged and quarantined roles are refused when they name the same model: two roles behind one model keeps the label and removes the control it stands for. There is no declarative fallback role either β fallback changes behaviour and must be explicit orchestration, not accepted YAML nothing executes |
| βοΈ | Signed manifests, bound to their purpose β a digest says what was published; a signature says who. Every signature this crate defines β manifest, record attestation, provenance seal β is made over a hash carrying its own domain label, so one can never be replayed as another |
| π | A registry that will not rewrite history or authorship β a published version is immutable; an unsigned artifact can adopt its first publisher attestation, but that identity cannot be silently reassigned. A resolve can be pinned to a digest, which is the check that still holds when the registry is the compromised party |
| ποΈ | Witnessing by somebody who is not you β a checkpoint is cosigned only if it provably extends the last one seen, so a shrunken log and a second history of the same size are both rejected. HttpWitness speaks C2SP tlog-witness, so the counterparty can be the existing public network rather than a second process you also own. A 409 is a stale cursor, never a fork: one is a routine retry, the other an integrity incident, and conflating them is how the alert that matters stops being believed. And the quorum is a declaration, not a hope: WitnessQuorum::of(n) is enforced per submission round, a shortfall is a finding rather than a log line, and a fork report survives a met quorum β two honest cosigners don't outvote the witness that remembers a different history |
| π | Break-glass that writes to the tenant it crossed β reaching another tenant's data is the one designed exception to isolation, so the crossing is sealed into that tenant's journal β actor, roles, mandatory reason β before any data is served, and a failure to record is a failure to access. It enters their chain, signature and Merkle log, and lists under GET /runs?outcome=broke-glass. An exception without a record is indistinguishable from the breach it is meant to be |
| π’ | Multi-tenancy in the key, not in a filter β the tenant leads every stored key on both backends, so a query that forgets it returns nothing rather than another tenant's rows. Blob paths lead with it too: content addressing otherwise puts two tenants' identical bytes in one object, and erasing it for one destroys the other's data while reporting both requests done. One process serves many tenants, resolving the plane from the caller's credential β never from the request β and refusing a tenant it does not serve rather than falling back to a default |
| π | Per-tenant metrics without leaking tenants β the label is opt-in, off by default, and bounded by configuration rather than data: it is the plane's own tenant, so no request can grow the cardinality. There is no pseudonymous mode, deliberately: the tenant already appears in store keys, blob paths and a publicly served Agent Card, so hashing it in one place would invite the belief that it is contained |
| π¦ | Ceilings that survive scaling out β a budget bounds one run; a tenant that can start runs can start a thousand. Per-tenant limits on concurrent runs and spend are accounted in the store, because an in-process counter fails open: it silently doubles the moment a second instance starts, which is exactly when it was needed. Refusals are back-pressure, distinct from a policy denial β one means not right now, the other never |
| π°οΈ | One plane, several agents β a runtime owns the journal, the drivers and the process identity; an agent owns a manifest and its skills. Each agent on a plane is separately declared, bounded and answerable, and two of them claiming one capability is refused at startup rather than silently resolved β as a panic naming the mistake when a binary wired itself, or a typed BuildError from try_build when the manifest arrived from a registry or a tenant, where a bad declaration is an input rather than a bug and a panic would take every other tenant's in-flight run down with it. StepCtx::commission hands work to a peer as a journaled effect, so a replay reassembles the room without waking it, the label travels with the answer, and the specialist's spend is billed to the run that asked |
What is deliberately not built, and what will move β docs/status.md
π Documentation
| π | Getting started β first run, first skill, first replay |
| π§ | Concepts β the ideas the rest is built from |
| ποΈ | Architecture β how it actually works, mechanism by mechanism |
| π³ | Cookbook β task-shaped recipes, including wiring an MCP server beside typed tools |
| π | Manifest reference β every field, what enforces it, and what an absent value means |
| π§ͺ | Testing agents β the fake provider, fault injection, and proving a replay actually replayed |
| π | Security model β the trust boundary, and what it does not cover |
| ποΈ | Erasure and keys β erasure that reaches backups, key rotation and revocation, and how tenants are kept apart |
| βοΈ | Operations β deploying, HA, retention, observability |
| βοΈ | Regulation β EU AI Act obligation by obligation, and what is missing |
| π | Status β what is pre-alpha, what to pin, what is deliberately absent |
| β¬οΈ | Upgrading β what breaks between pre-alpha releases, and the shortest correct fix |
| π | Changelog β what changed and when, including every mechanism's reasoning as it landed |
| π€ | Contributing β the assurance ladder, and how to run it |
π§ͺ Assurance
Each layer answers a question the others structurally cannot.
Two are unusual enough to name:
π¬ Formal specs. TLA+ specifications are model-checked on every push β the effect protocol, retry safety, sagas, fencing, authorization, delegation. And because a spec whose invariants cannot be violated proves nothing, each is re-checked against deliberately broken copies of itself; every mutant must be caught by the specific invariant written for it.
π§Ύ Conformance by the protocol's own kit. just test-a2a-tck runs the
official a2a-tck against this crate's
A2A server on a live socket. Every other A2A test drives this server with this
crate's own client, which proves symmetry, not conformance β a client and
server written from the same misreading agree everywhere. The kit's first run
found five defects no in-repo test could reach.
π Tests against a real provider. just test-live runs the OpenAI and
Gemini drivers against the actual APIs. They are gated twice β an explicit AGENTPLANE_LIVE=1
and a key β because a credential being available is not a decision to spend
money with it, and they are never part of ci. They exist because a stubbed
provider is structurally unable to have the defects a real one finds: it never
rejects a malformed request and never returns a shape the driver mis-reads.
Writing them found two, both of which every offline test had passed. The Gemini
battery is the sharpest case: a thought signature is minted and validated by
Google, so a canned server accepts whatever a fixture tells it to and says
nothing about whether Gemini takes the signature back β the one check that
distinguishes a driver carrying the model's turn verbatim from one rebuilding
it, which is where the rest of the ecosystem has been losing this.
𧬠Mutation testing over the code. Every load-bearing guarantee is broken on purpose, and the test named for each one must fail. A mutation caught by some other test is reported weak, not passing β that usually means the guarantee has no test of its own and is being held up by one that could be rewritten without anyone noticing what it protected.
This is not decoration. The project shipped an unfalsifiable guarantee once: the refusal to replan on untrusted data was implemented, tested, and green β and deleting it would have failed no test, because the fixtures laundered the taint before it reached the check. It was found by accident. The sweep is so the next one is not.
It runs on every push, sharded six ways. It was gated to pull requests, on
the reasoning that a push to main had already passed it β true of a repository
that merges, and this one has never opened a pull request, so the gate switched
the sweep off rather than making it cheaper. Three mutations then rotted into
code that no longer compiled, leaving three guarantees unfalsifiable with
every check green. just anchors reported all three present, correctly: it
checks that a mutation still matches the code it names, which is text and
not types.
MUTANTS_SHARD=k/n takes a round-robin slice β round-robin because the table
groups mutations by subject, so a contiguous split would hand one shard every
expensive target. Each line carries [142/379], and each shard needs its own
checkout: the sweep rewrites source in place.
π« Non-goals
| agentplane does not | Use instead |
|---|---|
| Ship a prompt library or IDE | Your prompts; agentplane pins the manifest that governs them by digest |
| Route or proxy model traffic | LiteLLM, Bifrost β the drivers themselves ship: OpenAI Responses, Anthropic Messages and Bedrock Converse are here, with streaming, structured output, reasoning continuation and per-provider failure mappings. What is out of scope is choosing between them at runtime |
| Implement a vector database | LanceDB / pgvector behind the SemanticRetriever seam; embedding is a journaled effect so the query vector is history rather than a recomputation |
| Ship a built-in tool catalogue | Write a typed Tool, or wire an MCP server. The tools other frameworks ship β web search, code interpreter β are mostly provider-hosted: they run during generation, so the call is not announced, authorized, metered or replayable. That is a world-visible action outside the journal, which is the one thing this runtime is for. Governed URL fetching is the media feature; untrusted code belongs behind a process boundary |
| Replace a deterministic protocol engine | Keep it; agentplane sits beside it, never inside it |
| Require Kubernetes | One static binary |
| Train, fine-tune, or serve models | Permanently out of scope |
| Grade output quality | It emits replayable traces; grade them elsewhere |
| Interpret payload contents | Payloads are opaque, and labeled |
| Claim regulatory compliance | It provides technical means; compliance is the deployer's |
Who should not use this: a team running three agents against low-stakes data. The complexity is justified when agents touch money, meters, or regulated records.
π Status
Pre-alpha, pre-release, no API stability. Breaking changes land without deprecation. The journal record format and the storage schema will change.
Rust 1.94.1+. #![forbid(unsafe_code)]. One crate, feature-gated: an embedded
redb store by default β pure Rust, two crates
deep, no C toolchain β with everything else opt-in.
Honest framing on regulation: agentplane is not "compliant" and cannot be. Compliance attaches to a system in a context, assessed by its provider or deployer. What this gives you is the technical means to discharge EU AI Act Articles 12 and 14 β means that are already load-bearing for recovery and testing, and therefore cannot quietly rot. Regulation maps obligation to mechanism, names what is not built, and notes that the Digital Omnibus moved the high-risk dates to December 2027 without amending the articles.
π License
MIT OR Apache-2.0, at your option.