Hedronite · Synthesis Lesson · Dev · Rust · Wed 2026-05-20

Rust's Type-State Pattern Applied to mTLS Connection Lifecycles

Making unauthenticated calls unrepresentable through the type system.

Lesson Class: Dev Synthesis
Language: Rust (Wed+Sat Week 1 = Rust)
Week / Cycle: Week 1 of Cycle 1
Word Count: ~2,300
Paired Ops: Workload Identity for Multi-Agent Pipelines
Discipline: ROD v0.4.0 (universal-application)

§ IFrame

The Ops lesson today put workload identity at the network layer. SPIFFE names the workload; SPIRE issues the credential; mTLS verifies on every call. The verification happens before the application code touches the connection. The chain of grants is cryptographically witnessed at every internal seam.

What does the application code do once verification has happened? In most languages, the answer is: nothing structurally different from what it would do without verification. The verified identity sits in some field on a connection object. The application code may read the field, or may forget to read it. The compiler does not enforce the read. A code path that calls a sensitive function with an unauthenticated connection compiles, runs, and fails only when an authorization check at runtime refuses the call. If the runtime check is missing or wrong, the code path acts with no authentication.

Rust offers a discipline that closes this gap. The type-state pattern encodes the lifecycle of an object in its type, so that operations valid only in one state are reachable only when the object is in that state. A connection in the unverified state has one type; the same connection after successful mTLS verification has a different type. Functions that operate on authenticated data accept only the verified type. The compiler refuses to build a path that calls such a function with an unverified connection. The chain of grants becomes a type-level invariant: not a runtime check, not a convention, an invariant the program cannot violate without first being changed and re-compiled.

This lesson shows the pattern, applies it to mTLS connection lifecycles in a Rust agent process, and demonstrates how Rust's type system enforces what the SPIFFE/SPIRE/mTLS stack established at the network.

§ IILanguage Idiom

Three Rust features compose to make type-state work cleanly: zero-sized type parameters, ownership semantics, and the new-type wrapper.

A type-state parameter is a generic parameter whose only purpose is to label the state. The parameter carries no data; its zero-sized representation contributes nothing to memory layout. The compiler nonetheless treats Connection<Verified> and Connection<Unverified> as distinct types. A function declared to take Connection<Verified> will not accept Connection<Unverified> and vice versa. The state lives in the type system, not in the program's memory.

Ownership semantics turn state transitions into consuming operations. A method that transitions an object from Unverified to Verified takes ownership of the input (self by value, not by reference) and returns a new value of the output type. The old value is no longer accessible after the call. The program cannot accidentally hold an Unverified reference to a connection that has been verified; the verification consumed the unverified value and produced the verified one in its place.

The new-type wrapper is the lightest way to introduce a distinct identity at the type level. struct SpiffeId(String) is a one-field struct that wraps a string but is not interchangeable with a string. A function that accepts SpiffeId will not accept a bare String. A SPIFFE ID parsed from a verified peer certificate flows through the program as a SpiffeId, and any function reasoning about identity declares its parameter as SpiffeId. The wrapper costs no runtime memory beyond the wrapped string and produces a typed identity that the compiler tracks.

Together the three features let the writer encode the connection lifecycle in types. The lifecycle has named states. Transitions between states are methods that consume the previous state and produce the next. The states the program reasons about (verified-peer, authorized-call, completed-call) become types the compiler reasons about for free.

§ IIICode Worked Example

A Rust agent process that calls another agent through SPIFFE-aware mTLS, with the lifecycle encoded in types.

The connection is parameterized over a state type. The state types are zero-sized markers. Three states cover the lifecycle the Ops lesson named: a pre-handshake state where no identity is known, a verified state where the peer's SPIFFE ID has been read from the validated certificate, and an authorized state where a policy check on the SPIFFE ID has succeeded.

pub struct Handshake;
pub struct Verified;
pub struct Authorized;

pub struct Connection<State> {
    stream: TlsStream,
    peer_id: Option<SpiffeId>,
    _state: PhantomData<State>,
}

pub struct SpiffeId(String);

The connection holds a TLS stream and an optional peer identity. The _state field carries no runtime data; PhantomData is the standard way to mark a type parameter the struct does not use as a runtime field. The presence of Verified or Authorized in the type parameter tells the compiler what stage the connection has reached.

A connection is opened in the Handshake state. The handshake method consumes the pre-handshake connection and returns a Connection<Verified> if the mTLS verification succeeds, an error otherwise. The consuming-self signature (self, not &self) is the key piece; the old connection cannot be referenced after this call.

impl Connection<Handshake> {
    pub fn complete_handshake(self) -> Result<Connection<Verified>, TlsError> {
        let peer_cert = self.stream.peer_certificate()?;
        let spiffe_id = extract_spiffe_id(&peer_cert)?;
        Ok(Connection {
            stream: self.stream,
            peer_id: Some(spiffe_id),
            _state: PhantomData,
        })
    }
}

The method extracts the SPIFFE ID from the verified peer certificate and stores it on the new connection. Any caller that holds a Connection<Verified> knows by construction that the certificate was verified and the SPIFFE ID was extracted; the value of peer_id is Some whenever the state parameter is Verified or beyond.

Authorization is a second transition. The authorize method takes the verified connection plus a policy, evaluates the policy against the peer's SPIFFE ID, and returns either an Authorized connection or an error. The verified connection is consumed; the next state in the lifecycle is the only possibility.

