Ownership: The Wall That Is Uniquely Rust
In most languages you never ask who owns a value or when it gets freed: garbage-collected languages handle it for you, and the rest teach you about dangling pointers and double frees the hard way. Rust builds a wall instead, and this wall is the first thing you'll walk into when reading Rust code. By the end of this chapter you should be able to answer:
- Given
chat_completionsfromgateway.rs, what dobody,request, andrequestedeach own, and on which line doesrequestgive up ownership? - Looking at
&HeaderMap,&str,&mut, and a bareChatRequestin a signature, can you classify each at a glance — takes, reads, or writes? - Why is the
self.0.clone()inprovider.rsa deliberate copy, and what breaks in the failover loop if you delete it?
Through the door: bytes become a value someone owns
An agent POSTs a JSON body to jackdaw's /v1/chat/completions. Inside the process, those bytes are a chunk of heap memory. Who frees it? What happens if it gets freed twice — or freed while something still points into it? Rust's answer is a single rule: every value has exactly one owner; when the owner goes out of scope, the value is freed, exactly once. That rule is ownership. Our storyline is the journey of those bytes, and the first stop is the gateway's handler function (the axum framework — Chapter 14):
async fn chat_completions(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
// ...(bearer token check omitted)
let request = match decode_request(&body) {
Ok(request) => request,
Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
};
let requested = request.model_field().to_string();
if request.wants_stream() {
stream_reply(&state, request, &requested).await
} else {
complete_reply(&state, request, &requested).await
}
}How to read it: start with the parameter list. body: Bytes means takes — axum hands the whole request body over as a fresh value, and this function answers for it to the end; headers: HeaderMap likewise. Treat State/Arc as boilerplate for passing in shared state (Chapter 14). The & in decode_request(&body) is a borrow: look, don't take. The resulting ChatRequest binds to request — from that line on, request is the owner of the parsed JSON.
The decoding step itself:
fn decode_request(body: &[u8]) -> Result<ChatRequest, String> {
let value: Value = serde_json::from_slice(body)
.map_err(|error| format!("request body is not valid JSON: {error}"))?;
if !value.is_object() {
return Err("request body must be a JSON object".to_string());
}
Ok(ChatRequest(value))
}How to read it: &[u8] is a read-only borrow of the bytes; from_slice parses into a brand-new Value (serde_json's any-JSON type; serde in Chapter 6), freshly allocated on the heap — not a view over the original bytes. ChatRequest(value) wraps it and hands it back along with the return value: ownership flows back into chat_completions through the return. You met Result in Chapter 4 — either Ok(value) or Err(a human message); here map_err just rewrites the error into a sentence.
A look or a home: &str vs String
Before any routing, the gateway needs to know which model the client asked for by name:
/// A chat request body, passed through untouched except for `model`.
#[derive(Debug, Clone)]
pub struct ChatRequest(pub Value);
impl ChatRequest {
// ...
/// The raw `model` string as received (for logs).
pub fn model_field(&self) -> &str {
self.0
.get("model")
.and_then(Value::as_str)
.unwrap_or("<missing>")
}
}How to read it: ChatRequest(pub Value) is a struct, and .0 is the JSON it wraps (#[derive(...)] has the compiler implement printing, cloning, and friends — Chapter 6). &self borrows self read-only; the returned &str is a string slice: a read-only view onto a few bytes inside request, copying nothing. The rules for how long a reference may live are its lifetime; the compiler infers them in most code, and for now recognizing the word is enough.
Now the line let requested = request.model_field().to_string();. Answer first: .to_string() is a copy. The next section shows request being handed over whole, and a &str view cannot outlive the data it points into; .to_string() copies the content into requested — a String that owns its bytes. Where the original goes after that, and when it's freed, is no longer requested's problem. Glance with &str, own with a String — that's the dividing line.
TIP
In stream_reply(&state, request, &requested) the argument is a &String but the parameter is a &str — Rust converts automatically, no .as_str() needed. Don't puzzle over it when reviewing an AI-generated diff.
move: hand it over, and the name is dead
In that same call, request carries no &: it is taken. This is a move — assignment or argument passing without & transfers ownership, and the old name is dead from that point on. Write request.wants_stream() after that line and the compiler refuses with use of moved value (E0382). Not a runtime guard — a wall at compile time. The storyline's exit sits at the provider:
#[async_trait::async_trait]
pub trait Provider: Send + Sync {
// ...
/// Send the request and wait for the full JSON response.
async fn complete(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError>;
// ...
}How to read it: trait is a contract of behavior, and async fn/.await means waiting out one network round trip — both in Chapter 7; the #[async_trait::async_trait] line above the trait is, for now, boilerplate — Chapter 7 takes apart why it must be there; skip Send + Sync on a first read; ProviderError is an enum (Chapter 6). The parameter is the point: request: ChatRequest without & — the provider takes the whole request, and router and gateway have no right to touch it again. The returned ChatResponse is a fresh value from upstream, and ownership flows back along the return. The full chain: bytes enter → parsed into request → moved into the router → moved to the provider → a new response value flows back.
Mutable borrows, and one deliberate Clone
On the routing side, a group/<id> can stand for several candidate models: if the first answers with a 429, the router tries the next (failover, Chapter 13). The body the client sends looks like this (illustrative), and with_model's job is to swap the group name for a real upstream model id:
{ "model": "group/backup", "stream": false, "messages": [ /* ... */ ] }/// A copy of the body with `model` set to the upstream model id.
///
/// One deliberate `clone` per candidate: the original request must stay
/// intact so the failover loop can rewrite it for the next model.
pub fn with_model(&self, model_id: &str) -> ChatRequest {
let mut body = self.0.clone();
// Insert through the object map (not `body["model"] = ...`, which
// panics on non-objects); callers guarantee an object body.
if let Value::Object(map) = &mut body {
map.insert("model".to_string(), Value::String(model_id.to_string()));
}
ChatRequest(body)
}How to read it: start at the .clone() line. self.0 is the original; .clone() deep-copies it, and the copy's owner is the local body — at that moment its model still reads group/backup. &mut body is a mutable borrow, the only one in this chapter: while map.insert swaps model for the upstream id, nothing else may read or write body. The final ChatRequest(body) hands the copy to the caller; the original was only ever read.
Why copy at all? The function's own comment answers: the failover loop must rewrite model again for the next candidate, so the original has to stay intact; take self by value instead, and the first failover consumes the original. .clone() never happens by accident — when you see one, someone decided "copy here" (clone); whether it's worth it depends on data size and frequency: once per candidate, once or twice per request — fine. The enforcement arm is the borrow checker: at any moment, either any number of read-only borrows or exactly one mutable borrow, checked at compile time. What it blocks isn't you — it's the dangling references and data races you'd otherwise be debugging at 2 a.m.
Signatures are documentation: takes, reads, writes
The whole chapter compresses into one cheat sheet. When you read any Rust signature, scan the parameters for & first:
| You see | Meaning | This chapter's example |
|---|---|---|
T (no &) | Takes: ownership moves in, the function answers for it | complete(&self, request: ChatRequest) |
&T | Read-only borrow: look, don't change, don't free | authorized(headers: &HeaderMap, token: &str) |
&mut T | Mutable borrow: exclusive write access | &mut body |
AI can write the code, but the signature is the route the data takes. In a diff review, the presence or absence of & tells you how data will move sooner than the function name does.
Recap
- Every value has exactly one owner: axum creates the bytes and hands them to the handler, the parsed JSON belongs to
request, andrequestmoves out at thestream_reply/complete_replycall — after stashing a copy of its own with.to_string(). &reads,&mutwrites, no&takes — that language is written into every signature..clone()is a deliberate copy; in jackdaw, the one you saw keeps the original intact for failover.
Next chapter upgrades values into types: structs and enums, and why wrapping ChatRequest in one thin layer pays for itself.
Self-Check
- You're coming from Python, where passing an object to a function and then going on to use it is normal. In
chat_completions, what happens if you writerequest.wants_stream()aftercomplete_reply(&state, request, &requested)? If you still need the value, what are your options? - Change
with_modelto takeselfby value (fn with_model(self, ...)), drop the.clone(), and writelet mut body = self.0;directly. Does the function itself still compile? Where does the failover loop break? - Why do both parameters of
authorized(headers: &HeaderMap, token: &str)use&? If you changed it to takeHeaderMapby value, wouldchat_completionsstill compile?
Answers
- It fails to compile:
use of moved value: request(E0382). Ownership transferred with the call; the name is dead. Your options: extract what you need before the move (exactly whatrequesteddoes in the source), or have the function take&ChatRequestinstead — whether you want to keep ownership decides which. - The function itself compiles. The caller breaks: the first call consumes the request, and by the second candidate there is no original left to rewrite — the compiler flags
use of moved valueat the call site. That is precisely the accident "One deliberatecloneper candidate" prevents: each candidate edits its own copy; the original stays intact. - Authentication only reads the headers and the token; taking them buys nothing. If
chat_completionsnever usesheadersafterauthorized, taking it by value still compiles — the move is legal, but it gives up the whole header map for nothing.&says "I'll look; I'm not responsible for freeing." When you read a signature,&is the first thing to look for.