Can Rust Make Unsafe AI Agent Actions Unrepresentable?

I went looking for a better runtime check and found a different way to think about the problem.

I have spent a lot of time recently thinking about what an AI agent should be allowed to do.

Reading data is one thing. Writing durable state is another. Sending an email, approving a refund, changing a production configuration, or deleting a record moves farther along the same spectrum.

The usual answer is to put a guardrail in front of the dangerous operation.

Flowchart of a conventional runtime guardrail. An agent proposes an action, which flows through validate action, then check policy, then an allow-or-deny decision. Allow leads to execute; deny leads to stop. The dangerous execute step sits at the end of a chain of checks that every caller must remember to run.

That makes sense, and I have built examples that work exactly this way.

Then I started looking at how I might implement the same kind of boundary in Rust.

Rust asked a more interesting question:

Why does the dangerous function accept an unchecked action in the first place?

Runtime Checks Are Still Checks

The complete runnable example is on GitHub if you want to make the compiler angry yourself.

Consider a simplified AI agent that can write information into durable memory.

The agent proposes a write:

struct ProposedWrite {
    key: String,
    value: String,
    authority: String,
}

Somewhere else in the application we have a function that persists it:

fn persist(write: ProposedWrite) {
    // write to durable storage
}

Obviously we should validate the write first.

fn validate(write: &ProposedWrite) -> bool {
    // validate provenance
    // evaluate authority
    // apply policy
    true
}

Then our application does this:

if validate(&write) {
    persist(write);
}

Perfectly reasonable.

It also means persist() will happily accept a ProposedWrite that has never been validated. We are relying on every caller to remember the protocol.

That is not necessarily a problem in a small example. In a large agentic system with multiple tools, services, developers, and execution paths, it becomes a much more interesting assumption.

What happens when somebody adds this six months later?

persist(write);

The compiler sees nothing wrong. The type is correct. The application is wrong.

Make the State Part of the Type

Rust gives us another option. Instead of treating “proposed” and “approved” as metadata attached to the same object, we can make them different types.

struct ProposedWrite {
    key: String,
    value: String,
    authority: String,
}

struct AdmittedWrite {
    key: String,
    value: String,
    authority: Authority,
}

Now persistence accepts only the second one:

fn persist(write: AdmittedWrite) {
    // write to durable storage
}

This seemingly small change alters the boundary. An agent can produce a ProposedWrite. It cannot produce an AdmittedWrite directly, provided we control how that type is constructed. Something trusted has to perform the transition.

Flowchart of the custody transition. A ProposedWrite enters an evaluate decision point. If the authority is valid, it becomes an AdmittedWrite, shown in the trusted deep-green state. If the authority is invalid, it becomes Rejected, shown in red. Evaluate is the only path from proposed to admitted.

The persistence layer no longer asks:

Has somebody remembered to validate this?

Its API says:

Give me something that has already crossed the admission boundary.

That is a much stronger contract.

This has a name in Rust circles: the typestate pattern, encoding a value’s state in its type so that only valid transitions type-check. It is the type-level cousin of a principle Alexis King named “parse, don’t validate“. Instead of checking a value and handing the same type onward, hoping every later caller re-checks, you transform it into a new type whose very existence proves the check already happened. The check is not something you remember to run. It is something the type system will not let you skip.

The Compiler Becomes Part of the Boundary

The phrase “provided we control how that type is constructed” is doing all the work in the previous section, so let us actually deliver that control. This is where the earlier version of this article was too loose, and where Rust rewards precision.

mod custody {
    use super::ProposedWrite;
    // Authority, Rejection, and validate_authority are defined in this module.

    pub struct AdmittedWrite {
        key: String,
        value: String,
        authority: Authority,
    }

    impl AdmittedWrite {
        pub fn key(&self) -> &str {
            &self.key
        }
        pub fn value(&self) -> &str {
            &self.value
        }
        pub fn authority(&self) -> &Authority {
            &self.authority
        }
    }

    pub fn evaluate(write: ProposedWrite) -> Result<AdmittedWrite, Rejection> {
        let authority = validate_authority(&write.authority)?;

        Ok(AdmittedWrite {
            key: write.key,
            value: write.value,
            authority,
        })
    }
}

The important detail is what is not marked pub. The struct is public, so other modules can name the type and accept it in their signatures. Its fields are private.

That distinction is the whole boundary. In Rust, a struct literal like custody::AdmittedWrite { key, value, authority } requires every field to be visible at the construction site. Because the fields are private to the custody module, no code outside that module can write that literal. And there is no other public constructor. The only way to obtain an AdmittedWrite from outside is to hand a ProposedWrite to evaluate and have it succeed.