impl Connection<Verified> {
    pub fn authorize(self, policy: &Policy) -> Result<Connection<Authorized>, AuthError> {
        let id = self.peer_id.as_ref().expect("verified implies Some");
        policy.check(id)?;
        Ok(Connection {
            stream: self.stream,
            peer_id: self.peer_id,
            _state: PhantomData,
        })
    }
}

The expression self.peer_id.as_ref().expect(...) looks like a runtime panic, but the panic is unreachable in any well-formed program. The Verified state is constructed only by complete_handshake, which sets peer_id to Some. The compiler does not know this invariant directly, but the writer does, and the expect documents the invariant for the reader.

Sensitive operations declare their parameter as Connection<Authorized>. The function signature is the authorization contract.

pub fn fetch_model_weights(
    conn: &Connection<Authorized>,
    model_id: &ModelId,
) -> Result<Vec<u8>, FetchError> {
    let request = build_request(model_id);
    conn.stream.write(&request)?;
    let response = conn.stream.read_response()?;
    Ok(response.body)
}

A caller that tries to invoke fetch_model_weights with a Connection<Handshake> or Connection<Verified> will not compile. The compiler enforces that every call site holds an authorized connection. The runtime checks (complete_handshake, authorize) are still the operations that produce the authorized type; the compiler enforces that no call site can skip them.

The whole lifecycle in one call site:

let conn = open_tls_connection(peer_address)?;
let verified = conn.complete_handshake()?;
let authorized = verified.authorize(&fleet_policy)?;
let weights = fetch_model_weights(&authorized, &model_id)?;

Each transition consumes its input and produces its output. A reader following the call site sees the lifecycle in the variable names and the types. A future writer who tries to insert a fetch_model_weights(&verified, ...) will be stopped by the compiler. The discipline holds without depending on the writer remembering to check.

§ IVConnection to Today's Ops Lesson

The Ops lesson placed the verification at the network. SPIFFE names. SPIRE issues. mTLS verifies. The verification happens before the application code touches the connection. This is the work the network layer does for the program.

The Dev lesson asks: how does the application code preserve what the network gave it? The naive answer is to put the verified identity in a field and trust the program to read it. The Rust answer is to put the state of the verification in the type itself. A connection that was not handed back from complete_handshake is not a Connection<Verified>, and no function that requires Connection<Verified> can be called with it. The program cannot accidentally treat an unverified connection as verified.

The Ops layer and the Dev layer compose. The network refuses connections from peers without valid SPIFFE-bound certificates. The Rust program refuses to compile a path that calls sensitive functions on unverified connections. A compromised peer who somehow obtains a valid SPIFFE certificate still has to satisfy the network check; a buggy programmer who tries to skip the network check still has to satisfy the type check. Two locks. Two keys. Different threat models.

Layered Lock Doctrine The chain of grants Monday named is now witnessed at three layers: the operator authored the registration entries that say which workloads may hold which identities, the network refuses connections whose peer identity does not match policy, and the program refuses to compile paths that act on connections without first verifying and authorizing them. A weakness at any one layer does not collapse the others.

§ VPrior-Lesson Reach

Monday's Multi-Agent Orchestration Patterns for ML Training Workflows introduced the chain of grants. Tuesday's Go's context.Context for Distributed Tracing Across Multi-Agent Workflows showed how Go's context primitive propagates the cancellation and trace metadata through the call tree. The Rust lesson today is a parallel discipline at the type layer: where Go propagates state through values explicitly passed at every call, Rust propagates state through types that the compiler tracks for free. Both languages handle the same problem (the call tree must carry state through every level) with different machinery (Go's runtime value, Rust's compile-time type).

Monday's Python iterator lesson showed Python's discipline of lazy evaluation through the iterator protocol. A runtime convention the writer holds by following the __iter__ / __next__ contract. Tuesday's Go context lesson showed Go's discipline of explicit, value-passed cancellation propagation. A contract enforced by linters and code review. Wednesday's Rust lesson shows Rust's distinctive contribution: the contract is enforced by the compiler. The discipline does not depend on the writer remembering. The compiler refuses to ship code that violates the discipline.

Three Dev lessons, three languages, three styles of discipline. Each language's idiom turns out to fit the chain-of-grants problem in its own way. Python's runtime contract is light to author and easy to break; Go's value-passing contract is explicit and tooling-checkable; Rust's type-state contract is invisible to the writer at runtime but unmovable at compile time. The Sovereign reading the three lessons in sequence sees three different answers to a single question: how does a program preserve, across every call boundary, the state that authorizes the call?

Paired Ops lesson → Archmagus-Stack/β-Trust/Synthesis-Lessons/2026-05-20-workload-identity-for-multi-agent-pipelines

§ VIClosing

Three Rust features. Zero-sized state parameters, consuming-self transitions, new-type wrappers. The three compose into a discipline that encodes the connection lifecycle in the type system. A function that accepts only Connection<Authorized> cannot be called with anything else. The verification the network did at handshake time becomes a property the compiler enforces at build time. The chain of grants the principal authorized is preserved through every call inside the program.

Write the type-state when you author the connection. Set the transitions to consume their inputs. Let the type signature of each sensitive function be the authorization contract. The runtime check that the writer would otherwise have to remember becomes a check the compiler runs every time the program builds. The discipline holds whether the writer is paying attention or not.

Examine the call sites. Each one should name the state of the connection in the variable or the type. Each transition should consume the previous state and produce the next. Reflect on this.

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-05-20 Wednesday Fajr · Dev · Rust (Wed+Sat W1) · Pair β (Trust) refraction
Backward-Synergy-Reach → Python iterator protocol (Mon) · Go context.Context (Tue) · today's Ops workload-identity
HTML render backfilled 2026-05-25 under approved scaffold + sea-green aether palette