So the persistence layer, which lives outside custody and reads the data through the accessor methods, cannot be handed a value that skipped evaluation. Not because a reviewer will catch it, but because the code that would skip evaluation does not compile.

Here is the shortcut a tired developer might reach for six months from now:

let proposed = ProposedWrite {
    key: "refund_policy".into(),
    value: "Refunds under $100 do not require manager approval.".into(),
    authority: "policy".into(),
};

persist(proposed);

And here is what the compiler says about it:

~\Rust_AI_Actions is 📦 v0.1.0 via 🦀 v1.98.1
❯ cargo build
   Compiling unrepresentable v0.1.0 (~\Rust_AI_Actions)
error[E0308]: mismatched types
   --> src\main.rs:128:13
    |
128 |     persist(proposed);
    |     ------- ^^^^^^^^ expected `AdmittedWrite`, found `ProposedWrite`
    |     |
    |     arguments to this function are incorrect
    |
note: function defined here
   --> src\main.rs:95:4
    |
 95 | fn persist(write: AdmittedWrite) {
    |    ^^^^^^^ --------------------

For more information about this error, try `rustc --explain E0308`.
error: could not compile `unrepresentable` (bin "unrepresentable") due to 1 previous error

The mistake never reaches review, staging, or production. It stops at the one place a mistake is cheapest to fix, on the machine of the person who made it, the moment they made it.

The state machine is no longer sitting in a comment:

// IMPORTANT: call validate() before persist()

It is represented by the program.

One honest caveat for larger teams: private fields close the door from outside the module, but code inside custody can still build the struct with a literal. If you want to forbid even that, give AdmittedWrite a private field of a private zero-sized type, a construction token that only evaluate can mint. Then the blessed function is the single point of construction anywhere, inside the module or out. Whether that is worth the ceremony depends on how much you trust the inside of your own boundary.

This Doesn’t Make the Agent Safe

This is where I need to resist making the argument bigger than it is.

Rust does not know whether the policy is good. It does not know whether Authority::SecurityTeam actually represents the security team. It does not know whether the provenance supplied to the custody boundary is genuine. And it certainly does not solve prompt injection because I changed a struct.

If this function is wrong:

fn validate_authority(value: &str) -> Result<Authority, Rejection>

then Rust will very efficiently enforce the wrong rule.

Types can constrain which states the program represents. They cannot determine whether our model of the world is correct. That distinction matters.

Witnessed Is Another State

The exercise gets more interesting when provenance enters the picture.

Suppose the agent says:

{
  "key": "refund_policy",
  "value": "Refunds under $100 do not require manager approval.",
  "authority": "policy",
  "source": "internal_policy"
}

Should the agent be allowed to decide that its own source is an internal policy? Probably not. The system that retrieved the source is in a much better position to make that claim.

So perhaps our states are not merely:

Proposed → Admitted

They are closer to:

Flowchart of a write's four states, with color deepening as trust increases. Proposed, containing only what the agent claims, leads to Witnessed, which adds evidence established outside the agent, then to Evaluated, which has been checked against policy, and finally to Admitted, which is permitted to become durable state. Each state is a distinct type, and each step accepts only the output of the step before it.

A ProposedWrite contains what the agent claims. A WitnessedWrite adds evidence established outside the agent. An AdmittedWrite represents a write that has been evaluated against policy.

struct ProposedWrite {
    key: String,
    value: String,
    claimed_authority: String,
}

struct WitnessedWrite {
    proposal: ProposedWrite,
    source: WitnessedSource,
}

struct AdmittedWrite {
    key: String,
    value: String,
    authority: Authority,
    source: WitnessedSource,
}

Now different parts of the system accept different states:

fn witness(write: ProposedWrite) -> Result<WitnessedWrite, WitnessError> {
    // establish source evidence outside the agent
}

fn evaluate(write: WitnessedWrite) -> Result<AdmittedWrite, Rejection> {
    // apply policy to the witnessed write
}

fn persist(write: AdmittedWrite) {
    // write to durable storage
}

Each step accepts only the output type of the step before it. There is no signature anywhere that accepts a ProposedWrite and persists it, so the shortcut is not something you have to remember not to take. It is not expressible. The types are the protocol; nothing else has to announce it.

What Rust Changed for Me

I started this experiment thinking about how to implement an AI safety boundary in another language. The more interesting lesson was that Rust made me reconsider where the boundary should live.

In many systems, we encode state like this:

{
  "status": "approved"
}

Then every downstream consumer has to inspect status and behave correctly.

Rust encourages another question:

If these states permit fundamentally different operations, why are they represented by the same type?

That question matters for AI agents because agentic systems cross consequential boundaries constantly.

A model proposes a tool call. A runtime authorizes it. A tool executes it. A result becomes memory. Memory later becomes context. Context influences another action.

At each transition, we can either carry another flag saying what happened, or change what the next component is capable of accepting. Those are not equivalent designs.

Invalid States Versus Invalid Reality

There is an important limit here.

Suppose a write was legitimately admitted yesterday because Alice had authority to approve it. Alice leaves the company today. The AdmittedWrite type does not magically expire. Likewise, a policy can be superseded, a credential revoked, or evidence later discovered to be wrong.

The compiler can enforce:

This value crossed the required transition.

It cannot establish:

Everything that justified that transition remains true forever.

That still requires runtime governance, lifecycle management, revocation, and revalidation.

So I would not claim Rust makes unsafe AI actions impossible. What it can do is make certain classes of architecturally invalid transitions harder to express accidentally.

That is narrower. It is also much more believable.

Could I Do This in Another Language?

Of course.

You can model state transitions in Go, Java, TypeScript, Python, C#, or plenty of other languages. You can build wrapper types, sealed classes, discriminated unions, private constructors, capability objects, and carefully designed APIs. Rust does not own the idea, and the typestate pattern predates it.

What I found useful is that Rust keeps pushing the design conversation in this direction. Ownership asks who controls a value. Visibility asks who can construct it. The type system asks what operations are valid for it. Result makes failure part of the function signature.

None of those concepts exists specifically for AI safety. Together, though, they provide an unusually direct vocabulary for designing agent boundaries, and the defaults nudge you toward making the boundary structural instead of remembered.

The Bigger Lesson

A lot of AI safety architecture is necessarily dynamic. Policies change. Users have different authority. Tools expose different capabilities. Context changes what an action means. We are never going to compile all of that uncertainty away.

But not every invariant is dynamic.

If an unwitnessed write must never be persisted, perhaps persist() should not accept unwitnessed writes. If an unevaluated tool call must never execute, perhaps execute() should not accept unevaluated tool calls. If a rejected action must never cross a boundary, perhaps rejection should produce a state for which crossing that boundary is not an available operation.

That is what Rust changed in how I think about this problem.

I started by asking:

How do I check that the agent is allowed to do this?

Rust made me ask:

Why does this function accept something the agent is not allowed to do?

That is a much better question.

Facebooktwitterredditlinkedinmail

Your AI Agent Shouldn’t Be Allowed to Write Whatever It Wants

Building a Write-Side Custody gate in Go

AI memory systems spend most of their design budget on retrieval. Which vector database? How should we chunk? Which embedding model? What should top_k be?

Those are useful questions, but they all arrive after something more consequential has already happened: the system decided that some piece of information deserved to become memory.

Consider an agent researching vendors for regulated workloads. It finds this statement:

Vendor X is approved for regulated workloads.

The source is Vendor X’s own marketing site.

The statement might be true. It might even be current. But the source does not have the authority to establish organizational security policy. If our agent writes it directly into durable memory, better retrieval will not save us. We have only made questionable evidence easier to find.

The problem is not storage. It is admission.

I’ve been calling the architectural boundary responsible for that decision Write-Side Custody. Let’s build a small one in Go.

What Write-Side Custody Does

A storage API answers a mechanical question:

Can I persist this object?

Write-Side Custody asks a different set:

  • Who is trying to write this?
  • Where did the information come from?
  • What authority is being claimed?
  • Is that source allowed to establish that authority?
  • Does policy permit this class of information to become durable?
  • What evidence should survive the decision?

Only after those are answered should storage become involved.

Flowchart showing a proposed write travelling from an agent or application into a Write-Side Custody gate. The gate routes accepted writes to durable memory and rejected writes to discard, while a dotted line records the decision in a Reasoning Ledger.

Note that the Reasoning Ledger does not make the decision. Custody enforces. The ledger witnesses. That separation matters a great deal once these systems have to be examined later.

Start With the Proposed Write

Go gives us a useful property for this experiment: we can make the things crossing our boundary explicit.

type ProposedWrite struct {
    Content          string
    Source           string
    ProducedBy       string
    ClaimedAuthority string
}

Our research agent might produce:

write := ProposedWrite{
    Content:          "Vendor X is approved for regulated workloads.",
    Source:           "https://vendorx.example.com/why-vendorx",
    ProducedBy:       "research-agent-run-4471",
    ClaimedAuthority: "security-policy",
}

Nothing in this structure says the statement is false, and that’s intentional. Write-Side Custody is not a universal truth detector. It determines whether a proposed write satisfies the rules governing this particular memory system.

For this system, a vendor marketing page cannot establish internal security policy. So we need policy.

Make Authority Explicit

First, two types. Authorities and source classes are different kinds of thing, and there is no situation in which we want to accidentally use one where the other belongs:

type Authority string
type SourceType string

Now we can define which source classes may establish which authorities:

type Policy struct {
    AuthoritySources map[Authority][]SourceType
}

var policy = Policy{
    AuthoritySources: map[Authority][]SourceType{
        "security-policy": {
            "internal-security-policy",
            "security-authority",
        },
        "user-preference": {
            "user",
        },
        "application-state": {
            "application",
            "runtime",
        },
    },
}

In production this comes from a policy service or configuration layer rather than a Go literal. The important part is that the relationship exists independently of whatever the agent claims. The agent does not get to decide that a marketing page constitutes security authority simply because it found one saying something useful.

Give the Gate a Verdict

type Verdict string

const (
    Allow Verdict = "ALLOW"
    Deny  Verdict = "DENY"
)

type CustodyDecision struct {
    Verdict   Verdict
    Reason    string
    Timestamp time.Time
}

Now the gate itself:

func EvaluateWrite(
    write ProposedWrite,
    sourceType SourceType,
    policy Policy,
) CustodyDecision {
    allowedSources, ok :=
        policy.AuthoritySources[Authority(write.ClaimedAuthority)]

    if !ok {
        return CustodyDecision{
            Verdict:   Deny,
            Reason:    "unknown claimed authority",
            Timestamp: time.Now().UTC(),
        }
    }

    for _, allowed := range allowedSources {
        if sourceType == allowed {
            return CustodyDecision{
                Verdict:   Allow,
                Reason:    "source may establish claimed authority",
                Timestamp: time.Now().UTC(),
            }
        }
    }

    return CustodyDecision{
        Verdict:   Deny,
        Reason:    "source cannot establish claimed authority",
        Timestamp: time.Now().UTC(),
    }
}

Two decisions in there are worth surfacing.

The first is that conversion on the map lookup. ProposedWrite holds plain strings because that’s what arrives over the wire, deserialized from JSON we did not write. Authority(write.ClaimedAuthority) is the moment an untrusted string becomes a term in our governance vocabulary, and it happens inside the gate rather than at the edge of the process. That’s the right place for it. Custody is precisely the layer where foreign input earns domain meaning.

The second is that sourceType is a separate parameter. It is not a field on ProposedWrite.

That is deliberate. Source classification is a judgment about the write, not a property the writer gets to assert about itself. If sourceType lived on the struct, our agent could label its own marketing page internal-security-policy and the gate would cheerfully agree. The classifier belongs to the custody layer, or to a runtime component that can independently observe where the content came from.

Small signature choice. Most of the security property.

Our vendor claim now reaches the boundary:

decision := EvaluateWrite(write, "vendor-marketing", policy)

fmt.Println(decision.Verdict)
fmt.Println(decision.Reason)

And receives:

DENY
source cannot establish claimed authority

The statement never becomes durable memory. We did not store questionable evidence and hope retrieval would eventually sort things out. We governed the write while the evidence and its provenance were still in hand.

The full gate, the policy, and a table-driven test suite covering the cases above are in memory-stack-patterns. Standard library only, so go test ./... and go run ./cmd/demo work on a clean checkout with nothing to install.

Don’t Throw Away the Rejection

Rejecting a write does not make the decision useless.

Imagine someone asks six months later:

Why doesn’t the system remember that Vendor X was approved?

“I don’t know” is not a satisfying answer, and in a regulated environment it isn’t an acceptable one either.

The custody decision is observable system behavior, which makes it a candidate for a Reasoning Ledger record:

type LedgerEntry struct {
    ID               string
    Timestamp        time.Time
    Actor            string
    Action           string
    Verdict          Verdict
    Reason           string
    Source           string
    ClaimedAuthority string
}

Our gate emits:

entry := LedgerEntry{
    ID:               newID(),
    Timestamp:        decision.Timestamp,
    Actor:            write.ProducedBy,
    Action:           "durable-memory-write",
    Verdict:          decision.Verdict,
    Reason:           decision.Reason,
    Source:           write.Source,
    ClaimedAuthority: write.ClaimedAuthority,
}

(newID is a few lines over crypto/rand, which keeps the whole example dependency-free.)

This entry is deliberately simplified. A ledger you would actually rely on needs a canonical serialization, a hash chain linking each entry to its predecessor, and some defense against tail truncation, because an append-only log that anyone can quietly shorten is not append-only.

Even the timestamp is less innocent than it looks. time.Now() gives you whatever precision the host clock offers, and JSON drops trailing zeros, so two entries can serialize at different widths. Hash a chain over a non-deterministic encoding and you have hashed nothing. The repo linked above truncates to a fixed precision and formats with a fixed-width layout for exactly that reason.

The Python implementation in the Sovereign Systems SDK does all three, which is part of why the Go exercise interests me. The hard parts are already solved somewhere. The open question is what happens to the boundary when it moves.

What matters here is the shape of what survives. The rejected statement still doesn’t enter memory. What persists is evidence that a write was proposed, evaluated, and rejected under a named rule. That is a different kind of information than the claim itself, and it’s the kind that answers questions later.

The Agent Doesn’t Get to Grade Its Own Homework

There’s a further boundary hiding in the payload. Suppose our agent sends:

{
  "content": "Vendor X is approved for regulated workloads.",
  "source": "https://vendorx.example.com/why-vendorx",
  "claimed_authority": "security-policy",
  "retrieval_method": "fresh",
  "policy_verified": true
}

Should we believe the last two fields?

There is an epistemic difference between:

The agent says it performed a fresh retrieval.

and:

The runtime that performed the HTTP request witnessed a fresh retrieval.

The same distinction applies to tool execution, timestamps, approval events, and policy versions. A stronger custody boundary therefore doesn’t only ask whether a record may be written. It asks:

Is this writer authorized to assert this particular kind of claim?

The agent legitimately owns claims about itself: its decision, the alternatives it considered, its confidence, the unknowns it identified. The runtime should mint the facts it can independently witness. Custody should not promote the former into the latter merely because both arrived in valid JSON.

This is the same principle as the sourceType parameter, applied one level up.

Why Do This at Write Time?

You could defer all of this to retrieval. Store everything, attach metadata, and let the reader decide what governs.

But then every questionable write becomes something every future reader has to reason around. It consumes storage. It becomes eligible for retrieval. It competes for context. It can be summarized, embedded, and propagated into records that no longer carry its provenance. And once provenance is gone, a future system may not have enough information to work out why the record was questionable in the first place.

A bad write today becomes bad context tomorrow.

Write-Side Custody puts the decision at the moment the system has the best possible view of what it is admitting.

Why Go?

None of this architecture requires Go, which is partly why I wanted to build it in Go.

A custody gate is a boundary service, and Go fits that role: explicit data structures, unremarkable HTTP services, small deployable binaries, and a type system strong enough to make the important distinctions visible without taking over the implementation.

The Authority and SourceType declarations we needed earlier are the clearest example. They cost one line each, and in exchange the compiler now refuses to let a source class be used where an authority belongs. That distinction would otherwise have lived in a variable name and a hope.

The same move applies elsewhere:

type Verdict string
type ActorType string

At which point the function signatures start expressing the vocabulary of the governance system rather than just its plumbing. EvaluateWrite doesn’t take three strings. It takes a proposed write, a source classification, and a policy, and no caller can shuffle them by accident.

Go didn’t create the architecture. It made the contracts hard to leave implicit.

Memory Begins Before Storage

Vector databases are very good at answering questions about similarity. They cannot tell us whether something deserved to become memory.

That’s an architecture decision, and by the time retrieval surfaces the problem, the questionable record may already have shaped dozens of others.

Give the proposed write provenance. Give the boundary policy. Give the decision evidence. Then let storage do what storage is good at.

Store what survived.


One thing I keep turning over: this boundary shouldn’t depend on Go. If Write-Side Custody only makes sense inside one language, it isn’t much of an architectural boundary. I’m curious what it would look like elsewhere. Would Rust’s type system make an invalid custody decision impossible to construct rather than merely inconvenient? Would Pydantic and FastAPI make the policy check feel so natural you’d stop noticing you were doing governance at all? If you’ve built something like this in your stack, I’d like to hear how the boundary changed shape.

Disclosure: I maintain the Sovereign Systems specification and SDK, which is where the vocabulary in this post comes from. The Go code here is a reference implementation written to test whether the idea travels, not a product.

Facebooktwitterredditlinkedinmail